订阅以接收新文章的通知:

使用 Workers、Durable Objects 和 Unity 构建实时游戏

2021/05/25

14 分钟阅读时间

Durable Objects 是 Workers 开发者生态系统的一个很棒的补充,它允许您在特定的 Worker 中寻址和工作,以提供应用程序的一致性。这听起来很令人兴奋,但如果你像我一样,你可能想知道“好吧,我可以用它做什么?”

没有什么比用技术构建真实的东西更能真正理解它了。

为了更好地理解为什么 Durable Objects 很重要,以及像 WebSockets 这样的 Workers 生态系统中的新公告如何与 Durable Objects 一起玩,我转向了我几个月来一直在业余时间构建的一类软件:视频游戏。

在过去十年中,游戏的技术方面发生了巨大变化。许多游戏都是默认在线的,而像 Unity 这样无处不在的工具使任何人都可以开始尝试开发游戏。

我听说了很多关于 Durable Objects 和 WebSockets 在应用程序中提供实时一致性的能力,为了测试这个用例,我构建了 Durable World:一个简单的 3D 多人游戏世界,完全部署在我们的 Cloudflare 上堆栈:为客户端游戏提供服务的页面,在 Unity 和 WebGL 中运行,Worker 作为协调层,使用 Durable Objects 和 WebSockets 同步玩家位置和其他信息,例如随机生成的用户名。

3D 游戏往往看起来非常令人印象深刻—它们是伟大的技术演示。即使看到来自世界各地的人员与您联系并与您一起在地图上移动这一“令人惊叹的因素”,您也可能会惊讶于该项目的相应代码是多么简单。让我们深入探讨 Durable World 的客户端和服务器方面,然后我将就这样的项目在未来如何发展以及接下来我想探索的内容提出一些想法。

除了这篇博文之外,我们最近还在 Cloudflare 的博客上发表了一篇关于使用 WebAssembly 和 Durable Objects 的 Workers 的文章,展示了多人 Doom 端口。随着 Durable Objects、WebSockets 和 WebAssembly 等工具的添加,无论您是移植现有游戏还是构建全新游戏,Workers 上的游戏用例数量都非常多。

Durable World 是使用权威的客户端模型构建的。客户端直接在浏览器中运行已编译的游戏,内置在 WebAssembly 中,因此无需将特定于平台的客户端下载到本地机器即可运行。该服务器完全运行在 Cloudflare Workers 上,可以通过 WebSockets 进行交互,并使用 Durable Objects 来管理游戏状态。

与我们在博客中展示的 Doom 示例非常相似,由 Workers 应用程序管理的 Durable Object 充当消息路由器,接受来自客户端的游戏状态更改,并保留通过 Worker 接收这些更新的活动客户端列表。

管理连接:Character Durable Object

在开始这个项目之前,我最大的恐惧是使用 Durable Objects。尽管我从未用 Unity 制作过任何严肃的游戏,而且我甚至无法在没有对基本语法进行 Google 搜索的情况下定义 C# 变量,但有关 Durable Objects 的概念部分的某些内容仍然让我感到害怕,直到我开始编写实际代码的那一刻。

想象一下,当编写 Durable Objects 和使用 API 变得非常简单时,我的惊讶。

Character 模块是一个使用我们在 Workers 中的新模块支持的 Durable Object,它构建在我们的 modules-rollup-esm 模板之上。该模块处理传入的请求,并充当客户端的 WebSocket 提供商程序:

export class Character {
  constructor(state, env) {
    this.state = state;
    this.env = env
  }

  async initialize() {
    let stored = await this.state.storage.get("state");
    this.value = stored || { users: [], websockets: [] }
  }

  async handleSession(websocket, ip) {
    websocket.accept()
    // Game state code
  }

  // Handle HTTP requests from clients.
  async fetch(request) {
    if (!this.initializePromise) {
      this.initializePromise = this.initialize().catch((err) => {
        this.initializePromise = undefined;
        throw err
      });
    }
    await this.initializePromise;

    // Apply requested action.
    let url = new URL(request.url);

    switch (url.pathname) {
      case "/websocket":
        if (request.headers.get("Upgrade") != "websocket") {
          return new Response("Expected websocket", { status: 406 })
        }
        let ip = request.headers.get("CF-Connecting-IP");
        let pair = new WebSocketPair();
        await this.handleSession(pair[1], ip);
        return new Response(null, { status: 101, webSocket: pair[0] });
      case "/":
        break;
      default:
        return new Response("Not found", { status: 404 });
    }

    return new Response(this.value);
  }
}

