创建一个多人.io网络游戏

图片

Agar.io于2015年发布,成为.io这类新游戏的前身 ,此后其受欢迎程度已大大提高。 我经历了.io游戏的普及度的增长:在过去三年中,我创作并出售了两种此类游戏。

如果您以前从未听说过此类游戏:这些是易于参与的免费多人网络游戏(无需帐户)。 通常他们会在同一个竞技场上推动许多对立的球员。 .io流派的其他著名游戏是: Slither.ioDiep.io。

在本文中,我们将了解如何从头开始创建.io游戏 。 为此,仅对Javascript的知识就足够了:您需要了解诸如ES6语法, thisPromises之类的知识 。 即使您不太了解Javascript,也仍然可以弄清楚大部分文章。

游戏示例.io


为了帮助您学习,我们将参考一个示例.io游戏 。 尝试播放!


游戏非常简单:您可以在有其他玩家的竞技场上控制飞船。 您的飞船会自动发射炮弹,并尝试击中其他玩家,同时避开其炮弹。

1.概述/项目结构


我建议下载示例游戏的源代码,以便您关注我。

该示例使用以下内容:

  • Express是最流行的Node.js网络框架,用于管理游戏的网络服务器。
  • socket.io-一个用于在浏览器和服务器之间交换数据的websocket库。
  • Webpack是一个模块管理器。 您可以在此处阅读有关为什么使用Webpack的信息

这是项目目录结构的样子:

 public/ assets/ ... src/ client/ css/ ... html/ index.html index.js ... server/ server.js ... shared/ constants.js 

公开/


服务器将静态传输public/文件夹中的所有内容。 public/assets/包含我们项目使用的图像。

src /


所有源代码都在src/文件夹中。 client/ server/的名称不言自明,而shared/包含一个由客户和服务器导入的常量文件。

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包
  • 我们使用Babel ,特别是@ babel / preset-env配置来为较旧的浏览器转换我们的JS代码。
  • 我们使用该插件提取JS文件引用的所有CSS,并将它们合并到一个位置。 我将其称为CSS包

您可能已经注意到奇怪的软件包文件名'[name].[contenthash].ext' 。 它们包含Webpack 文件 [name]替换: [name]将被输入点的名称替换(在我们的例子中,这是一个game ), [contenthash]将被文件内容的哈希替换。 我们这样做是为了优化用于哈希的项目 -您可以告诉浏览器无限期地缓存我们的JS包,因为如果包更改,则其文件名也会更改( 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.dev.js ,并切换到webpack.prod.js来优化在生产环境中部署时的包装尺寸。

本地设置


我建议将项目安装在本地计算机上,以便您可以按照本文中列出的步骤进行操作。 设置很简单:首先,必须在系统上安装NodeNPM 。 接下来,您需要执行

 $ git clone https://github.com/vzhou842/example-.io-game.git $ cd example-.io-game $ npm install 

您就可以出发了! 要启动开发服务器,只需运行

 $ npm run develop 

并在网络浏览器中访问localhost:3000 。 当代码更改时,开发服务器将自动重建JS和CSS包-只需刷新页面即可查看所有更改!

3.客户进入点


让我们开始研究游戏代码本身。 首先,我们需要index.html页面,当您访问该站点时,浏览器将首先加载它。 我们的页面将非常简单:

index.html

  <!DOCTYPE html>
 <html>
 <头>
   <title> .io游戏示例</ title>
   <link type =“ text / css” rel =“ stylesheet” href =“ / game.bundle.css”>
 </ head>
 <身体>
   <canvas id =“ game-canvas”> </ canvas>
   <script异步src =“ / game.bundle.js”> </ script>
   <div id =“播放菜单” class =“隐藏”>
     <input type =“文本” id =“用户名-输入”占位符=“用户名” />
     <button id =“ play-button”>播放</ button>
   </ div>
 </ body>
 </ html> 

为了清楚起见,此代码示例略有简化,我将在帖子的许多其他示例中进行相同的操作。 完整的代码始终可以在Github上查看。

我们有:

  • 我们将用来渲染游戏的HTML5 Canvas<canvas><canvas>
  • <link>添加我们的CSS包。
  • <script>添加我们的Javascript包。
  • 主菜单带有用户名<input>和“ PLAY”( <button><button>

加载主页后,将从入口点的JS文件src/client/index.js开始,在浏览器中开始执行Javascript代码。

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); }; }); 

