原文:How to Build a Multiplayer (.io) Web Game, Part 1
GitHub: https://github.com/vzhou842/example-.io-game
深入探索一個 .io
游戲的 Javascript client-side(客戶端)。
如果您以前從未聽說過 .io
游戲:它們是免費的多人 web 游戲,易於加入(無需帳戶),
並且通常在一個區域內讓許多玩家相互競爭。其他著名的 .io
游戲包括 Slither.io
和 Diep.io
。
- Slither.io:http://slither.io
- Diep.io:https://diep.io
在本文中,我們將了解如何從頭開始構建.io游戲。
您所需要的只是 Javascript 的實用知識:
您應該熟悉 ES6
語法,this
關鍵字和 Promises
之類的內容。
即使您對 Javascript 並不是最熟悉的,您仍然應該可以閱讀本文的大部分內容。
一個 .io
游戲示例
為了幫助我們學習,我們將參考 https://example-io-game.victorzhou.com。
這是一款非常簡單的游戲:你和其他玩家一起控制競技場中的一艘船。
你的飛船會自動發射子彈,你會試圖用自己的子彈擊中其他玩家,同時避開他們。
目錄
這是由兩部分組成的系列文章的第 1 部分。我們將在這篇文章中介紹以下內容:
- 項目概況/結構:項目的高級視圖。
- 構建/項目設置:開發工具、配置和設置。
- Client 入口:index.html 和 index.js。
- Client 網絡通信:與服務器通信。
- Client 渲染:下載 image 資源 + 渲染游戲。
- Client 輸入:讓用戶真正玩游戲。
- Client 狀態:處理來自服務器的游戲更新。
1. 項目概況/結構
我建議下載示例游戲的源代碼,以便您可以更好的繼續閱讀。
我們的示例游戲使用了:
- Express,Node.js 最受歡迎的 Web 框架,以為其 Web 服務器提供動力。
- socket.io,一個 websocket 庫,用於在瀏覽器和服務器之間進行通信。
- Webpack,一個模塊打包器。
項目目錄的結構如下所示:
public/
assets/
...
src/
client/
css/
...
html/
index.html
index.js
...
server/
server.js
...
shared/
constants.js
public/
我們的服務器將靜態服務 public/
文件夾中的所有內容。 public/assets/
包含我們項目使用的圖片資源。
src/
所有源代碼都在 src/
文件夾中。
client/
和 server/
很容易說明,shared/
包含一個由 client 和 server 導入的常量文件。
2. 構建/項目設置
如前所述,我們正在使用 Webpack 模塊打包器來構建我們的項目。讓我們看一下我們的 Webpack 配置:
webpack.common.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: {
game: './src/client/index.js',
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ['@babel/preset-env'],
},
},
},
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'src/client/html/index.html',
}),
],
};
src/client/index.js
是 Javascript (JS) 客戶端入口點。Webpack 將從那里開始,遞歸地查找其他導入的文件。- 我們的 Webpack 構建的 JS 輸出將放置在
dist/
目錄中。我將此文件稱為 JS bundle。 - 我們正在使用 Babel,特別是
@babel/preset-env
配置,來為舊瀏覽器編譯 JS 代碼。 - 我們正在使用一個插件來提取 JS 文件引用的所有 CSS 並將其捆綁在一起。我將其稱為 CSS bundle。
您可能已經注意到奇怪的 '[name].[contenthash].ext'
捆綁文件名。
它們包括 Webpack 文件名替換:[name]
將替換為入口點名稱(這是game
),[contenthash]將替換為文件內容的哈希。
我們這樣做是為了優化緩存 - 我們可以告訴瀏覽器永遠緩存我們的 JS bundle,因為如果 JS bundle 更改,其文件名也將更改(contenthash
也會更改)。最終結果是一個文件名,例如:game.dbeee76e91a97d0c7207.js
。
webpack.common.js
文件是我們在開發和生產配置中導入的基本配置文件。例如,下面是開發配置:
webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
});
我們在開發過程中使用 webpack.dev.js
來提高效率,並在部署到生產環境時切換到 webpack.prod.js
來優化包的大小。
本地設置
我建議在您的本地計算機上安裝該項目,以便您可以按照本文的其余內容進行操作。
設置很簡單:首先,確保已安裝 Node
和 NPM
。 然后,
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install
您就可以出發了! 要運行開發服務器,只需
$ npm run develop
並在網絡瀏覽器中訪問 localhost:3000
。
當您編輯代碼時,開發服務器將自動重建 JS 和 CSS bundles - 只需刷新即可查看更改!
3. Client 入口
讓我們來看看實際的游戲代碼。首先,我們需要一個 index.html
頁面,
這是您的瀏覽器訪問網站時首先加載的內容。我們的將非常簡單:
index.html
<!DOCTYPE html>
<html>
<head>
<title>An example .io game</title>
<link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
<canvas id="game-canvas"></canvas>
<script async src="/game.bundle.js"></script>
<div id="play-menu" class="hidden">
<input type="text" id="username-input" placeholder="Username" />
<button id="play-button">PLAY</button>
</div>
</body>
</html>
我們有:
- 我們將使用 HTML5 Canvas(
<canvas>
)元素來渲染游戲。 <link>
包含我們的 CSS bundle。<script>
包含我們的 Javascript bundle。- 主菜單,帶有用戶名
<input>
和“PLAY”
<button>
。
一旦主頁加載到瀏覽器中,我們的 Javascript 代碼就會開始執行,
從我們的 JS 入口文件 src/client/index.js
開始。
index.js
import { connect, play } from './networking';
import { startRendering, stopRendering } from './render';
import { startCapturingInput, stopCapturingInput } from './input';
import { downloadAssets } from './assets';
import { initState } from './state';
import { setLeaderboardHidden } from './leaderboard';
import './css/main.css';
const playMenu = document.getElementById('play-menu');
const playButton = document.getElementById('play-button');
const usernameInput = document.getElementById('username-input');
Promise.all([
connect(),
downloadAssets(),
]).then(() => {
playMenu.classList.remove('hidden');
usernameInput.focus();
playButton.onclick = () => {
// Play!
play(usernameInput.value);
playMenu.classList.add('hidden');
initState();
startCapturingInput();
startRendering();
setLeaderboardHidden(false);
};
});
這似乎很復雜,但實際上並沒有那么多事情發生:
- 導入一堆其他 JS 文件。
- 導入一些 CSS(因此 Webpack 知道將其包含在我們的 CSS bundle 中)。
- 運行
connect()
來建立到服務器的連接,運行downloadAssets()
來下載渲染游戲所需的圖像。 - 步驟 3 完成后,顯示主菜單(
playMenu
)。 - 為 “PLAY” 按鈕設置一個點擊處理程序。如果點擊,初始化游戲並告訴服務器我們准備好玩了。
客戶端邏輯的核心駐留在由 index.js
導入的其他文件中。接下來我們將逐一討論這些問題。
4. Client 網絡通信
對於此游戲,我們將使用眾所周知的 socket.io
庫與服務器進行通信。
Socket.io 包含對 WebSocket
的內置支持,
這非常適合雙向通訊:我們可以將消息發送到服務器,而服務器可以通過同一連接向我們發送消息。
我們將有一個文件 src/client/networking.js
,它負責所有與服務器的通信:
networking.js
import io from 'socket.io-client';
import { processGameUpdate } from './state';
const Constants = require('../shared/constants');
const socket = io(`ws://${window.location.host}`);
const connectedPromise = new Promise(resolve => {
socket.on('connect', () => {
console.log('Connected to server!');
resolve();
});
});
export const connect = onGameOver => (
connectedPromise.then(() => {
// Register callbacks
socket.on(Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
socket.on(Constants.MSG_TYPES.GAME_OVER, onGameOver);
})
);
export const play = username => {
socket.emit(Constants.MSG_TYPES.JOIN_GAME, username);
};
export const updateDirection = dir => {
socket.emit(Constants.MSG_TYPES.INPUT, dir);
};
此文件中發生3件主要事情:
- 我們嘗試連接到服務器。只有建立連接后,
connectedPromise
才能解析。 - 如果連接成功,我們注冊回調(
processGameUpdate()
和onGameOver()
)我們可能從服務器接收到的消息。 - 我們導出
play()
和updateDirection()
以供其他文件使用。
5. Client 渲染
是時候讓東西出現在屏幕上了!
但在此之前,我們必須下載所需的所有圖像(資源)。讓我們寫一個資源管理器:
assets.js
const ASSET_NAMES = ['ship.svg', 'bullet.svg'];
const assets = {};
const downloadPromise = Promise.all(ASSET_NAMES.map(downloadAsset));
function downloadAsset(assetName) {
return new Promise(resolve => {
const asset = new Image();
asset.onload = () => {
console.log(`Downloaded ${assetName}`);
assets[assetName] = asset;
resolve();
};
asset.src = `/assets/${assetName}`;
});
}
export const downloadAssets = () => downloadPromise;
export const getAsset = assetName => assets[assetName];
管理 assets 並不難實現!主要思想是保留一個 assets
對象,它將文件名 key 映射到一個 Image
對象值。
當一個 asset
下載完成后,我們將其保存到 assets
對象中,以便以后檢索。
最后,一旦每個 asset
下載都已 resolve(意味着所有 assets 都已下載),我們就 resolve downloadPromise
。
隨着資源的下載,我們可以繼續進行渲染。如前所述,我們正在使用 HTML5 畫布(<canvas>
)繪制到我們的網頁上。我們的游戲非常簡單,所以我們需要畫的是:
- 背景
- 我們玩家的飛船
- 游戲中的其他玩家
- 子彈
這是 src/client/render.js
的重要部分,它准確地繪制了我上面列出的那四件事:
render.js
import { getAsset } from './assets';
import { getCurrentState } from './state';
const Constants = require('../shared/constants');
const { PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE } = Constants;
// Get the canvas graphics context
const canvas = document.getElementById('game-canvas');
const context = canvas.getContext('2d');
// Make the canvas fullscreen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function render() {
const { me, others, bullets } = getCurrentState();
if (!me) {
return;
}
// Draw background
renderBackground(me.x, me.y);
// Draw all bullets
bullets.forEach(renderBullet.bind(null, me));
// Draw all players
renderPlayer(me, me);
others.forEach(renderPlayer.bind(null, me));
}
// ... Helper functions here excluded
let renderInterval = null;
export function startRendering() {
renderInterval = setInterval(render, 1000 / 60);
}
export function stopRendering() {
clearInterval(renderInterval);
}
render()
是該文件的主要函數。startRendering()
和 stopRendering()
控制 60 FPS 渲染循環的激活。
各個渲染幫助函數(例如 renderBullet()
)的具體實現並不那么重要,但這是一個簡單的示例:
render.js
function renderBullet(me, bullet) {
const { x, y } = bullet;
context.drawImage(
getAsset('bullet.svg'),
canvas.width / 2 + x - me.x - BULLET_RADIUS,
canvas.height / 2 + y - me.y - BULLET_RADIUS,
BULLET_RADIUS * 2,
BULLET_RADIUS * 2,
);
}
請注意,我們如何使用前面在 asset.js
中看到的 getAsset()
方法!
如果你對其他渲染幫助函數感興趣,請閱讀 src/client/render.js
的其余部分。
6. Client 輸入🕹️
現在該使游戲變得可玩了!我們的 control scheme 非常簡單:使用鼠標(在桌面上)或觸摸屏幕(在移動設備上)來控制移動方向。為此,我們將為 Mouse 和 Touch 事件注冊事件監聽器。
src/client/input.js
會處理這些問題:
input.js
import { updateDirection } from './networking';
function onMouseInput(e) {
handleInput(e.clientX, e.clientY);
}
function onTouchInput(e) {
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
function handleInput(x, y) {
const dir = Math.atan2(x - window.innerWidth / 2, window.innerHeight / 2 - y);
updateDirection(dir);
}
export function startCapturingInput() {
window.addEventListener('mousemove', onMouseInput);
window.addEventListener('touchmove', onTouchInput);
}
export function stopCapturingInput() {
window.removeEventListener('mousemove', onMouseInput);
window.removeEventListener('touchmove', onTouchInput);
}
onMouseInput()
和 onTouchInput()
是事件監聽器,當輸入事件發生(例如:鼠標移動)時,
它們調用 updateDirection()
(來自 networking.js
)。
updateDirection()
負責向服務器發送消息,服務器將處理輸入事件並相應地更新游戲狀態。
7. Client 狀態
這部分是這篇文章中最先進的部分。如果你一遍讀不懂所有內容,不要灰心!請隨意跳過這一節,稍后再來討論它。
完成客戶端代碼所需的最后一個難題是狀態。還記得“客戶端渲染”部分的這段代碼嗎?
render.js
import { getCurrentState } from './state';
function render() {
const { me, others, bullets } = getCurrentState();
// Do the rendering
// ...
}
getCurrentState()
必須能夠根據從服務器接收到的游戲更新隨時向我們提供客戶端的當前游戲狀態。這是服務器可能發送的游戲更新示例:
{
"t": 1555960373725,
"me": {
"x": 2213.8050880413657,
"y": 1469.370893425012,
"direction": 1.3082443894581433,
"id": "AhzgAtklgo2FJvwWAADO",
"hp": 100
},
"others": [],
"bullets": [
{
"id": "RUJfJ8Y18n",
"x": 2354.029197099604,
"y": 1431.6848318262666
},
{
"id": "ctg5rht5s",
"x": 2260.546457727445,
"y": 1456.8088728920968
}
],
"leaderboard": [
{
"username": "Player",
"score": 3
}
]
}
每個游戲更新都具有以下 5 個字段:
- t:創建此更新的服務器時間戳。
- me:接收更新的玩家的 player 信息。
- others:同一游戲中其他玩家的玩家信息數組。
- bullets:在游戲中的 bullets 子彈信息的數組。
- leaderboard:當前排行榜數據。
7.1 Native 客戶端狀態
getCurrentState()
的 native 實現可以直接返回最近收到的游戲更新的數據。
naive-state.js
let lastGameUpdate = null;
// Handle a newly received game update.
export function processGameUpdate(update) {
lastGameUpdate = update;
}
export function getCurrentState() {
return lastGameUpdate;
}
干凈整潔!如果那么簡單就好了。此實現存在問題的原因之一是因為它將渲染幀速率限制為服務器 tick 速率。
- Frame Rate:每秒的幀數(即,
render()
調用)或 FPS。游戲通常以至少 60 FPS 為目標。 - Tick Rate:服務器向客戶端發送游戲更新的速度。這通常低於幀速率。對於我們的游戲,服務器以每秒30 ticks 的速度運行。
如果我們僅提供最新的游戲更新,則我們的有效 FPS 不能超過 30,因為我們永遠不會從服務器每秒收到超過 30 的更新。即使我們每秒調用 render()
60次,這些調用中的一半也只會重繪完全相同的內容,實際上什么也沒做。
Native 實現的另一個問題是它很容易滯后。在完美的互聯網條件下,客戶端將完全每33毫秒(每秒30個)收到一次游戲更新:
可悲的是,沒有什么比這更完美。 一個更現實的表示可能看起來像這樣:
當涉及到延遲時,native 實現幾乎是最糟糕的情況。
如果游戲更新晚到50毫秒,客戶端會多凍結50毫秒,因為它仍在渲染前一個更新的游戲狀態。
你可以想象這對玩家來說是多么糟糕的體驗:游戲會因為隨機凍結而感到不安和不穩定。
7.2 更好的客戶端狀態
我們將對這個簡單的實現進行一些簡單的改進。第一種是使用100毫秒的渲染延遲,這意味着“當前”客戶端狀態總是比服務器的游戲狀態滯后100毫秒。例如,如果服務器的時間是150,客戶端呈現的狀態將是服務器在時間50時的狀態:
這給了我們100毫秒的緩沖區來容忍不可預測的游戲更新到來:
這樣做的代價是恆定的100毫秒輸入延遲。對於擁有穩定流暢的游戲玩法來說,這是一個小小的代價——大多數玩家(尤其是休閑玩家)甚至不會注意到游戲的延遲。對人類來說,適應恆定的100毫秒的延遲要比嘗試應付不可預測的延遲容易得多。
我們可以使用另一種稱為“客戶端預測”的技術,該技術可以有效地減少感知到的滯后,但這超出了本文的范圍。
我們將進行的另一項改進是使用線性插值。由於渲染延遲,通常我們會比當前客戶端時間早至少更新1次。每當調用 getCurrentState()
時,我們都可以在當前客戶端時間前后立即在游戲更新之間進行線性插值:
這解決了我們的幀率問題:我們現在可以隨心所欲地渲染獨特的幀了!
7.3 實現更好的客戶端狀態
src/client/state.js
中的示例實現使用了渲染延遲和線性插值,但有點長。讓我們把它分解成幾個部分。這是第一個:
state.js, Part 1
const RENDER_DELAY = 100;
const gameUpdates = [];
let gameStart = 0;
let firstServerTimestamp = 0;
export function initState() {
gameStart = 0;
firstServerTimestamp = 0;
}
export function processGameUpdate(update) {
if (!firstServerTimestamp) {
firstServerTimestamp = update.t;
gameStart = Date.now();
}
gameUpdates.push(update);
// Keep only one game update before the current server time
const base = getBaseUpdate();
if (base > 0) {
gameUpdates.splice(0, base);
}
}
function currentServerTime() {
return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}
// Returns the index of the base update, the first game update before
// current server time, or -1 if N/A.
function getBaseUpdate() {
const serverTime = currentServerTime();
for (let i = gameUpdates.length - 1; i >= 0; i--) {
if (gameUpdates[i].t <= serverTime) {
return i;
}
}
return -1;
}
首先要了解的是 currentServerTime()
的功能。如前所述,每個游戲更新都包含服務器時間戳。我們希望使用渲染延遲來在服務器后渲染100毫秒,但我們永遠不會知道服務器上的當前時間,因為我們不知道任何給定更新要花費多長時間。互聯網是無法預測的,並且變化很大!
為了解決這個問題,我們將使用一個合理的近似方法:我們假設第一個更新立即到達。如果這是真的,那么我們就會知道服務器在那一刻的時間!我們在 firstServerTimestamp
中存儲服務器時間戳,在 gameStart
中存儲本地(客戶端)時間戳。
哇,等一下。服務器上的時間不應該等於客戶端上的時間嗎?為什么在“服務器時間戳”和“客戶端時間戳”之間有區別?這是個好問題,讀者們!事實證明,它們不一樣。Date.now()
將根據客戶端和服務器的本地因素返回不同的時間戳。永遠不要假設您的時間戳在不同機器之間是一致的。
現在很清楚 currentServerTime()
的作用了:它返回當前渲染時間的服務器時間戳。換句話說,它是當前服務器時間(firstServerTimestamp + (Date.now() - gameStart)
) 減去渲染延遲(RENDER_DELAY
)。
接下來,讓我們了解如何處理游戲更新。processGameUpdate()
在從服務器接收到更新時被調用,我們將新更新存儲在 gameUpdates
數組中。然后,為了檢查內存使用情況,我們刪除了在基本更新之前的所有舊更新,因為我們不再需要它們了。
基本更新到底是什么? 這是我們從當前服務器時間倒退時發現的第一個更新。 還記得這張圖嗎?
“客戶端渲染時間”左邊的游戲更新是基礎更新。
基礎更新的用途是什么?為什么我們可以丟棄基礎更新之前的更新?最后讓我們看看 getCurrentState()
的實現,以找出:
state.js, Part 2
export function getCurrentState() {
if (!firstServerTimestamp) {
return {};
}
const base = getBaseUpdate();
const serverTime = currentServerTime();
// If base is the most recent update we have, use its state.
// Else, interpolate between its state and the state of (base + 1).
if (base < 0) {
return gameUpdates[gameUpdates.length - 1];
} else if (base === gameUpdates.length - 1) {
return gameUpdates[base];
} else {
const baseUpdate = gameUpdates[base];
const next = gameUpdates[base + 1];
const r = (serverTime - baseUpdate.t) / (next.t - baseUpdate.t);
return {
me: interpolateObject(baseUpdate.me, next.me, r),
others: interpolateObjectArray(baseUpdate.others, next.others, r),
bullets: interpolateObjectArray(baseUpdate.bullets, next.bullets, r),
};
}
}
我們處理3種情況:
base < 0
,意味着在當前渲染時間之前沒有更新(請參見上面的getBaseUpdate()
的實現)。由於渲染延遲,這可能會在游戲開始時發生。在這種情況下,我們將使用最新的更新。base
是我們最新的更新(😢)。這種情況可能是由於網絡連接的延遲或較差造成的。在本例中,我們還使用了最新的更新。- 我們在當前渲染時間之前和之后都有更新,所以我們可以插值!
state.js 剩下的就是線性插值的實現,這只是一些簡單(但很無聊)的數學運算。如果您想查看,請在 Github 上查看 state.js。
我是為少。
微信:uuhells123。
公眾號:黑客下午茶。
謝謝點贊支持👍👍👍!