其中大部分在概念上与我们的 websocket 模板相同—我们在传入请求中查找 Upgrade 标头,并设置一个 WebSocketPair,其中包含一个服务器和一个客户端 WebSocket。

handleSession 函数是我们大部分游戏特定逻辑发生的地方。在这种情况下,我们的 Durable Objects + WebSocket 代码有两个任务需要管理:第一,处理新玩家—给他们一个随机生成的用户名,并使用有效的 WebSocket 设置他们,第二,接受新的玩家位置,并广播这些位置给目前在游戏中的每个人。‘tick’ 函数用于向我们的客户端广播游戏状态,其余代码解析传入的数据,并确定哪些 WebSocket 客户端应该接收新数据。执行此操作的代码如下所示:

async tick(skipKey) {
  const users = this.value.users.filter(user => user.id !== skipKey)
  this.value.websockets
    .forEach(
      ({ id, name, websocket }) => {
        websocket.send(
          JSON.stringify({
            id,
            name,
            users
          })
        )
      }
    )
}

async key(ip) {
  const text = new TextEncoder().encode(`${this.env.SECRET}-${ip}`)
  const digest = await crypto.subtle.digest({ name: "SHA-256", }, text)
  const digestArray = new Uint8Array(digest)
  return btoa(String.fromCharCode.apply(null, digestArray))
}

constructName() {
  function titleCase(str) {
    return str.toLowerCase().split(' ').map(function (word) {
      return word.replace(word[0], word[0].toUpperCase());
    }).join(' ');
  }

  return titleCase(faker.fake("{{commerce.color}} {{hacker.adjective}} {{hacker.abbreviation}}"))
}

async handleSession(websocket, ip) {
  websocket.accept()

  try {
    let currentState = this.value;
    const key = await this.key(ip)

    const name = this.constructName()
    let newUser = { id: key, name, position: '0.0,0.0,0.0', rotation: '0.0,0.0,0.0' }
    if (!currentState.users.find(user => user.id === key)) {
      currentState.users.push(newUser)
      currentState.websockets.push({ id: key, name, websocket })
    }

    this.value = currentState
    this.tick(key)

    websocket.addEventListener("message", async msg => {
      try {
        let { type, position, rotation } = JSON.parse(msg.data)
        switch (type) {
          case 'POSITION_UPDATED':
            let user = currentState.users.find(user => user.id === key)
            if (user) {
              user.position = position
              user.rotation = rotation
            }

            this.value = currentState
            this.tick(key)

            break;
          default:
            console.log(`Unknown type of message ${type}`)
            websocket.send(JSON.stringify({ message: "UNKNOWN" }))
            break;
        }
      } catch (err) {
        websocket.send(JSON.stringify({ error: err.toString() }))
      }
    })

    const closeOrError = async evt => {
      currentState.users = currentState.users.filter(user => user.id !== key)
      currentState.websockets = currentState.websockets.filter(user => user.id !== key)
      this.value = currentState
      this.tick(key)
    }

    websocket.addEventListener("close", closeOrError)
    websocket.addEventListener("error", closeOrError)
  } catch (err) {
    websocket.send(JSON.stringify({ message: err.toString() }))
  }
}

在设置新的 WebSocketPair 时,Workers 函数会创建一个从用户 IP 地址派生的唯一 ID(尽管您可以轻松地使用 UUID 或其他任何内容),然后开始将 WebSocket 数据发送到新客户端。当数据传入时(例如一个新的玩家位置),该函数会查看是在发送数据,并将新信息发送到当前游戏中的所有其他的 WebSocket。

处理玩家位置和移动:在 Unity 中使用 Durable Objects 构建

对于像我这样的人来说,Unity 是一个很棒的游戏引擎:一个没有制作游戏经验的相当有经验的程序员。多年来,我一直断断续续地使用 Unity,但在过去的几个月里,我一直在深入研究它并扩展我对如何实际构建真实游戏的理解。

以下是您在构建 Durable World 的上下文中需要了解的有关 Unity 的内容:游戏对象Unity 中所有事物的首选,并且使用 C# 脚本,您可以为游戏对象编程不同的行为,无论是联网的还是玩家本地的。

在我们的游戏中,存在三种不同类型的游戏对象。首先,是世界本身—静态网格的集合,主要是立方体。这些网格在游戏的网络方面根本没有表现出来。通过一系列对撞机,可以停止在这些网格之上或周围导航的任何其他游戏对象从地板上掉下来和穿过墙壁移动。过去 20 年来,您在每款 3D 游戏中都看到了这种设计,包括像《超级马里奥 64》这样的经典游戏。

via GIPHY