这可能看起来很复杂,但实际上这里没有发生很多动作:

  1. 导入其他几个JS文件。
  2. 导入CSS(因此Webpack知道将它们包含在我们的CSS包中)。
  3. 运行connect()建立与服务器的连接,并运行downloadAssets()下载渲染游戏所需的图像。
  4. 完成第3步后 ,将显示主菜单( playMenu )。
  5. 配置用于按PLAY按钮的处理程序。 当按下按钮时,代码将初始化游戏并告诉服务器我们已经准备好玩。

客户端-服务器逻辑的主要“内容”是由index.js文件导入的那些文件。 现在,我们将按顺序考虑它们。

4.交换客户数据


在这个游戏中,我们使用众所周知的socket.io库与服务器进行通信。 Socket.io具有对WebSocket的内置支持,非常适合双向通信:我们可以将消息发送到服务器,而服务器可以通过同一连接向我们发送消息。

我们将拥有一个src/client/networking.js文件,该文件将处理与服务器的所有通信:

网络.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); }; 

为了清楚起见,此代码也略有减少。

此文件中有三个主要操作:

  • 我们正在尝试连接到服务器。 仅在我们建立连接后才允许connectedPromise
  • 如果成功建立连接,我们将注册回调函数( processGameUpdate()onGameOver() ),以接收可以从服务器接收的消息。
  • 我们导出play()updateDirection()以便其他文件可以使用它们。

5.客户端渲染


现在该在屏幕上显示图片了!

...但是在我们执行此操作之前,我们需要下载为此所需的所有图像(资源)。 让我们写一个资源管理器:

asset.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对象,它将把文件名键绑定到Image对象的值。 加载资源后,我们将其保存在assets对象中,以便将来快速检索。 当允许为每个单独的资源进行下载(即,将下载所有资源)时,我们启用downloadPromise

下载资源后,可以开始渲染。 如前所述,我们使用HTML5 Canvas<canvas> )在网页上绘制。 我们的游戏非常简单,因此仅绘制以下内容就足够了:

  1. 背景知识
  2. 玩家飞船
  3. 游戏中的其他玩家
  4. 炮弹

以下是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.客户输入


现在该使游戏变得可玩了 ! 控制方案将非常简单:您可以使用鼠标(在计算机上)或触摸屏幕(在移动设备上)来更改移动方向。 为了实现这一点,我们将为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.客户条件


这部分是文章第一部分中最困难的部分。 如果您不读一读就不要灰心! 您甚至可以跳过它,稍后再返回。

完成客户端-服务器代码所需的最后一个难题是state 。 还记得“客户端渲染”部分的代码片段吗?

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 } ] } 

每个游戏更新均包含五个相同的字段:

  • t :创建此更新的时间的服务器时间戳。
  • :有关播放器收到此更新的信息。
  • 其他 :有关参与同一游戏的其他玩家的一系列信息。
  • bullets :有关游戏中的炮弹的一系列信息。
  • 页首横幅 :当前的页首横幅数据。 在这篇文章中,我们将不考虑它们。

7.1客户的天真状态


getCurrentState()实现只能直接从收到的最新游戏更新中返回数据。

naive-state.js

 let lastGameUpdate = null; // Handle a newly received game update. export function processGameUpdate(update) { lastGameUpdate = update; } export function getCurrentState() { return lastGameUpdate; } 

美丽而清晰! 但是,如果一切都这么简单。 此实现存在问题的原因之一: 它将渲染的帧速率限制为服务器时钟的频率

帧率 :每秒的帧数(即, render()调用)或FPS。 游戏通常通常至少达到60 FPS。

报价速度 :服务器向客户端发送游戏更新的频率。 通常它低于帧速率 。 在我们的游戏中,服务器以每秒30个周期的频率运行。

如果我们仅渲染游戏的最新更新,那么FPS实际上将永远不会超过30,因为我们每秒从服务器接收的更新永远不会超过30 。 即使我们每秒调用render() 60次,这些调用中的一半也只会重绘同一件事,实际上什么也没做。 天真的实现的另一个问题是它容易出现延迟 。 以理想的Internet速度,客户端将每隔33毫秒(每秒30个)准确地收到一次游戏更新:


不幸的是,没有什么是完美的。 更为现实的情况是:

当涉及到延迟时,幼稚的实现实际上是最坏的情况。 如果收到游戏更新的延迟为50毫秒,则客户端会额外降低50毫秒的速度,因为它仍会呈现前一次更新的游戏状态。 您可以想象这对玩家来说是多么的不方便:由于任意制动,游戏似乎会抽搐且不稳定。

7.2改善客户状态


我们将对天真的实现进行一些改进。 首先,我们使用100 ms的渲染延迟 。 这意味着客户端的“当前”状态将始终落后于服务器上的游戏状态100毫秒。 例如,如果服务器上的时间为150 ,则服务器在时间50处的状态将在客户端上呈现:


这为我们提供了100毫秒的缓冲区,使我们能够在无法预测的时间内度过以接收游戏更新:


为此付出的代价 100 ms的恒定输入延迟(输入滞后) 。 这是平稳游戏的一个小牺牲-大多数玩家(尤其是休闲玩家)甚至都不会注意到这种延迟。 人们适应100毫秒的恒定延迟比玩不可预测的延迟要容易得多。

我们可以使用另一种称为“客户端预测”的技术该技术可以很好地减少感知到的延迟,但是本文中将不予考虑。

我们使用的另一个改进是线性插值 。 由于渲染延迟,我们通常会至少更新一次客户端中的当前时间。 调用getCurrentState() ,我们可以在客户端中当前时间前后立即在游戏更新之间执行线性插值


这就解决了帧速率问题:现在,我们可以以所需的任何频率渲染唯一的帧!

7.3实施增强的客户端状态


src/client/state.js的示例实现src/client/state.js使用了渲染延迟和线性插值,但这并不长久。 让我们将代码分为两部分。 这是第一个:

state.js第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 ms处渲染图像,但是我们永远不会知道服务器上的当前时间 ,因为我们不知道有多长时间才能获得更新。 互联网是无法预测的,其速度可能相差很大!

为了解决这个问题,您可以使用一个合理的近似值:我们将假定第一个更新是即时到达的 。 如果这是真的,那么我们将知道特定时间的服务器时间! 我们将服务器时间戳保存在firstServerTimestamp同时将本地 (客户端)时间戳保存在gameStart

等等 服务器上是否应该没有时间=客户端中的时间? 为什么我们要区分“服务器时间戳”和“客户端时间戳”? 这是一个很好的问题! 事实证明,这不是同一回事。 Date.now()将在客户端和服务器中返回不同的时间戳,这取决于这些计算机的本地因素。 永远不要假设所有机器上的时间戳都相同。

现在我们了解了currentServerTime()作用:它返回当前渲染时间的服务器时间戳换句话说,这是当前服务器时间(firstServerTimestamp <+ (Date.now() - gameStart))减去渲染延迟(RENDER_DELAY)。

现在,让我们看看我们如何处理游戏更新。从服务器接收到更新时,将称为processGameUpdate(),并将新更新保存到array gameUpdates。然后,要检查内存使用情况,我们将所有旧的更新删除到基本更新,因为我们不再需要它们。

什么是“基本更新”?这是我们发现从当前服务器时间开始向后移动第一个更新。还记得这条电路吗?


直接在“客户端渲染时间”左侧进行的游戏更新是一项基本更新。

基本更新用于什么?为什么我们要放弃对基础的更新?为了理解这一点,让我们最后看一下实现getCurrentState()

state.js第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), }; } } 

我们处理三种情况:

  1. base < 0表示当前渲染时间没有更新(请参见上述实现getBaseUpdate())。由于渲染延迟,这种情况可能在游戏开始时就发生。在这种情况下,我们使用最新更新。
  2. base这是我们的最新更新。这可能是由于网络延迟或互联网连接不佳。在这种情况下,我们还将使用我们拥有的最新更新。
  3. 我们在当前渲染时间之前和之后都有更新,因此您可以进行插值

剩下的state.js就是线性插值的实现,这是一个简单(但很无聊)的数学运算。如果您想自己学习它,请state.jsGithub打开它

第2部分。后端服务器


在这一部分中,我们将研究运行.io游戏示例的Node.js后端

1.服务器入口点


为了控制Web服务器,我们将使用流行的Node.js Web框架Express它将由我们的服务器入口点文件配置src/server/server.js

