原文:How to Build a Multiplayer (.io) Web Game, Part 1css
GitHub: https://github.com/vzhou842/example-.io-gamehtml
深刻探索一個 .io
遊戲的 Javascript client-side(客戶端)。node
若是您之前從未據說過 .io
遊戲:它們是免費的多人 web 遊戲,易於加入(無需賬戶),
而且一般在一個區域內讓許多玩家相互競爭。其餘著名的 .io
遊戲包括 Slither.io
和 Diep.io
。webpack
在本文中,咱們將瞭解如何從頭開始構建.io遊戲。
您所須要的只是 Javascript 的實用知識:
您應該熟悉 ES6
語法,this
關鍵字和 Promises
之類的內容。
即便您對 Javascript 並非最熟悉的,您仍然應該能夠閱讀本文的大部份內容。git
.io
遊戲示例爲了幫助咱們學習,咱們將參考 https://example-io-game.victorzhou.com。github
這是一款很是簡單的遊戲:你和其餘玩家一塊兒控制競技場中的一艘船。
你的飛船會自動發射子彈,你會試圖用本身的子彈擊中其餘玩家,同時避開他們。web
這是由兩部分組成的系列文章的第 1 部分。咱們將在這篇文章中介紹如下內容:npm
我建議下載示例遊戲的源代碼,以便您能夠更好的繼續閱讀。canvas
咱們的示例遊戲使用了:數組
項目目錄的結構以下所示:
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 導入的常量文件。
如前所述,咱們正在使用 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 將從那裏開始,遞歸地查找其餘導入的文件。dist/
目錄中。我將此文件稱爲 JS bundle。@babel/preset-env
配置,來爲舊瀏覽器編譯 JS 代碼。您可能已經注意到奇怪的 '[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 - 只需刷新便可查看更改!
讓咱們來看看實際的遊戲代碼。首先,咱們須要一個 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>
咱們有:
<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); }; });
這彷佛很複雜,但實際上並無那麼多事情發生:
connect()
來創建到服務器的鏈接,運行 downloadAssets()
來下載渲染遊戲所需的圖像。playMenu
)。客戶端邏輯的核心駐留在由 index.js
導入的其餘文件中。接下來咱們將逐一討論這些問題。
對於此遊戲,咱們將使用衆所周知的 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()
以供其餘文件使用。是時候讓東西出如今屏幕上了!
但在此以前,咱們必須下載所需的全部圖像(資源)。讓咱們寫一個資源管理器:
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
的其他部分。
如今該使遊戲變得可玩了!咱們的 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()
負責向服務器發送消息,服務器將處理輸入事件並相應地更新遊戲狀態。
這部分是這篇文章中最早進的部分。若是你一遍讀不懂全部內容,不要灰心!請隨意跳過這一節,稍後再來討論它。
完成客戶端代碼所需的最後一個難題是狀態。還記得「客戶端渲染」部分的這段代碼嗎?
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 個字段:
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 速率。
render()
調用)或 FPS。遊戲一般以致少 60 FPS 爲目標。若是咱們僅提供最新的遊戲更新,則咱們的有效 FPS 不能超過 30,由於咱們永遠不會從服務器每秒收到超過 30 的更新。即便咱們每秒調用 render()
60次,這些調用中的一半也只會重繪徹底相同的內容,實際上什麼也沒作。
Native 實現的另外一個問題是它很容易滯後。在完美的互聯網條件下,客戶端將徹底每33毫秒(每秒30個)收到一次遊戲更新:
可悲的是,沒有什麼比這更完美。 一個更現實的表示可能看起來像這樣:
當涉及到延遲時,native 實現幾乎是最糟糕的狀況。
若是遊戲更新晚到50毫秒,客戶端會多凍結50毫秒,由於它仍在渲染前一個更新的遊戲狀態。
你能夠想象這對玩家來講是多麼糟糕的體驗:遊戲會由於隨機凍結而感到不安和不穩定。
咱們將對這個簡單的實現進行一些簡單的改進。第一種是使用100毫秒的渲染延遲,這意味着「當前」客戶端狀態老是比服務器的遊戲狀態滯後100毫秒。例如,若是服務器的時間是150,客戶端呈現的狀態將是服務器在時間50時的狀態:
這給了咱們100毫秒的緩衝區來容忍不可預測的遊戲更新到來:
這樣作的代價是恆定的100毫秒輸入延遲。對於擁有穩定流暢的遊戲玩法來講,這是一個小小的代價——大多數玩家(尤爲是休閒玩家)甚至不會注意到遊戲的延遲。對人類來講,適應恆定的100毫秒的延遲要比嘗試應付不可預測的延遲容易得多。
咱們可使用另外一種稱爲「客戶端預測」的技術,該技術能夠有效地減小感知到的滯後,但這超出了本文的範圍。
咱們將進行的另外一項改進是使用線性插值。因爲渲染延遲,一般咱們會比當前客戶端時間早至少更新1次。每當調用 getCurrentState()
時,咱們均可以在當前客戶端時間先後當即在遊戲更新之間進行線性插值:
這解決了咱們的幀率問題:咱們如今能夠爲所欲爲地渲染獨特的幀了!
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。 公衆號:黑客下午茶。 謝謝點贊支持👍👍👍!