在 Durable World 中,您的玩家游戏对象是一个简单的胶囊。此形状内置于 Unity 中,通过附加 C# 脚本,我们可以使用键盘控件进行基本移动(在我的情况下,我使用了Brackey 的本教程)。

多人游戏角色被表示为同一玩家胶囊的简化版本。与将任何类型的输入逻辑(键盘、鼠标等)附加到这些游戏对象不同,它们在 3D 空间中的位置的关键方面—即位置和旋转—由 WebSocket 客户端管理。

游戏开始时,您被置于单人游戏环境中:您的角色可以在静态 3D 世界中移动。一旦游戏连接到 Workers 并接收到一个 WebSocket,它就可以开始在多人游戏环境中运行了。这是在世界启动之前的线框图:

当谈到项目的实际代码时,连接方面非常简单:在游戏开始时创建一个连接单例,它使用 WebSocket 类连接到 Worker,并在新的 WebSocket 更新上调用各种函数。您可以在此处找到完整的代码,但我将在下面总结重要部分。

首先,我们需要将玩家的位置发送回 Workers。这发生在一个循环中,每 0.2 秒调用一次。UpdatePosition 函数获取玩家位置和旋转,将它们编码为 JSON,并将数据发送到 WebSocket。请注意,通过每 0.2 秒发送一次位置,我们有效地构建了一个以每秒 5 帧更新的播放器。考虑到大多数游戏至少每秒运行 30 帧,如果不是更高,这将是我们稍后将使用插值解决的问题。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using NativeWebSocket;

public class Connection : MonoBehaviour
{
  WebSocket websocket;

  // Start is called before the first frame update
  void Start()
  {
    Connect();
  }

  async void Connect()
  {
    retries += 1;

    if (maxRetries < retries)
    {
      return;
    }

    websocket = new WebSocket("wss://durable-world.signalnerve.workers.dev/websocket");

    websocket.OnOpen += () =>
    {
      Debug.Log("Connection open!");
    };

    websocket.OnError += (e) =>
    {
      Debug.Log("Error! " + e);
      Connect();
    };

    websocket.OnClose += (e) =>
    {
      Debug.Log("Connection closed!" + e);
      Connect();
    };

    websocket.OnMessage += (bytes) =>
    {
      // Do things with new messages
    };

    // Keep sending messages at every 0.2 seconds
    InvokeRepeating("UpdatePosition", 0.0f, 0.2f);

    // waiting for messages
    await websocket.Connect();
  }

  void Update()
  {
#if !UNITY_WEBGL || UNITY_EDITOR
    websocket.DispatchMessageQueue();
#endif
  }

  async void UpdatePosition()
  {
    if (websocket.State == WebSocketState.Open)
    {
      var currentPos = player.transform.position;
      if (currentPos == lastPosition)
      {
        return;
      }

      PlayerPosition playerPosition = new PlayerPosition();
      playerPosition.position = $"{currentPos.x},{currentPos.y},{currentPos.z}";
      var currentRot = player.transform.rotation;
      playerPosition.rotation = $"{currentRot.eulerAngles.x},{currentRot.eulerAngles.y},{currentRot.eulerAngles.z}";
      playerPosition.type = "POSITION_UPDATED";
      await websocket.SendText(JsonUtility.ToJson(playerPosition));
      lastPosition = currentPos;
    }
  }

  private async void OnApplicationQuit()
  {
    await websocket.Close();
  }
}

接下来,我们需要监听当前在游戏中的其他玩家。为了解决这个问题,我们监听来自 Workers 的传入 WebSocket 消息。每条消息都将包含我们的整个游戏状态(我们将来肯定可以优化),我们可以解析并使用它们来决定我们的本地游戏版本应该如何更新。对于我们的 gameState 负载中的每个用户,我们可以创建一个玩家的新实例,并开始在本地跟踪它。我们还可以在 CreateClient 中更新位置、旋转并设置一个指示玩家姓名的简单 UI 元素:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using NativeWebSocket;

