如何從零開始構建實時多用戶遊戲
已發表: 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
)。
球員的模式類似,但包括更多的屬性來存儲球員的名字和球隊的號碼,需要在創建球員實例時提供:
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 應用程序中的物理”部分)( onCreate
),並在客戶端連接時將玩家添加到世界中( onJoin
)。 然後它使用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