server.js,第1部分

 const express = require('express'); const webpack = require('webpack'); const webpackDevMiddleware = require('webpack-dev-middleware'); const webpackConfig = require('../../webpack.dev.js'); // Setup an Express server const app = express(); app.use(express.static('public')); if (process.env.NODE_ENV === 'development') { // Setup Webpack for development const compiler = webpack(webpackConfig); app.use(webpackDevMiddleware(compiler)); } else { // Static serve the dist/ folder in production app.use(express.static('dist')); } // Listen on port const port = process.env.PORT || 3000; const server = app.listen(port); console.log(`Server listening on port ${port}`); 

还记得我们在第一部分中讨论过Webpack吗?这是我们将使用Webpack配置的地方。我们将以两种方式应用它们:

  • 使用webpack-dev-middleware自动重建我们的开发包,或者
  • dist/在组装生产后,将Webpack 静态地将其写入文件的文件夹传输到该文件夹

另一个重要任务server.js是配置socket.io服务器,该服务器仅连接到Express服务器:

server.js,第2部分

 const socketio = require('socket.io'); const Constants = require('../shared/constants'); // Setup Express // ... const server = app.listen(port); console.log(`Server listening on port ${port}`); // Setup socket.io const io = socketio(server); // Listen for socket.io connections io.on('connection', socket => { console.log('Player connected!', socket.id); socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame); socket.on(Constants.MSG_TYPES.INPUT, handleInput); socket.on('disconnect', onDisconnect); }); 

与服务器成功建立socket.io连接后,我们为新套接字配置事件处理程序。事件处理程序通过委派给singleton对象来处理从客户端收到的消息game

server.js,第3部分

 const Game = require('./game'); // ... // Setup the Game const game = new Game(); function joinGame(username) { game.addPlayer(this, username); } function handleInput(dir) { game.handleInput(this, dir); } function onDisconnect() { game.removePlayer(this); } 

我们创建了一个.io风格的游戏,因此我们只需要一个实例Game(“游戏”)-所有玩家都在同一个竞技场上玩!在下一节中,我们将看到此类的工作原理Game

2.游戏服务器


该类Game包含最重要的服务器端逻辑。它有两个主要任务:玩家管理游戏模拟

让我们从第一个任务开始-播放器管理。