public class Connection : MonoBehaviour
{
  async void Connect()
  {
    // Truncated code

    websocket.OnMessage += (bytes) =>
    {
      var payload = System.Text.Encoding.UTF8.GetString(bytes);
      GameState gameState = JsonUtility.FromJson<GameState>(payload);

      foreach (var user in gameState.users)
      {
        try
        {
          if (user.id == gameState.id)
          {
            continue;
          }

          Client client;
          if (!Clients.TryGetValue(user.id, out client))
          {
            client = CreateClient(user);
          }

          var rt = user.rotation.Split(","[0]); // gets 3 parts of the vector into separate strings
          var rtx = float.Parse(rt[0]);
          var rty = float.Parse(rt[1]);
          var rtz = float.Parse(rt[2]);
          var newRot = Quaternion.Euler(rtx, rty, rtz);
          client.interpolateMovement.endRotation = newRot;

          var pt = user.position.Split(","[0]); // gets 3 parts of the vector into separate strings
          var ptx = float.Parse(pt[0]);
          var pty = float.Parse(pt[1]);
          var ptz = float.Parse(pt[2]);
          var newPos = new Vector3(ptx, pty, ptz);
          client.interpolateMovement.endPosition = newPos;
        }
        catch (Exception e)
        {
          Debug.Log(e);
        }
      }

      TMPro.TextMeshProUGUI text = onlineText.GetComponent<TMPro.TextMeshProUGUI>();
      text.text = $"Online: {gameState.users.Length + 1}\\nPlaying as {gameState.name}";
    };

    // Keep sending messages at every 0.2 seconds
    InvokeRepeating("UpdatePosition", 0.0f, 0.2f);

    // waiting for messages
    await websocket.Connect();
  }

  Client CreateClient(User user)
  {
    var newClient = new Client();
    newClient.id = user.id;
    var otherPlayer = Instantiate(otherPlayerPrefab, new Vector3(0, 0, 0), Quaternion.identity);
    otherPlayer.name = user.id;

    TMPro.TextMeshPro text = otherPlayer.GetComponentInChildren<TMPro.TextMeshPro>();
    text.text = user.name;

    newClient.playerObject = otherPlayer;
    newClient.interpolateMovement = otherPlayer.GetComponent<InterpolateMovement>();
    Clients.Add(user.id, newClient);
    return newClient;
  }

  // Truncated code
}

设置所有这些代码后,我们建立了一个简单的系统,用于将我们的本地玩家位置发送给 Workers。当我的玩家位置更新时,游戏中的其他所有人都会收到该位置作为更大游戏状态负载的一部分,并相应地更新每个玩家的本地副本。

我提到这些更新每 0.2 秒发生一次。游戏预计每秒至少更新 30 次,甚至更多:现代游戏通常预计以每秒 60 帧的速度运行,并且更新速度极快。

正是因为这种期望,我们需要为我们的玩家插入运动。而不是每秒发送玩家更新六十次,这将是对我们的持久对象一个巨大的负荷,我们可以看一下传入的新位置或旋转的对象,并使用一些数学运算来平滑移动,从玩家所在的位置到他们要去的地方 Unity(以及许多其他游戏引擎)通过诸如 SmoothDamp 之类的 API 提供这种行为—一种用于随着时间的推移平滑快速、不和谐的运动—如下面的 InterpolateMovement 脚本所示,该脚本用于管理玩家位置和旋转:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InterpolateMovement : MonoBehaviour
{
  public Vector3 endPosition;
  public Quaternion endRotation;

  public float rotationSmoothTime = 0.3f;
  public float positionSmoothTime = 0.6f;
  private Vector3 posVelocity = Vector3.zero;
  private float rotVelocity = 0.0f;

  void Update()
  {
    transform.position = Vector3.SmoothDamp(transform.position, endPosition, ref posVelocity, positionSmoothTime);

    float delta = Quaternion.Angle(transform.rotation, endRotation);
    if (delta > 0f)
    {
      float t = Mathf.SmoothDampAngle(delta, 0.0f, ref rotVelocity, rotationSmoothTime);
      t = 1.0f - (t / delta);
      transform.rotation = Quaternion.Slerp(transform.rotation, endRotation, t);
    }
  }
}

下一步

边缘的 Durable Objects 和 WebSockets 等工具的可用性开启了我们可以使用 Cloudflare Workers 构建的一类新应用程序。游戏只是一个用例,我们才刚刚开始探索边缘实时、高度交互游戏的可能性。如果您有兴趣查看 Durable World 的源代码,可以在 GitHub 上查看。如果您想聊聊 Workers、Durable Objects 或其他任何探索我们可以在分布式无服务器环境中构建的新的、有趣的东西,请加入我们的 Cloudflare Workers Discord

我们保护整个企业网络,帮助客户高效构建互联网规模的应用程序,加速任何网站或互联网应用程序抵御 DDoS 攻击,防止黑客入侵,并能协助您实现 Zero Trust 的过程

从任何设备访问 1.1.1.1,以开始使用我们的免费应用程序,帮助您更快、更安全地访问互联网。要进一步了解我们帮助构建更美好互联网的使命,请从这里开始。如果您正在寻找新的职业方向,请查看我们的空缺职位
简体中文Cloudflare Workers (CN)

在 X 上关注

Kristian Freeman|@kristianf_
Cloudflare|@cloudflare

相关帖子