欢迎阅读“开发人员聚焦”新博客文章系列。在本系列中,我们将展示一些在 Cloudflare Workers 生态系统上构建的有趣应用程序。

为了庆祝 Durable Objects 正式发布,我们使用称为 Full Tilt 的 Durable Objects 技术演示来开始这一系列。这个演示十分炫酷,将为我们的新系列带来良好开端。

Guido Zuidhof 制作的 Full Tilt 是针对 Ludum Dare 的一个 Game Jam 项目,Ludum Dare 是最大和最古老的 Game Jam 之一,Guido Zuidhof 在此获得了创新类别的第一名。Game Jam 就像游戏的黑客松,您需要在很短的时间(通常为 48-72 小时)内从头开始创建游戏。

我们喜爱 Full Tilt,不仅仅是因为 Guido 采用 Workers 和 Durable Objects 来构建一个很酷的游戏(您可以通过手机控制计算机上的游戏),还因为它展示了 Durable Objects 的强大能力。在不到 48 小时的时间内,Guido 能够编写所有逻辑,在世界上尽可能靠近玩家的任何位置即时启动个人游戏服务器。其速度极快,让您感觉您正在直接控制计算机。

现在,就由 Guido 来讲述他开发 Full Tilt 的经历。

去年十月,我参加了 Ludum Dare 的第 49 届 Game Jam。我很喜欢 Game Jam 的一点是,时间限制会强制人们快速工作、经常迭代,最重要的是缩小范围。

在本次 Game Jam 中,我构建了 Full Tilt,在这个游戏中,您可以将手机用作类似 Wiimote 的运动控制器,无缝操控在笔记本电脑的 Web 浏览器中运行的游戏。使其能够如此顺畅的秘密武器就是将 Game Jam 和 Jamstack 结合起来,并混合一些 Durable Objects。

您可以在这里试玩游戏。

将手机用作控制器

Full Tilt 是一款浏览器游戏,您可以通过移动自己的手来移动玩家角色。我们可以通过几种方式来实现这一点。一个想法是使用计算机的网络摄像头在 3D 空间中跟踪一个标记对象。尽管这有可能实现,但在任何环境下,实际操作起来都有些麻烦(黑暗的环境就可能会存在问题!),而且部分玩家可能会觉得因为一款简单的 Web 游戏而打开网络摄像头感到不自在。

智能手机内置大量传感器,包括磁力计和回转仪 — 这些传感器正是我们所需要的。我们可以假设大部分潜在玩家身旁都有智能手机。当原生移动应用程序使用这些传感器时, 它会带来许多摩擦(这时候用户需要安装应用程序)以及大量实施工作。幸运的是,现代 Web 浏览器允许您通过 DeviceMotion API 从这些传感器读取数据:这是一款能够完美执行工作的小型 Web 应用程序!

接下来的挑战是将来自手机的传感器读数传达给在主要计算机上运行的游戏。为实现此目标,我们将结合使用 Cloudflare Workers 和 Durable Objects。这需要一些主要计算机和智能手机都与之通信的共用接触点(例如游戏服务器)。使用无服务器解决方案对于 Web 游戏十分有效。由于我们只有 48 个小时,需要担心的事情更少也是一个大卖点。Workers 和 Durable Objects 还可协助在 Game Jam 之后更轻松地让游戏保持运行,无需支付—或者说更重要的是担忧 — 让服务器保持运行。

设置通信线路

浏览器的通信大致有两种方式:通过共享连接(位于某处的游戏服务器)或对等连接,这样浏览器可以利用 WebRTC 数据通道彼此直接通信,而无需中间人。

使 WebRTC 变得可靠可能十分具有挑战性,而且对于多个 NAT 背后的一些特别有问题的网络设置,可能仍然需要代理服务器。设置 WebRTC 连接可能是获得最低可能延迟的最佳解决方案,但对于如此简短的 Game Jam,该方案则超出范围。

我们创建的游戏具有低延迟要求,当我倾斜我的手机,游戏应当快速作出响应。尽管较低的两位数或更低的延迟绝对是首选,但只要低于 100 毫秒,便可能足以称得上佳作!如果我位于某个地理位置,且游戏服务器位于足够近的地理位置,那么游戏服务器就能够以较短的延迟从我的智能手机传递消息到我的笔记本电脑。边缘计算如今十分流行,而对于该游戏用例,这仅仅只是“有则更好”,但并非必需。

背景情况介绍得差不多了,我们开始构建系统。

随需应变的游戏房间

我需要设置的第一项就是游戏房间。手机和计算机浏览器都应当能够连接到游戏房间,以便互相传递消息。

Durable Objects 允许我们创建大量小型的有状态“袖珍服务器”或“房间”,多个客户端可通过 WebSockets 同时与其连接,提供完美的小型按需游戏服务器。将 Durable Objects 视为一个单线程 JavaScript,它在最初客户要求创建该线程的位置附近的数据中心中运行。

