2015年發布
如果您以前從未聽說過這些遊戲,這些是易於玩的免費多人網頁遊戲(無需帳戶)。 他們通常在同一競技場面對許多對手。 其他著名的.io遊戲:
在這篇文章中,我們將探討如何 從頭開始創建 .io 遊戲。 為此,只需了解 Javascript 就足夠了:您需要了解語法等內容 this
и
.io遊戲示例
對於學習幫助,我們將參考
遊戲非常簡單:您在有其他玩家的競技場中控制一艘船。 你的飛船會自動發射射彈,你會嘗試擊中其他玩家,同時避開他們的射彈。
1. 項目簡要概述/結構
我建議
下載源代碼 示例遊戲,以便您可以關注我。
該示例使用以下內容:
特快 是最流行的 Node.js Web 框架,用於管理遊戲的 Web 服務器。套接字.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.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/預設環境 為舊版瀏覽器轉譯我們的 JS 代碼。 - 我們使用一個插件來提取 JS 文件引用的所有 CSS 並將它們組合到一個地方。 我會稱他為我們的 CSS包.
您可能已經註意到奇怪的包文件名 '[name].[contenthash].ext'
。 他們包含 [name]
將被替換為輸入點的名稱(在我們的例子中,這個 game
),和 [contenthash]
將被替換為文件內容的哈希值。 我們這樣做是為了 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
在部署到生產時優化包大小。
本地設置
我建議在本地計算機上安裝該項目,以便您可以按照本文中列出的步驟進行操作。 設置很簡單:首先,系統必須已安裝
$ git clone https://github.com/vzhou842/example-.io-game.git
$ cd example-.io-game
$ npm install
現在您就可以出發了! 要啟動開發服務器,只需運行
$ npm run develop
並轉到網絡瀏覽器
3. 客戶端入口點
讓我們開始討論遊戲代碼本身。 首先我們需要一個頁面 index.html
,當訪問該網站時,瀏覽器將首先加載它。 我們的頁面將非常簡單:
的index.html
.io 遊戲示例 玩
為了清楚起見,此代碼示例已稍微簡化,我將對許多其他帖子示例執行相同的操作。 完整的代碼可以隨時查看
我們有:
HTML5 畫布元素 (<canvas>
)我們將用它來渲染遊戲。<link>
添加我們的 CSS 包。<script>
添加我們的 Javascript 包。- 帶用戶名的主菜單
<input>
和“播放”按鈕(<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 包中)。
- Запуск
connect()
與服務器建立連接並運行downloadAssets()
下載渲染遊戲所需的圖像。 - 第3階段完成後 顯示主菜單(
playMenu
). - 設置按下“PLAY”按鈕的處理程序。 當按下按鈕時,代碼會初始化遊戲並告訴服務器我們已經準備好開始玩了。
我們的客戶端-服務器邏輯的主要“內容”位於由文件導入的那些文件中 index.js
。 現在我們將按順序考慮它們。
4. 客戶數據交換
在這個遊戲中,我們使用一個知名的庫與服務器進行通信
我們將有一個文件 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. 客戶端渲染
是時候在屏幕上顯示圖片了!
…但在此之前,我們需要下載所需的所有圖像(資源)。 我們來寫一個資源管理器:
資產.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
.
下載資源後就可以開始渲染了。 如前所述,要在網頁上繪圖,我們使用 <canvas>
)。 我們的遊戲非常簡單,所以我們只需要繪製以下內容:
- 背景
- 玩家船
- 遊戲中的其他玩家
- 貝殼
這是重要的片段 src/client/render.js
,它準確呈現上面列出的四個項目:
渲染.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()
)並不那麼重要,但這裡有一個簡單的例子:
渲染.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,
);
}
請注意,我們正在使用該方法 getAsset()
,之前曾在 asset.js
!
如果您有興趣了解其他渲染助手,請閱讀其餘部分。
src/client/render.js .
6. 客戶輸入
是時候做個遊戲了 可玩的! 控制方案將非常簡單:要改變移動方向,您可以使用鼠標(在計算機上)或觸摸屏幕(在移動設備上)。 為了實現這一點,我們將註冊
會照顧好這一切 src/client/input.js
:
輸入.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. 客戶狀態
這一部分是本文第一部分中最困難的部分。 如果您第一次閱讀時不明白,請不要灰心! 您甚至可以跳過它並稍後再返回。
完成客戶端/服務器代碼所需的最後一塊拼圖是 為。 還記得客戶端渲染部分的代碼片段嗎?
渲染.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:指示此更新創建時間的服務器時間戳。
- me:有關接收此更新的玩家的信息。
- 其他類:有關參與同一遊戲的其他玩家的一系列信息。
- 子彈:有關遊戲中射彈的一系列信息。
- 排行榜:當前排行榜數據。 在這篇文章中,我們不會考慮它們。
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 次,那麼其中一半的調用只會重繪相同的內容,基本上什麼也不做。 天真的實現的另一個問題是它 容易出現延誤。 在理想的互聯網速度下,客戶端將每 33 毫秒收到一次遊戲更新(每秒 30 次):
不幸的是,沒有什麼是完美的。 更現實的情況是:
就延遲而言,幼稚的實現實際上是最糟糕的情況。 如果延遲 50 毫秒收到遊戲更新,則 客戶攤位 額外的 50 毫秒,因為它仍在渲染上次更新的遊戲狀態。 你可以想像這對於玩家來說是多麼不舒服:任意剎車會讓遊戲感覺生澀和不穩定。
7.2 改進的客戶端狀態
我們將對幼稚的實現進行一些改進。 首先,我們使用 渲染延遲 100 毫秒。 這意味著客戶端的“當前”狀態將始終落後於服務器上的遊戲狀態 100 毫秒。 例如,如果服務器上的時間是 150,那麼客戶端就會渲染出服務器當時的狀態 50:
這為我們提供了 100 毫秒的緩衝區來應對不可預測的遊戲更新時間:
這樣做的回報將是永久的
我們還可以使用另一種技術,稱為
客戶端預測 ,它在減少感知延遲方面做得很好,但本文不會討論。
我們正在使用的另一個改進是 線性插值。 由於渲染延遲,我們通常會比客戶端中的當前時間提前至少一次更新。 被叫時 getCurrentState()
,我們可以執行
這解決了幀速率問題:我們現在可以以我們想要的任何幀速率渲染獨特的幀!
7.3 實現增強的客戶端狀態
實現示例在 src/client/state.js
同時使用渲染延遲和線性插值,但時間不會太長。 讓我們將代碼分成兩部分。 這是第一個:
狀態.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 毫秒渲染圖像,但是 我們永遠不會知道服務器上的當前時間,因為我們不知道任何更新需要多長時間才能到達我們手中。 互聯網是不可預測的,其速度差異很大!
為了解決這個問題,我們可以使用一個合理的近似: 假裝第一個更新立即到達。 如果這是真的,那麼我們就會知道這個特定時刻的服務器時間! 我們將服務器的時間戳存儲在 firstServerTimestamp
並保留我們的 當地的 (客戶端)同一時刻的時間戳 gameStart
.
等一下。 不應該是服務器時間=客戶端時間嗎? 為什麼要區分“服務器時間戳”和“客戶端時間戳”? 這是一個很好的問題! 事實證明它們不是同一件事。 Date.now()
將在客戶端和服務器中返回不同的時間戳,這取決於這些機器的本地因素。 永遠不要假設所有機器上的時間戳都是相同的。
現在我們明白了什麼是 currentServerTime()
: 它返回 當前渲染時間的服務器時間戳。 換句話說,這是服務器的當前時間(firstServerTimestamp <+ (Date.now() - gameStart)
) 減去渲染延遲 (RENDER_DELAY
).
現在讓我們看看我們如何處理遊戲更新。 當從更新服務器收到時,它被稱為 processGameUpdate()
然後我們將新的更新保存到數組中 gameUpdates
。 然後,為了檢查內存使用情況,我們刪除之前的所有舊更新 基礎更新因為我們不再需要它們了。
什麼是“基本更新”? 這 我們通過從服務器當前時間向後移動找到的第一個更新。 還記得這張圖嗎?
“客戶端渲染時間”左側的遊戲更新是基礎更新。
基礎更新有什麼用? 為什麼我們可以放棄對基線的更新? 為了弄清楚這一點,讓我們 最後 考慮實施 getCurrentState()
:
狀態.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),
};
}
}
我們處理三種情況:
base < 0
意味著在當前渲染時間之前沒有更新(參見上面的實現getBaseUpdate()
)。 由於渲染延遲,這種情況可能會在遊戲開始時發生。 在這種情況下,我們使用收到的最新更新。base
是我們的最新更新。 這可能是由於網絡延遲或互聯網連接不良造成的。 在這種情況下,我們也使用我們擁有的最新更新。- 我們在當前渲染時間之前和之後都有更新,因此我們可以 插!
剩下的一切都在 state.js
是線性插值的實現,是簡單(但無聊)的數學。 如果您想親自探索,請打開 state.js
上
第 2 部分. 後端服務器
在這一部分中,我們將看一下控制我們的 Node.js 後端
1. 服務器入口點
為了管理 Web 服務器,我們將使用一個名為 Node.js 的流行 Web 框架 src/server/server.js
:
服務器.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-中間件 自動重建我們的開發包,或者 - 靜態傳輸文件夾
dist/
,Webpack 將在生產構建後將我們的文件寫入其中。
另一項重要任務 server.js
就是設置服務器
服務器.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 連接後,我們為新套接字設置事件處理程序。 事件處理程序通過委託給單例對象來處理從客戶端接收到的消息 game
:
服務器.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
包含服務器端最重要的邏輯。 它有兩個主要任務: 球員管理 и 遊戲模擬.
讓我們從第一個任務開始,玩家管理。
遊戲.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綁定到代碼>Player對象的對象
bullets
是一個對像數組 Bullet
,沒有明確的順序。
lastUpdateTime
是遊戲上次更新的時間戳。 我們很快就會看到它是如何使用的。
shouldSendUpdate
是一個輔助變量。 我們很快也會看到它的使用。
方法 addPlayer()
, removePlayer()
и handleInput()
無需解釋,它們用於 server.js
。 如果您需要刷新記憶,請返回更高一點。
最後一行 constructor()
啟動 更新周期 遊戲(更新頻率為60次/秒):
遊戲.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()
包含可能是最重要的服務器端邏輯部分。 以下是它的作用(按順序):
- 計算多長時間
dt
自上次以來已過去update()
. - 刷新每個射彈並在必要時銷毀它們。 稍後我們將看到此功能的實現。 目前,我們只要知道這一點就足夠了
bullet.update()
回報true
如果射彈應該被銷毀 (他走出了競技場)。 - 更新每個玩家並在必要時創建一個射彈。 稍後我們還將看到這個實現 -
player.update()
可以返回一個對象Bullet
. - 檢查射彈和玩家之間的碰撞
applyCollisions()
,它返回擊中玩家的射彈數組。 對於每個返回的射彈,我們都會增加發射它的玩家的分數(使用player.onDealtDamage()
)然後從數組中移除射彈bullets
. - 通知並消滅所有被殺死的玩家。
- 向所有玩家發送遊戲更新 每一秒 被叫時的次數
update()
。 這有助於我們跟踪上面提到的輔助變量。shouldSendUpdate
。 因為update()
稱為60次/秒,我們發送遊戲更新30次/秒。 因此, 時鐘頻率 服務器時鐘為 30 個時鐘/秒(我們在第一部分中討論了時鐘速率)。
為什麼只發送遊戲更新 通過時間 ? 保存頻道。 每秒 30 次遊戲更新已經很多了!
為什麼不直接打電話
update()
每秒30次? 提高遊戲模擬性。 更常被稱為update()
,遊戲模擬越準確。 但不要因挑戰的數量而過於得意忘形。update()
,因為這是一項計算成本較高的任務 - 每秒 60 次就足夠了。
班級的其餘部分 Game
由使用的輔助方法組成 update()
:
遊戲.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()
非常簡單 - 它按分數對玩家進行排序,取前五名,然後返回每個玩家的用戶名和分數。
createUpdate()
用於 update()
創建分發給玩家的遊戲更新。 它的主要任務是調用方法 serializeForUpdate()
為班級實施 Player
и Bullet
。 請注意,它僅將數據傳遞給每個玩家 最近的 玩家和投射物 - 無需傳輸遠離玩家的遊戲對象的信息!
3. 服務器上的遊戲對象
在我們的遊戲中,彈丸和玩家實際上非常相似:它們都是抽象的、圓形的、可移動的遊戲對象。 為了利用玩家和射彈之間的這種相似性,讓我們從實現基類開始 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
:
子彈.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
僅以下擴展:
- 使用包
短號 用於隨機生成id
射彈。 - 添加字段
parentID
這樣您就可以追踪創建此射彈的玩家。 - 添加返回值
update()
,等於true
如果射彈在競技場之外(還記得我們在上一節中討論過這一點嗎?)。
讓我們繼續 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,
};
}
}
玩家比射彈更複雜,因此這個類中應該存儲更多的字段。 他的方法 update()
做了很多工作,特別是如果沒有剩下的,則返回新創建的射彈 fireCooldown
(還記得我們在上一節中討論過這個嗎?)。 它還擴展了方法 serializeForUpdate()
,因為我們需要在遊戲更新中包含玩家的附加字段。
有一個基類 Object
- 避免重複代碼的重要一步。 例如,沒有課 Object
每個遊戲對象必須具有相同的實現 distanceTo()
,並且在多個文件中復制粘貼所有這些實現將是一場噩夢。 這對於大型項目尤其重要。當數量擴大時 Object
班級人數不斷增加。
4. 碰撞檢測
我們唯一要做的就是識別射彈何時擊中玩家! 記住方法中的這段代碼 update()
在班上 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
.
這是我們的碰撞檢測實現的樣子:
碰撞.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.parentID
сplayer.id
. - 在多個玩家同時碰撞的極限情況下,射彈只能擊中一次。 我們將使用運算符來解決這個問題
break
:一旦發現與彈丸碰撞的玩家,我們就停止搜索並繼續處理下一個彈丸。
Конец
就這樣! 我們已經介紹了創建 .io 網頁遊戲所需了解的所有內容。 下一步是什麼? 構建您自己的 .io 遊戲!
所有示例代碼都是開源的並發佈在
來源: www.habr.com