如何从零开始构建实时多用户游戏
已发表: 2022-03-10随着大流行的持续,与我一起工作的突然偏远的团队变得越来越缺乏桌上足球。 我想过如何在远程环境中玩桌上足球,但很明显,仅仅在屏幕上重建桌上足球的规则并不会很有趣。
有趣的是用玩具车踢球——这是我在和我 2 岁的孩子玩耍时意识到的。 同一天晚上,我开始为一款后来成为Autowuzzler的游戏构建第一个原型。
这个想法很简单:玩家在一个类似于桌上足球的自上而下的竞技场中驾驶虚拟玩具车。 先进 10 球的球队获胜。
当然,使用汽车踢足球的想法并不是独一无二的,但Autowuzzler应该有两个主要想法:我想重建在物理桌上足球桌上玩的一些外观和感觉,我想确保它是尽可能轻松地邀请朋友或队友参加快速休闲游戏。
在本文中,我将描述创建 Autowuzzler 背后的过程,我选择了哪些工具和框架,并分享了一些实现细节和我学到的经验教训。
第一个工作(糟糕)原型
第一个原型是使用开源游戏引擎 Phaser.js 构建的,主要用于包含的物理引擎,因为我已经有一些使用它的经验。 游戏阶段嵌入到 Next.js 应用程序中,这也是因为我已经对 Next.js 有了深入的了解,并且希望主要专注于游戏。
由于游戏需要实时支持多个玩家,我使用 Express 作为 WebSockets 代理。 不过,这就是它变得棘手的地方。
由于在 Phaser 游戏中物理计算是在客户端完成的,我选择了一个简单但明显有缺陷的逻辑:第一个连接的客户端有权对所有游戏对象进行物理计算,并将结果发送到快速服务器,进而将更新的位置、角度和力量广播回其他玩家的客户端。 然后其他客户端会将更改应用到游戏对象。
这导致了第一个玩家实时看到物理发生的情况(毕竟它是在他们的浏览器本地发生的),而所有其他玩家至少落后 30 毫秒(我选择的广播速率),或者——如果第一个玩家的网络连接很慢——更糟。
如果这对你来说听起来像是糟糕的架构——你是绝对正确的。 然而,我接受了这个事实,赞成快速获得一些可玩的东西来确定游戏是否真的很有趣。
验证想法,放弃原型
尽管实施存在缺陷,但它足以邀请朋友进行第一次试驾。 反馈非常积极,主要关注点——毫不奇怪——实时性能。 其他固有问题包括当第一个玩家(记住,负责一切的人)离开游戏时的情况——谁应该接手? 此时只有一个游戏室,所以任何人都会加入同一个游戏。 我也有点担心 Phaser.js 库引入的包大小。
是时候放弃原型并从新的设置和明确的目标开始了。
项目设置
显然,需要用游戏状态存在于服务器上的解决方案来取代“第一客户端统治一切”的方法。 在我的研究中,我遇到了 Colyseus,这听起来像是完成这项工作的完美工具。
对于我选择的游戏的其他主要构建块:
- Matter.js 作为物理引擎而不是 Phaser.js,因为它在 Node 中运行,而 Autowuzzler 不需要完整的游戏框架。
- SvelteKit 作为应用程序框架而不是 Next.js,因为当时它刚刚进入公测阶段。 (另外:我喜欢和 Svelte 一起工作。)
- Supabase.io 用于存储用户创建的游戏 PIN。
让我们更详细地看一下这些构建块。
与 Colyseus 同步、集中的游戏状态
Colyseus 是一个基于 Node.js 和 Express 的多人游戏框架。 它的核心是:
- 以权威的方式跨客户端同步状态;
- 通过仅发送更改的数据,使用 WebSockets 进行高效的实时通信;
- 多房间设置;
- JavaScript、Unity、Defold Engine、Haxe、Cocos Creator、Construct3 的客户端库;
- 生命周期钩子,例如创建房间、用户加入、用户离开等;
- 以广播消息的形式向房间内的所有用户或单个用户发送消息;
- 内置监控面板和负载测试工具。
注意: Colyseus 文档通过提供npm init
脚本和示例存储库,使您可以轻松地开始使用准系统 Colyseus 服务器。
创建模式
Colyseus 应用程序的主要实体是游戏房间,它保存单个房间实例及其所有游戏对象的状态。 在Autowuzzler的情况下,这是一个游戏会话:
- 两队,
- 有限数量的玩家,
- 一球。
需要为应该跨客户端同步的游戏对象的所有属性定义模式。 例如,我们希望球同步,因此我们需要为球创建一个模式:
class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });
在上面的示例中,创建了一个扩展 Colyseus 提供的模式类的新类; 在构造函数中,所有属性都接收一个初始值。 使用五个属性来描述球的位置和运动: x
、 y
、 angle
、 velocityX,
velocityY
。 此外,我们需要指定每个属性的类型。 此示例使用 JavaScript 语法,但您也可以使用稍微紧凑的 TypeScript 语法。
属性类型可以是原始类型:
-
string
-
boolean
-
number
(以及更有效的整数和浮点类型)
或复杂类型:
-
ArraySchema
(类似于 JavaScript 中的 Array) -
MapSchema
(类似于 JavaScript 中的 Map) -
SetSchema
(类似于 JavaScript 中的 Set) -
CollectionSchema
(类似于 ArraySchema,但不控制索引)
上面的Ball
类有五个number
类型的属性:它的坐标( x
, y
),它的当前angle
和速度向量( velocityX
, velocityY
)。
玩家的模式类似,但包含更多属性来存储玩家的姓名和球队的号码,在创建 Player 实例时需要提供这些属性:
class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });
最后, Autowuzzler Room
的模式连接了之前定义的类:一个房间实例有多个团队(存储在 ArraySchema 中)。 它还包含一个球,因此我们在 RoomSchema 的构造函数中创建一个新的 Ball 实例。 玩家存储在 MapSchema 中,以便使用他们的 ID 快速检索。
多房间设置(“配对”)
只要拥有有效的游戏 PIN,任何人都可以加入Autowuzzler游戏。 我们的 Colyseus 服务器会在第一个玩家加入时为每个游戏会话创建一个新的 Room 实例,并在最后一个玩家离开时丢弃该房间。
将玩家分配到他们想要的游戏房间的过程称为“匹配”。 Colyseus 在定义新房间时使用filterBy
方法可以很容易地设置:
gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);
现在,任何使用相同游戏gamePIN
加入游戏的玩家(我们稍后会看到如何“加入”)最终会进入同一个游戏房间! 任何状态更新和其他广播消息都仅限于同一房间的玩家。
Colyseus 应用程序中的物理
Colyseus 提供了许多开箱即用的功能,可以通过权威的游戏服务器快速启动和运行,但让开发人员来创建实际的游戏机制——包括物理机制。 我在原型中使用的 Phaser.js 无法在非浏览器环境中执行,但 Phaser.js 的集成物理引擎 Matter.js 可以在 Node.js 上运行。
使用 Matter.js,您可以定义具有某些物理属性(如大小和重力)的物理世界。 它提供了几种创建原始物理对象的方法,这些对象通过遵守(模拟的)物理定律(包括质量、碰撞、摩擦运动等)相互作用。 您可以通过施加力来移动物体——就像在现实世界中一样。
一个 Matter.js 的“世界”是Autowuzzler游戏的核心; 它定义了汽车的移动速度、球的弹性、球门的位置以及如果有人射门会发生什么。
let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);
在 Matter.js 中将“球”游戏对象添加到舞台的简化代码。
一旦定义了规则,Matter.js 就可以运行,无论是否将某些内容实际渲染到屏幕上。 对于Autowuzzler ,我正在利用此功能为服务器和客户端重用物理世界代码 - 有几个关键区别:
服务器上的物理世界:
- 通过 Colyseus 接收用户输入(用于驾驶汽车的键盘事件)并对游戏对象(用户的汽车)施加适当的力;
- 对所有对象(球员和球)进行所有物理计算,包括检测碰撞;
- 将每个游戏对象的更新状态传达回 Colyseus,然后 Colyseus 将其广播给客户端;
- 每 16.6 毫秒(= 60 帧/秒)更新一次,由我们的 Colyseus 服务器触发。
客户端上的物理世界:
- 不直接操纵游戏对象;
- 从 Colyseus 接收每个游戏对象的更新状态;
- 在接收到更新状态后应用位置、速度和角度的变化;
- 将用户输入(用于驾驶汽车的键盘事件)发送到 Colyseus;
- 加载游戏精灵并使用渲染器将物理世界绘制到画布元素上;
- 跳过碰撞检测(对对象使用
isSensor
选项); - 使用 requestAnimationFrame 更新,理想情况下为 60 fps。
现在,所有的魔法都发生在服务器上,客户端只处理输入并将它从服务器接收到的状态绘制到屏幕上。 除了一个例外:
客户端插值
由于我们在客户端上重用了相同的 Matter.js 物理世界,我们可以通过一个简单的技巧来提高体验性能。 我们不仅更新游戏对象的位置,还同步对象的速度。 这样,即使来自服务器的下一次更新需要比平时更长的时间,对象也会继续沿其轨迹移动。 因此,我们不是以离散的步骤将对象从位置 A 移动到位置 B,而是改变它们的位置并使它们朝某个方向移动。
生命周期
Autowuzzler Room
类是处理与 Colyseus 房间不同阶段相关的逻辑的地方。 Colyseus 提供了几种生命周期方法:
-
onCreate
:创建新房间时(通常是在第一个客户端连接时); -
onAuth
:作为允许或拒绝进入房间的授权钩子; -
onJoin
:当客户端连接到房间时; -
onLeave
:当客户端与房间断开连接时; -
onDispose
: 当房间被丢弃时。
Autowuzzler房间在创建后立即创建物理世界的新实例(参见“Colyseus 应用程序中的物理”一节),并在客户端连接onJoin
onCreate
。 然后它使用setSimulationInterval
方法(我们的主游戏循环)每秒更新物理世界 60 次(每 16.6 毫秒):
// deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));
物理对象独立于 Colyseus 对象,这给我们留下了同一个游戏对象(如球)的两种排列,即物理世界中的一个对象和一个可以同步的 Colyseus 对象。
一旦物理对象发生变化,就需要将其更新的属性应用回 Colyseus 对象。 我们可以通过监听 Matter.js 的afterUpdate
事件并从那里设置值来实现:
Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })
我们需要处理的对象还有一个副本:面向用户的游戏中的游戏对象。
客户端应用程序
现在我们在服务器上有一个应用程序可以处理多个房间的游戏状态同步以及物理计算,让我们专注于构建网站和实际的游戏界面。 Autowuzzler前端有以下职责:
- 使用户能够创建和共享游戏 PIN 以访问各个房间;
- 将创建的游戏 PIN 发送到 Supabase 数据库以进行持久化;
- 提供可选的“加入游戏”页面供玩家输入游戏密码;
- 当玩家加入游戏时验证游戏 PIN;
- 在可共享(即唯一)的 URL 上托管和呈现实际游戏;
- 连接到 Colyseus 服务器并处理状态更新;
- 提供登陆(“营销”)页面。
为了实现这些任务,我选择了 SvelteKit 而不是 Next.js,原因如下:
为什么选择 SvelteKit?
自从我构建 neolightsout 以来,我一直想使用 Svelte 开发另一个应用程序。 当 SvelteKit(Svelte 的官方应用程序框架)进入公开测试版时,我决定用它构建Autowuzzler ,并接受使用新测试版带来的任何麻烦——使用 Svelte 的乐趣显然弥补了这一点。
这些关键特性让我选择了 SvelteKit 而不是 Next.js 来实际实现游戏前端:
- Svelte 是一个 UI 框架和一个编译器,因此在没有客户端运行时的情况下发布了最少的代码;
- Svelte 具有富有表现力的模板语言和组件系统(个人喜好);
- Svelte 包括开箱即用的全局存储、过渡和动画,这意味着:选择全局状态管理工具包和动画库不会导致决策疲劳;
- Svelte 支持单文件组件中的作用域 CSS;
- SvelteKit 支持 SSR、简单但灵活的基于文件的路由和用于构建 API 的服务器端路由;
- SvelteKit 允许每个页面在服务器上运行代码,例如获取用于呈现页面的数据;
- 跨路线共享的布局;
- SvelteKit 可以在无服务器环境中运行。
创建和存储游戏 PIN
在用户开始玩游戏之前,他们首先需要创建一个游戏 PIN。 通过与其他人共享 PIN,他们都可以访问同一个游戏室。
这是 SvelteKits 服务器端端点与 Sveltes onMount 函数结合的一个很好的用例:端点/api/createcode
生成一个游戏 PIN,将其存储在 Supabase.io 数据库中,并将游戏 PIN 作为响应输出。 这是“创建”页面的页面组件安装后立即获取的响应:
使用 Supabase.io 存储游戏 PIN
Supabase.io 是 Firebase 的开源替代品。 Supbase 使得创建 PostgreSQL 数据库并通过其客户端库之一或通过 REST 访问它变得非常容易。
对于 JavaScript 客户端,我们导入createClient
函数并使用我们在创建数据库时收到的参数supabase_url
和supabase_key
执行它。 要存储在每次调用createcode
端点时创建的游戏 PIN ,我们需要做的就是运行这个简单的insert
查询:
import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);
注意: supabase_url
和supabase_key
存储在 .env 文件中。 由于 Vite(SvelteKit 的核心构建工具),需要在环境变量前加上 VITE_ 前缀,以使它们在 SvelteKit 中可访问。
访问游戏
我想让加入Autowuzzler游戏变得像点击链接一样简单。 因此,每个游戏房间都需要根据之前创建的游戏 PIN 拥有自己的 URL ,例如 https://autowuzzler.com/play/12345。
在 SvelteKit 中,通过在命名页面文件时将路由的动态部分放在方括号中来创建具有动态路由参数的页面: client/src/routes/play/[gamePIN].svelte
。 然后, gamePIN
参数的值将在页面组件中可用(有关详细信息,请参阅 SvelteKit 文档)。 在play
路由中,我们需要连接到 Colyseus 服务器,实例化物理世界以渲染到屏幕,处理游戏对象的更新,监听键盘输入并显示其他 UI,如乐谱等。
连接到 Colyseus 并更新状态
Colyseus 客户端库使我们能够将客户端连接到 Colyseus 服务器。 首先,让我们创建一个新的Colyseus.Client
,将其指向 Colyseus 服务器(开发中的ws://localhost:2567
)。 然后使用我们之前选择的名称 ( autowuzzler
) 和 route 参数中的gamePIN
加入房间。 gamePIN
参数确保用户加入正确的房间实例(参见上面的“匹配”)。
let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });
由于 SvelteKit 最初在服务器上呈现页面,因此我们需要确保此代码仅在页面加载完成后才在客户端上运行。 同样,我们为该用例使用onMount
生命周期函数。 (如果你熟悉 React, onMount
类似于带有空依赖数组的useEffect
钩子。)
onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })
现在我们已连接到 Colyseus 游戏服务器,我们可以开始监听游戏对象的任何更改。
下面是一个如何监听玩家加入房间( onAdd
) 并接收该玩家的连续状态更新的示例:
this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };
在物理世界的updatePlayer
方法中,我们一个一个地更新属性,因为 Colyseus 的onChange
传递了一组所有改变的属性。
注意:此功能仅在物理世界的客户端版本上运行,因为游戏对象仅通过 Colyseus 服务器间接操作。
updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }
相同的过程适用于其他游戏对象(球和球队):监听它们的变化并将变化的值应用到客户端的物理世界。
到目前为止,没有物体在移动,因为我们仍然需要监听键盘输入并将其发送到服务器。 我们不是在每个keydown
事件上直接发送事件,而是维护当前按下的键的映射,并在 50ms 循环中将事件发送到 Colyseus 服务器。 这样,我们可以支持同时按下多个键,并减轻在第一个和连续的keydown
事件之后发生的暂停:
let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);
现在这个循环就完成了:监听击键,向 Colyseus 服务器发送相应的命令,对服务器上的物理世界进行操作。 然后 Colyseus 服务器将新的物理属性应用于所有游戏对象,并将数据传播回客户端以更新面向用户的游戏实例。
小麻烦
回想起来,没有人告诉我但有人应该想到的两件事:
- 很好地理解物理引擎的工作原理是有益的。 我花了相当多的时间来微调物理属性和约束。 尽管我之前使用 Phaser.js 和 Matter.js 构建了一个小游戏,但为了让对象按照我想象的方式移动,我还是经历了很多试错。
- 实时很难——尤其是在基于物理的游戏中。 轻微的延迟会大大降低体验,虽然使用 Colyseus 跨客户端同步状态效果很好,但它无法消除计算和传输延迟。
SvelteKit 的陷阱和注意事项
由于我在 SvelteKit 刚从 beta 烤箱中出来时使用它,因此我想指出一些问题和注意事项:
- 花了一段时间才弄清楚环境变量需要以 VITE_ 为前缀才能在 SvelteKit 中使用它们。 现在在常见问题解答中正确记录了这一点。
- 要使用 Supabase,我必须将 Supabase 添加到 package.json的
dependencies
项和devDependencies
列表中。 我相信情况不再如此。 - SvelteKits
load
函数在服务器和客户端都运行! - 要启用完整的热模块替换(包括保留状态),您必须在页面组件中手动添加注释行
<!-- @hmr:keep-all -->
。 有关更多详细信息,请参阅常见问题解答。
许多其他框架也非常适合,但我并不后悔为这个项目选择 SvelteKit。 它使我能够以一种非常有效的方式处理客户端应用程序——主要是因为 Svelte 本身非常富有表现力并且跳过了许多样板代码,还因为 Svelte 具有动画、过渡、范围 CSS 和全局存储等功能。 SvelteKit提供了我需要的所有构建块(SSR、路由、服务器路由),虽然仍处于测试阶段,但感觉非常稳定和快速。
部署和托管
最初,我在 Heroku 实例上托管 Colyseus (Node) 服务器,并浪费了大量时间让 WebSockets 和 CORS 正常工作。 事实证明,小型(免费)Heroku dyno 的性能不足以满足实时用例。 后来我将 Colyseus 应用程序迁移到 Linode 的小型服务器上。 客户端应用程序由 Netlify 通过 SvelteKits adapter-netlify 部署和托管。 这里没有惊喜:Netlify 效果很好!
结论
从一个非常简单的原型开始来验证这个想法,这对我弄清楚这个项目是否值得关注以及游戏的技术挑战在哪里有很大帮助。 在最终的实施中,Colyseus 负责跨多个客户端实时同步状态的所有繁重工作,这些客户端分布在多个房间中。 使用 Colyseus构建实时多用户应用程序的速度令人印象深刻— 一旦您弄清楚如何正确描述模式。 Colyseus 的内置监控面板有助于解决任何同步问题。
让这个设置复杂的是游戏的物理层,因为它引入了每个需要维护的与物理相关的游戏对象的额外副本。 从 SvelteKit 应用程序将游戏 PIN 存储在 Supabase.io 中非常简单。 事后看来,我本可以使用 SQLite 数据库来存储游戏 PIN,但在构建辅助项目时,尝试新事物是乐趣的一半。
最后,使用 SvelteKit 构建游戏的前端让我可以快速行动——而且我的脸上偶尔会露出喜悦的笑容。
现在,继续邀请您的朋友参加一轮 Autowuzzler!
进一步阅读 Smashing Magazine
- “通过构建 Whac-A-Mole 游戏开始使用 React,”Jhey Tompkins
- “如何构建实时多人虚拟现实游戏”,Alvin Wan
- “用 Node.js 编写多人文本冒险引擎”,Fernando Doglio
- “移动网页设计的未来:视频游戏设计和讲故事,” Suzanne Scacca
- “如何在虚拟现实中构建无尽的跑步游戏,”Alvin Wan