当计算机上的浏览器开始游戏后,它要求我们的 Cloudflare Worker API 为当前游戏会话创建房间。该 API 请求是一个简单的 POST 请求,服务器使用四个字符的房间代码来响应,该房间代码唯一标识创建的房间。我们希望房间代码简短的原因是,当用户无法扫描 QR 码时,他们可能需要复制此代码并输入到智能手机中。

众所周知,人们并不擅长复写随机字符串,因为不同的字符看起来很相似,因此我们使用了有限的字符集,排除了最容易混淆的字符:

const DICTIONARY = "2345679ADEFGHJKLMNPQRSTUVWXYZ"; // 29 chars (no 0, O, I, 1, B, 8)

四个字符的代码让我们能够唯一标识大约 700,000 个不同的房间,这样一来,即使我们的游戏变得十分流行,看起来也已经足够!更何况,这些游戏会话并不会永远持续。在过去一段时间后(比如说 24 小时),我们可以确定该游戏会话已经结束,然后重新使用该房间代码。

房间代码协调

在您的 Cloudflare Worker 脚本中,有两种方法来创建 Durable Object:我们可以要求创建的 Durable Object 具有依据名称生成的 ID,也可以要求创建具有随机唯一 ID 的 Durable Object。最简朴的解决方案是,创建一个 Durable Object,使其具有依据房间代码生成的 ID。然而,在这里,这并不是一个好的想法,因为 Durable Object 在地理上靠近最终用户的数据中心中创建。

这样会出现一个有问题的情境,假设一个位于孟买的用户请求房间,并获得房间代码 ABCD。最初,只有少数人玩这个游戏,并没有什么问题。

但是一周后,问题出现了。当为身处洛杉矶的另一位玩家重复使用该房间代码时,该游戏房间 Durable Object 将在孟买复苏,而位于洛杉矶的玩家将面临可怕的延迟。未来,Durable Objects 可能会在数据中心间迁移,但这目前并不能保证。

取而代之,我们能做的是为每一个新游戏会话创建具有随机 ID 的新 Durable Object,并保持四个字符的房间代码与该随机 ID 的映射。我们在系统中引入了一些状态:我们需要一个真相中心来源,在这里,Durable Objects 可以再次发挥用途。

我们将创建单个“房间中心”Durable Object 跟踪从房间代码到 Durable Object ID 的这一映射,从而解决此问题。此 Durable Object 将有两个端点,一个用于请求新房间,另一个用于查找房间的信息。

下面是用于房间请求端点的请求处理器(参数是一个 Sunder Context,Sunder 是我用于此项目的 Web 框架):

export async function handleRoomRequest(ctx: Context<Env>) {
    const now = Date.now();    
    const reqBody = await ctx.request.json();

    // We make some attempts to find a room that is available..
    const attempts = 5

    let roomCode: string;
    let roomStorageKey: string;

    for (let i = 0; i < attempts; i++) {
        roomCode = generateRoomCode();
        roomStorageKey = ROOM_STATE_PREFIX + roomCode;
        const room = await ctx.state.storage.get<RoomData>(roomStorageKey);
        if (room === undefined) {
            break;
        } else if (now - room.createdAt > MAX_ROOM_AGE) {
            await ctx.state.storage.delete(roomStorageKey);
            break;
        }
        if (i === attempts-1) {
            return ctx.throw("Couldn't find available room code :(");
        }
    }

    const roomData: RoomData = {
        roomCode: roomCode,
        durableObjectId: reqBody.durableObjectId,
        createdAt: now,
    }

    await ctx.state.storage.put<RoomData>(roomStorageKey, roomData);

    ctx.response.body = {
        room: roomData
    };
    ctx.response.status = HttpStatus.Created;
}

简而言之,我们生成几个房间代码,直到找到一个从未用过或在足够长的时间内未使用的房间代码。

这段代码中有一个很微妙但很重要的玄妙之处:Durable Object 在与房间中心通信的 Cloudflare Worker 中创建,而不是在房间中心本身中创建。我们的房间中心将在 Cloudflare 网络上某个位置的单个数据中心中运行。如果我们从这里创建游戏房间,则可能仍然距离最终用户十分遥远!

查找房间的信息则更为简单,我们返回房间数据或状态 404。

export async function handleRoomLookup(ctx: Context<Env, {roomCode: string}>) {
    const now = Date.now();

    let roomStorageKey = ROOM_STATE_PREFIX + ctx.params.roomCode;
    const roomData = await ctx.state.storage.get<RoomData>(roomStorageKey);

    if (roomData === undefined) {
        ctx.throw(404, "Room not found");
        return;
    }

    if (now - roomData.createdAt > MAX_ROOM_AGE) {
        // About time we cleaned it up.
        await ctx.state.storage.delete(roomStorageKey);
        ctx.response.status = HttpStatus.NotFound;
        return;
    }

    ctx.response.body = {
        room: roomData
    };
}

