原文:How to Build a Multiplayer (.io) Web Game, Part 1
GitHub: https://github.com/vzhou842/e...
深刻摸索一个 .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.victo...。
这是一款非常简单的游戏:你和其余玩家一起管制竞技场中的一艘船。
你的飞船会主动发射子弹,你会试图用本人的子弹击中其余玩家,同时避开他们。
目录
这是由两局部组成的系列文章的第 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 contextconst canvas = document.getElementById('game-canvas');const context = canvas.getContext('2d');// Make the canvas fullscreencanvas.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 excludedlet 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。
- https://github.com/vzhou842/e...
我是为少。微信:uuhells123。公众号:黑客下午茶。谢谢点赞反对????????????!