game.js,第1部分

 const Constants = require('../shared/constants'); const Player = require('./player'); class Game { constructor() { this.sockets = {}; this.players = {}; this.bullets = []; this.lastUpdateTime = Date.now(); this.shouldSendUpdate = false; setInterval(this.update.bind(this), 1000 / 60); } addPlayer(socket, username) { this.sockets[socket.id] = socket; // Generate a position to start this player at. const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5); const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5); this.players[socket.id] = new Player(socket.id, username, x, y); } removePlayer(socket) { delete this.sockets[socket.id]; delete this.players[socket.id]; } handleInput(socket, dir) { if (this.players[socket.id]) { this.players[socket.id].setDirection(dir); } } // ... } 

在此游戏中,我们将通过id插座socket.io 的字段来识别玩家(如果您感到困惑,请返回server.js)。Socket.io本身为每个套接字分配一个唯一的套接字id,因此我们不必为此担心。我将呼叫他的玩家ID

考虑到这一点,让我们检查类中的实例变量Game

  • sockets是将播放器ID绑定到与播放器关联的套接字的对象。它使我们可以在一定时间内通过其播放器ID访问套接字。
  • players 是将玩家ID绑定到代码>玩家对象的对象

bulletsBullet没有特定顺序的对象数组
lastUpdateTime-这是游戏上次更新时间的时间戳。很快我们将看到它的用法。
shouldSendUpdate是辅助变量。我们也将很快看到它的使用。
方法addPlayer()removePlayer()并且handleInput()不需要解释,他们在使用server.js如果您需要刷新内存,请返回更高一点。

最后一行constructor()开始游戏更新周期(频率为60次更新/秒):

game.js,第2部分

 const Constants = require('../shared/constants'); const applyCollisions = require('./collisions'); class Game { // ... update() { // Calculate time elapsed const now = Date.now(); const dt = (now - this.lastUpdateTime) / 1000; this.lastUpdateTime = now; // Update each bullet const bulletsToRemove = []; this.bullets.forEach(bullet => { if (bullet.update(dt)) { // Destroy this bullet bulletsToRemove.push(bullet); } }); this.bullets = this.bullets.filter( bullet => !bulletsToRemove.includes(bullet), ); // Update each player Object.keys(this.sockets).forEach(playerID => { const player = this.players[playerID]; const newBullet = player.update(dt); if (newBullet) { this.bullets.push(newBullet); } }); // Apply collisions, give players score for hitting bullets const destroyedBullets = applyCollisions( Object.values(this.players), this.bullets, ); destroyedBullets.forEach(b => { if (this.players[b.parentID]) { this.players[b.parentID].onDealtDamage(); } }); this.bullets = this.bullets.filter( bullet => !destroyedBullets.includes(bullet), ); // Check if any players are dead Object.keys(this.sockets).forEach(playerID => { const socket = this.sockets[playerID]; const player = this.players[playerID]; if (player.hp <= 0) { socket.emit(Constants.MSG_TYPES.GAME_OVER); this.removePlayer(socket); } }); // Send a game update to each player every other time if (this.shouldSendUpdate) { const leaderboard = this.getLeaderboard(); Object.keys(this.sockets).forEach(playerID => { const socket = this.sockets[playerID]; const player = this.players[playerID]; socket.emit( Constants.MSG_TYPES.GAME_UPDATE, this.createUpdate(player, leaderboard), ); }); this.shouldSendUpdate = false; } else { this.shouldSendUpdate = true; } } // ... } 

该方法update()可能包含服务器端逻辑中最重要的部分。按照顺序,我们列出了他所做的一切:

  1. 计算dt自上次以来经过了多少时间update()
  2. . . , bullet.update() true , ( ).
  3. . — player.update() Bullet .
  4. applyCollisions() , , . , ( player.onDealtDamage() ), bullets .
  5. .
  6. update() . shouldSendUpdate . update() 60 /, 30 /. , 30 / ( ).

? . 30 – !

为什么然后不update()每秒呼叫30次呢?改善了游戏的仿真性。调用update()得越多,游戏的模拟就越准确。但是不要update()因为调用数量而太费劲,因为这是一个计算量很大的任务-每秒60个就足够了。

该类的其余部分Game由用于以下方面的辅助方法组成update()

game.js,第3部分

 class Game { // ... getLeaderboard() { return Object.values(this.players) .sort((p1, p2) => p2.score - p1.score) .slice(0, 5) .map(p => ({ username: p.username, score: Math.round(p.score) })); } createUpdate(player, leaderboard) { const nearbyPlayers = Object.values(this.players).filter( p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2, ); const nearbyBullets = this.bullets.filter( b => b.distanceTo(player) <= Constants.MAP_SIZE / 2, ); return { t: Date.now(), me: player.serializeForUpdate(), others: nearbyPlayers.map(p => p.serializeForUpdate()), bullets: nearbyBullets.map(b => b.serializeForUpdate()), leaderboard, }; } } 

getLeaderboard()非常简单-按得分数对玩家进行排序,获得5个最佳成绩,然后为每个用户名和得分返回。

createUpdate()用于update()创建传递给玩家的游戏更新。它的主要任务是调用serializeForUpdate()Player类实现的方法Bullet请注意,他仅向每个玩家传送有关最近的玩家和炮弹的信息-无需传送与玩家距离较远的游戏对象的信息!

3.服务器上的游戏对象


在我们的游戏中,炮弹和玩家实际上非常相似:它们是抽象的圆形移动游戏对象。为了利用播放器和外壳的这种相似性,让我们从实现基类开始Object

object.js

 class Object { constructor(id, x, y, dir, speed) { this.id = id; this.x = x; this.y = y; this.direction = dir; this.speed = speed; } update(dt) { this.x += dt * this.speed * Math.sin(this.direction); this.y -= dt * this.speed * Math.cos(this.direction); } distanceTo(object) { const dx = this.x - object.x; const dy = this.y - object.y; return Math.sqrt(dx * dx + dy * dy); } setDirection(dir) { this.direction = dir; } serializeForUpdate() { return { id: this.id, x: this.x, y: this.y, }; } } 

这里没有复杂的事情发生。此类将是扩展的良好参考点。让我们看看该类如何Bullet使用Object

bullet.js

 const shortid = require('shortid'); const ObjectClass = require('./object'); const Constants = require('../shared/constants'); class Bullet extends ObjectClass { constructor(parentID, x, y, dir) { super(shortid(), x, y, dir, Constants.BULLET_SPEED); this.parentID = parentID; } // Returns true if the bullet should be destroyed update(dt) { super.update(dt); return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE; } } 

实现Bullet很短!我们Object添加了以下扩展名:

  • 使用shortid随机生成id弹丸。
  • 添加一个字段,parentID以便您可以跟踪创建此射弹的玩家。
  • 向加上返回值update()true如果射弹不在竞技场之外,则返回值相等(还记得,我们在上一节中讨论了这一点吗?)。

让我们继续Player

player.js

 const ObjectClass = require('./object'); const Bullet = require('./bullet'); const Constants = require('../shared/constants'); class Player extends ObjectClass { constructor(id, username, x, y) { super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED); this.username = username; this.hp = Constants.PLAYER_MAX_HP; this.fireCooldown = 0; this.score = 0; } // Returns a newly created bullet, or null. update(dt) { super.update(dt); // Update score this.score += dt * Constants.SCORE_PER_SECOND; // Make sure the player stays in bounds this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x)); this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y)); // Fire a bullet, if needed this.fireCooldown -= dt; if (this.fireCooldown <= 0) { this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN; return new Bullet(this.id, this.x, this.y, this.direction); } return null; } takeBulletDamage() { this.hp -= Constants.BULLET_DAMAGE; } onDealtDamage() { this.score += Constants.SCORE_BULLET_HIT; } serializeForUpdate() { return { ...(super.serializeForUpdate()), direction: this.direction, hp: this.hp, }; } } 

播放器比shell复杂,因此应在此类中存储更多字段。他的方法需要update()做很多工作,特别是如果不留下新创建的外壳,则返回它fireCooldown(请记住,我们在上一节中已经讨论过了吗?)。他还扩展了方法serializeForUpdate(),因为我们需要在游戏更新中包括玩家的其他字段。

拥有基类Object是避免代码可重复性的重要步骤。例如,如果没有类,则Object每个游戏对象都应具有相同的实现distanceTo(),并且将所有这些实现的复制粘贴同步到几个文件中将是一场噩梦。当扩展的数量增加时,这对于大型项目尤为重要Object

4.冲突识别


给我们留下的唯一一件事就是认识炮弹何时击中了玩家!请记住update()类中某个方法的这段代码Game

game.js

 const applyCollisions = require('./collisions'); class Game { // ... update() { // ... // Apply collisions, give players score for hitting bullets const destroyedBullets = applyCollisions( Object.values(this.players), this.bullets, ); destroyedBullets.forEach(b => { if (this.players[b.parentID]) { this.players[b.parentID].onDealtDamage(); } }); this.bullets = this.bullets.filter( bullet => !destroyedBullets.includes(bullet), ); // ... } } 

我们需要实现一种方法applyCollisions()该方法返回击中玩家的所有炮弹。幸运的是,这并不是那么困难,因为

  • 所有碰撞对象都是圆,这是实现碰撞识别的最简单图形。
  • 我们已经distanceTo()在类的上一节中实现了一种方法Object

这是我们的碰撞识别实现的样子:

crashs.js

 const Constants = require('../shared/constants'); // Returns an array of bullets to be destroyed. function applyCollisions(players, bullets) { const destroyedBullets = []; for (let i = 0; i < bullets.length; i++) { // Look for a player (who didn't create the bullet) to collide each bullet with. // As soon as we find one, break out of the loop to prevent double counting a bullet. for (let j = 0; j < players.length; j++) { const bullet = bullets[i]; const player = players[j]; if ( bullet.parentID !== player.id && player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS ) { destroyedBullets.push(bullet); player.takeBulletDamage(); break; } } } return destroyedBullets; } 

这种简单的碰撞识别基于以下事实:如果两个圆的中心之间的距离小于其半径之和,则两个圆会发生碰撞这是两个圆心之间的距离完全等于其半径之和时的情况:


在这里,您需要仔细考虑以下两个方面:

  • 射弹不应落入创建它的玩家手中。这可以通过比较可以实现bullet.parentIDplayer.id
  • 在与多个玩家同时碰撞的极端情况下,子弹只能命中一次。我们将在操作员的帮助下解决此问题break:一旦发现玩家与弹丸相撞,我们将停止搜索并继续前进至下一个弹丸。

结束


仅此而已!我们已经介绍了创建.io网络游戏所需的所有知识。接下来是什么? 建立自己的.io游戏!

所有的示例代码都是开源的,并发布在Github上

Source: https://habr.com/ru/post/zh-CN450574/


All Articles