智能手机浏览器需要连接到该相同房间。为了方便用户,我们生成四个字符的房间代码,指向该特定“游戏房间”Durable Object。这样,用户可以使用他们的智能手机导航到网站地址 https://ld49.pages.dev,并输入代码“ABCD”(或更为常用的是,他们可以扫描含有链接 https://ld49.pages.dev?room=ABCD 的 QR 码)。

游戏房间 Durable Object

我们的游戏房间 Durable Object 可能十分简单,因为它仅负责将消息与最新传感器读数从智能手机传递到笔记本电脑。我可以修改 Durable Objects 聊天室示例来实现这一功能,这对于 Game Jam 来说可以大大节省时间!

当浏览器连接后,它们可以作为“对等”或“主机”角色连接。从对等发出的所有消息都会转发至主机,从主机发出的所有消息也会转发至所有对等。在此示例中,我们的主机是运行游戏的笔记本电脑浏览器,对等是智能手机控制器。在实现方面,这意味着我们保持两个用户列表:对等和主机。每当有消息进来时,我们都会在列表中循环一遍,以将其广播给其他角色的所有连接。在实践中,会使用更多的代码来处理用户断开连接问题。

Full Tilt 是单人游戏,但使用此设置,可以轻松将其改进为多人游戏。想象一下,在您的浏览器中运行一款类似《马里奥赛车》的游戏,可以有多个好友共同参与游戏,并使用他们的智能手机作为方向盘控制器!遗憾的是,在本次 Game Jam 中,并没有足够的时间来制作一款精美的游戏。

前端

在后端准备就绪后,我们仍然需要创建实际游戏和控制器 Web 应用。

我最初的计划是制作一款可以通过倾斜手机来驾驶 3D 飞机的游戏,在游戏过程中收集星星并完成特技飞行技巧。这将会契合“不稳定”这一主题,因为我原本认为这种控制方法将会十分脆弱!由于所剩余时间不多,无法构建类似于此的内容,所以不得不缩减范围。

我最终使用 Phaser 游戏引擎,并将整个系统放在一个 Svelte 应用程序中。这确实是一个挑战,因为我只是在很多年前使用过 Phaser 一次,并且从未使用过 Svelte。幸运的是,我确实足够迅速地做出了一个简单的东西:一款像贪吃蛇一样的游戏,移动一个东西来收集屏幕上随机出现的光点。

为了让游戏成为一个游戏,需要为用户设置一些目标,通常是一些游戏结束条件。我不断调整这个游戏,使它随着时间的推移而变得越来越快,添加了一个记分器,然后增加一个游戏结束条件,当您点到屏幕边缘时,游戏就会失败。我在 MS Paint 中进行了我的“艺术创作”,使用在线工具 sfxr 生成了一些音效,并在 Chrome 的 Music Lab Song Maker 中“创作”了音乐配乐。

同时,我为游戏服务器编写了一个小型客户端,并将由浏览器的 DeviceMotion API 提供支持的智能手机控制器应用程序打包在一起。我使用 Cloudflare Pages 来分发我的游戏,这在第一次运行时就卓有成效。

全部完成

然后差不多就到截止时间了,我在截止时间前才堪堪完成任务,但我提交的作品让我感到骄傲。虽然这并不是一款非常棒的游戏,但胜在有有趣的后端系统和新颖的输入方法。请您进入这里亲自试玩一下这款游戏,源代码可在此处找到(警告:当然,这是非常令人头疼的代码!)。

和我一起参加 Jam 的人都反响良好。尽管大家都觉得游戏本身和图案都十分糟糕,但它很新颖。最终,在本次 Game Jam 的数百款游戏中,此款游戏被评为*创新*类别的第一名!

最后,这是游戏服务器的未来吗?对于独立开发人员和小型工作室来说,设置和维护全球分布的游戏服务器队伍既要耗费大量精力,还需高昂成本。无服务器,尤其是 Durable Objects 能够提供令人惊艳的解决方案。

但是并非每个游戏都适合基于 WebSocket 的后端。在一些实时游戏中,您不会对一秒钟之前发生的事情感兴趣,只有最新的信息才是重要的。这时,WebSockets 的可靠和有序的特性就会产生妨碍。

总而言之,我对 Durable Objects 的第一印象是非常正面的,这是一个需要掌握的重要工具,可以用于所有种类的 Web 项目。现在,您可以在几分钟之内解决那些原本需要几天才能解决的问题。我很期待 Durable Objects 还能够让哪些其他问题变得更为简单,甚至是那些我目前还没有想到的问题。

在 Twitter 上讨论 在 Hacker News 上讨论 在 Reddit 上讨论

Full Stack Week 开发人员聚焦 Cloudflare Workers Durable Objects