如何構(gòu)建一個(gè)多人(.io) Web 游戲,第 1 部分

banner.png

原文:How to Build a Multiplayer (.io) Web Game, Part 1

GitHub: https://github.com/vzhou842/example-.io-game

深入探索一個(gè) .io 游戲的 Javascript client-side(客戶端)谭胚。

如果您以前從未聽說過 .io 游戲:它們是免費(fèi)的多人 web 游戲,易于加入(無需帳戶),
并且通常在一個(gè)區(qū)域內(nèi)讓許多玩家相互競爭。其他著名的 .io 游戲包括 Slither.ioDiep.io腹缩。

在本文中,我們將了解如何從頭開始構(gòu)建.io游戲空扎。
您所需要的只是 Javascript 的實(shí)用知識:
您應(yīng)該熟悉 ES6 語法庆聘,this 關(guān)鍵字和 Promises之類的內(nèi)容。
即使您對 Javascript 并不是最熟悉的勺卢,您仍然應(yīng)該可以閱讀本文的大部分內(nèi)容。

一個(gè) .io 游戲示例

為了幫助我們學(xué)習(xí)象对,我們將參考 https://example-io-game.victorzhou.com黑忱。

1.png

這是一款非常簡單的游戲:你和其他玩家一起控制競技場中的一艘船。
你的飛船會(huì)自動(dòng)發(fā)射子彈勒魔,你會(huì)試圖用自己的子彈擊中其他玩家甫煞,同時(shí)避開他們。

目錄

這是由兩部分組成的系列文章的第 1 部分冠绢。我們將在這篇文章中介紹以下內(nèi)容:

  1. 項(xiàng)目概況/結(jié)構(gòu):項(xiàng)目的高級視圖抚吠。
  2. 構(gòu)建/項(xiàng)目設(shè)置:開發(fā)工具、配置和設(shè)置弟胀。
  3. Client 入口:index.html 和 index.js楷力。
  4. Client 網(wǎng)絡(luò)通信:與服務(wù)器通信。
  5. Client 渲染:下載 image 資源 + 渲染游戲孵户。
  6. Client 輸入:讓用戶真正玩游戲萧朝。
  7. Client 狀態(tài):處理來自服務(wù)器的游戲更新。

1. 項(xiàng)目概況/結(jié)構(gòu)

我建議下載示例游戲的源代碼夏哭,以便您可以更好的繼續(xù)閱讀检柬。

我們的示例游戲使用了:

  • Express,Node.js 最受歡迎的 Web 框架竖配,以為其 Web 服務(wù)器提供動(dòng)力何址。
  • socket.io,一個(gè) websocket 庫进胯,用于在瀏覽器和服務(wù)器之間進(jìn)行通信用爪。
  • Webpack,一個(gè)模塊打包器胁镐。

項(xiàng)目目錄的結(jié)構(gòu)如下所示:

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

public/

我們的服務(wù)器將靜態(tài)服務(wù) public/ 文件夾中的所有內(nèi)容项钮。 public/assets/ 包含我們項(xiàng)目使用的圖片資源。

src/

所有源代碼都在 src/ 文件夾中。
client/server/ 很容易說明烁巫,shared/ 包含一個(gè)由 client 和 server 導(dǎo)入的常量文件署隘。

2. 構(gòu)建/項(xiàng)目設(shè)置

如前所述,我們正在使用 Webpack 模塊打包器來構(gòu)建我們的項(xiàng)目亚隙。讓我們看一下我們的 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) 客戶端入口點(diǎn)磁餐。Webpack 將從那里開始,遞歸地查找其他導(dǎo)入的文件阿弃。
  • 我們的 Webpack 構(gòu)建的 JS 輸出將放置在 dist/ 目錄中诊霹。我將此文件稱為 JS bundle。
  • 我們正在使用 Babel渣淳,特別是 @babel/preset-env 配置脾还,來為舊瀏覽器編譯 JS 代碼。
  • 我們正在使用一個(gè)插件來提取 JS 文件引用的所有 CSS 并將其捆綁在一起入愧。我將其稱為 CSS bundle鄙漏。

您可能已經(jīng)注意到奇怪的 '[name].[contenthash].ext' 捆綁文件名。
它們包括 Webpack 文件名替換:[name] 將替換為入口點(diǎn)名稱(這是game)棺蛛,[contenthash]將替換為文件內(nèi)容的哈希怔蚌。
我們這樣做是為了優(yōu)化緩存 - 我們可以告訴瀏覽器永遠(yuǎn)緩存我們的 JS bundle,因?yàn)槿绻?JS bundle 更改旁赊,其文件名也將更改(contenthash 也會(huì)更改)桦踊。最終結(jié)果是一個(gè)文件名,例如:game.dbeee76e91a97d0c7207.js终畅。

webpack.common.js 文件是我們在開發(fā)和生產(chǎn)配置中導(dǎo)入的基本配置文件籍胯。例如,下面是開發(fā)配置:

webpack.dev.js

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
});

我們在開發(fā)過程中使用 webpack.dev.js 來提高效率离福,并在部署到生產(chǎn)環(huán)境時(shí)切換到 webpack.prod.js 來優(yōu)化包的大小芒炼。

本地設(shè)置

我建議在您的本地計(jì)算機(jī)上安裝該項(xiàng)目,以便您可以按照本文的其余內(nèi)容進(jìn)行操作术徊。
設(shè)置很簡單:首先本刽,確保已安裝 NodeNPM。 然后赠涮,

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

您就可以出發(fā)了子寓! 要運(yùn)行開發(fā)服務(wù)器,只需

$ npm run develop

并在網(wǎng)絡(luò)瀏覽器中訪問 localhost:3000笋除。
當(dāng)您編輯代碼時(shí)斜友,開發(fā)服務(wù)器將自動(dòng)重建 JS 和 CSS bundles - 只需刷新即可查看更改!

3. Client 入口

讓我們來看看實(shí)際的游戲代碼垃它。首先鲜屏,我們需要一個(gè) index.html 頁面烹看,
這是您的瀏覽器訪問網(wǎng)站時(shí)首先加載的內(nèi)容。我們的將非常簡單:

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 代碼就會(huì)開始執(zhí)行忆嗜,
從我們的 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);
  };
});

這似乎很復(fù)雜,但實(shí)際上并沒有那么多事情發(fā)生:

  • 導(dǎo)入一堆其他 JS 文件捆毫。
  • 導(dǎo)入一些 CSS(因此 Webpack 知道將其包含在我們的 CSS bundle 中)闪湾。
  • 運(yùn)行 connect() 來建立到服務(wù)器的連接,運(yùn)行 downloadAssets() 來下載渲染游戲所需的圖像绩卤。
  • 步驟 3 完成后途样,顯示主菜單(playMenu)。
  • 為 “PLAY” 按鈕設(shè)置一個(gè)點(diǎn)擊處理程序省艳。如果點(diǎn)擊,初始化游戲并告訴服務(wù)器我們準(zhǔn)備好玩了嫁审。

客戶端邏輯的核心駐留在由 index.js 導(dǎo)入的其他文件中跋炕。接下來我們將逐一討論這些問題。

4. Client 網(wǎng)絡(luò)通信

對于此游戲律适,我們將使用眾所周知的 socket.io 庫與服務(wù)器進(jìn)行通信辐烂。
Socket.io 包含對 WebSocket 的內(nèi)置支持,
這非常適合雙向通訊:我們可以將消息發(fā)送到服務(wù)器捂贿,而服務(wù)器可以通過同一連接向我們發(fā)送消息纠修。

我們將有一個(gè)文件 src/client/networking.js,它負(fù)責(zé)所有與服務(wù)器的通信:

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

此文件中發(fā)生3件主要事情:

  • 我們嘗試連接到服務(wù)器厂僧。只有建立連接后扣草,connectedPromise 才能解析。
  • 如果連接成功颜屠,我們注冊回調(diào)( processGameUpdate()onGameOver() )我們可能從服務(wù)器接收到的消息辰妙。
  • 我們導(dǎo)出 play()updateDirection() 以供其他文件使用。

5. Client 渲染

是時(shí)候讓東西出現(xiàn)在屏幕上了!

但在此之前甫窟,我們必須下載所需的所有圖像(資源)密浑。讓我們寫一個(gè)資源管理器:

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 并不難實(shí)現(xiàn)!主要思想是保留一個(gè) assets 對象粗井,它將文件名 key 映射到一個(gè) Image 對象值尔破。
當(dāng)一個(gè) asset 下載完成后街图,我們將其保存到 assets 對象中,以便以后檢索懒构。
最后餐济,一旦每個(gè) asset 下載都已 resolve(意味著所有 assets 都已下載),我們就 resolve downloadPromise痴脾。

隨著資源的下載颤介,我們可以繼續(xù)進(jìn)行渲染。如前所述赞赖,我們正在使用 HTML5 畫布(<canvas>)繪制到我們的網(wǎng)頁上滚朵。我們的游戲非常簡單,所以我們需要畫的是:

  1. 背景
  2. 我們玩家的飛船
  3. 游戲中的其他玩家
  4. 子彈

這是 src/client/render.js 的重要部分前域,它準(zhǔn)確地繪制了我上面列出的那四件事:

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() 是該文件的主要函數(shù)辕近。startRendering()stopRendering() 控制 60 FPS 渲染循環(huán)的激活。

各個(gè)渲染幫助函數(shù)(例如 renderBullet() )的具體實(shí)現(xiàn)并不那么重要匿垄,但這是一個(gè)簡單的示例:

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

請注意移宅,我們?nèi)绾问褂们懊嬖?asset.js 中看到的 getAsset() 方法!

如果你對其他渲染幫助函數(shù)感興趣椿疗,請閱讀 src/client/render.js 的其余部分漏峰。

6. Client 輸入???

現(xiàn)在該使游戲變得可玩了!我們的 control scheme 非常簡單:使用鼠標(biāo)(在桌面上)或觸摸屏幕(在移動(dòng)設(shè)備上)來控制移動(dòng)方向届榄。為此浅乔,我們將為 Mouse 和 Touch 事件注冊事件監(jiān)聽器。

src/client/input.js 會(huì)處理這些問題:

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() 是事件監(jiān)聽器铝条,當(dāng)輸入事件發(fā)生(例如:鼠標(biāo)移動(dòng))時(shí)靖苇,
它們調(diào)用 updateDirection() (來自 networking.js )。
updateDirection() 負(fù)責(zé)向服務(wù)器發(fā)送消息班缰,服務(wù)器將處理輸入事件并相應(yīng)地更新游戲狀態(tài)贤壁。

7. Client 狀態(tài)

這部分是這篇文章中最先進(jìn)的部分。如果你一遍讀不懂所有內(nèi)容埠忘,不要灰心脾拆!請隨意跳過這一節(jié),稍后再來討論它莹妒。

完成客戶端代碼所需的最后一個(gè)難題是狀態(tài)假丧。還記得“客戶端渲染”部分的這段代碼嗎?

render.js

import { getCurrentState } from './state';

function render() {
  const { me, others, bullets } = getCurrentState();

  // Do the rendering
  // ...
}

getCurrentState() 必須能夠根據(jù)從服務(wù)器接收到的游戲更新隨時(shí)向我們提供客戶端的當(dāng)前游戲狀態(tài)动羽。這是服務(wù)器可能發(fā)送的游戲更新示例:

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

每個(gè)游戲更新都具有以下 5 個(gè)字段:

  • t:創(chuàng)建此更新的服務(wù)器時(shí)間戳包帚。
  • me:接收更新的玩家的 player 信息。
  • others:同一游戲中其他玩家的玩家信息數(shù)組运吓。
  • bullets:在游戲中的 bullets 子彈信息的數(shù)組渴邦。
  • leaderboard:當(dāng)前排行榜數(shù)據(jù)疯趟。

7.1 Native 客戶端狀態(tài)

getCurrentState() 的 native 實(shí)現(xiàn)可以直接返回最近收到的游戲更新的數(shù)據(jù)。

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

干凈整潔谋梭!如果那么簡單就好了信峻。此實(shí)現(xiàn)存在問題的原因之一是因?yàn)樗鼘秩編俾氏拗茷榉?wù)器 tick 速率。

  • Frame Rate:每秒的幀數(shù)(即瓮床,render()調(diào)用)或 FPS盹舞。游戲通常以至少 60 FPS 為目標(biāo)。
  • Tick Rate:服務(wù)器向客戶端發(fā)送游戲更新的速度隘庄。這通常低于幀速率踢步。對于我們的游戲,服務(wù)器以每秒30 ticks 的速度運(yùn)行丑掺。

如果我們僅提供最新的游戲更新获印,則我們的有效 FPS 不能超過 30,因?yàn)槲覀冇肋h(yuǎn)不會(huì)從服務(wù)器每秒收到超過 30 的更新街州。即使我們每秒調(diào)用 render() 60次兼丰,這些調(diào)用中的一半也只會(huì)重繪完全相同的內(nèi)容,實(shí)際上什么也沒做唆缴。

Native 實(shí)現(xiàn)的另一個(gè)問題是它很容易滯后鳍征。在完美的互聯(lián)網(wǎng)條件下,客戶端將完全每33毫秒(每秒30個(gè))收到一次游戲更新:

2.png

可悲的是面徽,沒有什么比這更完美艳丛。 一個(gè)更現(xiàn)實(shí)的表示可能看起來像這樣:

3.png

當(dāng)涉及到延遲時(shí),native 實(shí)現(xiàn)幾乎是最糟糕的情況斗忌。
如果游戲更新晚到50毫秒质礼,客戶端會(huì)多凍結(jié)50毫秒旺聚,因?yàn)樗栽阡秩厩耙粋€(gè)更新的游戲狀態(tài)织阳。
你可以想象這對玩家來說是多么糟糕的體驗(yàn):游戲會(huì)因?yàn)殡S機(jī)凍結(jié)而感到不安和不穩(wěn)定。

7.2 更好的客戶端狀態(tài)

我們將對這個(gè)簡單的實(shí)現(xiàn)進(jìn)行一些簡單的改進(jìn)砰粹。第一種是使用100毫秒的渲染延遲唧躲,這意味著“當(dāng)前”客戶端狀態(tài)總是比服務(wù)器的游戲狀態(tài)滯后100毫秒。例如碱璃,如果服務(wù)器的時(shí)間是150弄痹,客戶端呈現(xiàn)的狀態(tài)將是服務(wù)器在時(shí)間50時(shí)的狀態(tài):

4.png

這給了我們100毫秒的緩沖區(qū)來容忍不可預(yù)測的游戲更新到來:

5.png

這樣做的代價(jià)是恒定的100毫秒輸入延遲。對于擁有穩(wěn)定流暢的游戲玩法來說嵌器,這是一個(gè)小小的代價(jià)——大多數(shù)玩家(尤其是休閑玩家)甚至不會(huì)注意到游戲的延遲肛真。對人類來說,適應(yīng)恒定的100毫秒的延遲要比嘗試應(yīng)付不可預(yù)測的延遲容易得多爽航。

我們可以使用另一種稱為“客戶端預(yù)測”的技術(shù)蚓让,該技術(shù)可以有效地減少感知到的滯后乾忱,但這超出了本文的范圍。

我們將進(jìn)行的另一項(xiàng)改進(jìn)是使用線性插值历极。由于渲染延遲窄瘟,通常我們會(huì)比當(dāng)前客戶端時(shí)間早至少更新1次。每當(dāng)調(diào)用 getCurrentState() 時(shí)趟卸,我們都可以在當(dāng)前客戶端時(shí)間前后立即在游戲更新之間進(jìn)行線性插值:

6.png

這解決了我們的幀率問題:我們現(xiàn)在可以隨心所欲地渲染獨(dú)特的幀了蹄葱!

7.3 實(shí)現(xiàn)更好的客戶端狀態(tài)

src/client/state.js 中的示例實(shí)現(xiàn)使用了渲染延遲和線性插值,但有點(diǎn)長锄列。讓我們把它分解成幾個(gè)部分图云。這是第一個(gè):

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() 的功能。如前所述右蕊,每個(gè)游戲更新都包含服務(wù)器時(shí)間戳琼稻。我們希望使用渲染延遲來在服務(wù)器后渲染100毫秒,但我們永遠(yuǎn)不會(huì)知道服務(wù)器上的當(dāng)前時(shí)間饶囚,因?yàn)槲覀儾恢廊魏谓o定更新要花費(fèi)多長時(shí)間帕翻。互聯(lián)網(wǎng)是無法預(yù)測的萝风,并且變化很大嘀掸!

為了解決這個(gè)問題,我們將使用一個(gè)合理的近似方法:我們假設(shè)第一個(gè)更新立即到達(dá)规惰。如果這是真的睬塌,那么我們就會(huì)知道服務(wù)器在那一刻的時(shí)間!我們在 firstServerTimestamp 中存儲(chǔ)服務(wù)器時(shí)間戳歇万,在 gameStart 中存儲(chǔ)本地(客戶端)時(shí)間戳揩晴。

哇,等一下贪磺。服務(wù)器上的時(shí)間不應(yīng)該等于客戶端上的時(shí)間嗎硫兰?為什么在“服務(wù)器時(shí)間戳”和“客戶端時(shí)間戳”之間有區(qū)別?這是個(gè)好問題寒锚,讀者們劫映!事實(shí)證明,它們不一樣刹前。Date.now() 將根據(jù)客戶端和服務(wù)器的本地因素返回不同的時(shí)間戳泳赋。永遠(yuǎn)不要假設(shè)您的時(shí)間戳在不同機(jī)器之間是一致的。

現(xiàn)在很清楚 currentServerTime() 的作用了:它返回當(dāng)前渲染時(shí)間的服務(wù)器時(shí)間戳喇喉。換句話說祖今,它是當(dāng)前服務(wù)器時(shí)間(firstServerTimestamp + (Date.now() - gameStart)) 減去渲染延遲(RENDER_DELAY)。

接下來,讓我們了解如何處理游戲更新千诬。processGameUpdate() 在從服務(wù)器接收到更新時(shí)被調(diào)用撒踪,我們將新更新存儲(chǔ)在 gameUpdates 數(shù)組中。然后大渤,為了檢查內(nèi)存使用情況制妄,我們刪除了在基本更新之前的所有舊更新,因?yàn)槲覀儾辉傩枰鼈兞恕?/p>

基本更新到底是什么泵三? 這是我們從當(dāng)前服務(wù)器時(shí)間倒退時(shí)發(fā)現(xiàn)的第一個(gè)更新耕捞。 還記得這張圖嗎?

6.png

“客戶端渲染時(shí)間”左邊的游戲更新是基礎(chǔ)更新烫幕。

基礎(chǔ)更新的用途是什么俺抽?為什么我們可以丟棄基礎(chǔ)更新之前的更新?最后讓我們看看 getCurrentState() 的實(shí)現(xiàn)较曼,以找出:

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種情況:

  1. base < 0磷斧,意味著在當(dāng)前渲染時(shí)間之前沒有更新(請參見上面的 getBaseUpdate() 的實(shí)現(xiàn))。由于渲染延遲捷犹,這可能會(huì)在游戲開始時(shí)發(fā)生弛饭。在這種情況下,我們將使用最新的更新萍歉。
  2. base 是我們最新的更新(??)侣颂。這種情況可能是由于網(wǎng)絡(luò)連接的延遲或較差造成的。在本例中枪孩,我們還使用了最新的更新憔晒。
  3. 我們在當(dāng)前渲染時(shí)間之前和之后都有更新,所以我們可以插值蔑舞!

state.js 剩下的就是線性插值的實(shí)現(xiàn)拒担,這只是一些簡單(但很無聊)的數(shù)學(xué)運(yùn)算。如果您想查看攻询,請?jiān)?Github 上查看 state.js朵栖。

我是為少胞枕。
微信:uuhells123戒良。
公眾號:黑客下午茶殷绍。
謝謝點(diǎn)贊支持??????呆馁!
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末桐经,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子浙滤,更是在濱河造成了極大的恐慌阴挣,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,627評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件纺腊,死亡現(xiàn)場離奇詭異畔咧,居然都是意外死亡茎芭,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,180評論 3 399
  • 文/潘曉璐 我一進(jìn)店門誓沸,熙熙樓的掌柜王于貴愁眉苦臉地迎上來梅桩,“玉大人,你說我怎么就攤上這事拜隧∷薨伲” “怎么了?”我有些...
    開封第一講書人閱讀 169,346評論 0 362
  • 文/不壞的土叔 我叫張陵洪添,是天一觀的道長垦页。 經(jīng)常有香客問我,道長干奢,這世上最難降的妖魔是什么痊焊? 我笑而不...
    開封第一講書人閱讀 60,097評論 1 300
  • 正文 為了忘掉前任,我火速辦了婚禮忿峻,結(jié)果婚禮上薄啥,老公的妹妹穿的比我還像新娘。我一直安慰自己逛尚,他們只是感情好罪佳,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,100評論 6 398
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著黑低,像睡著了一般赘艳。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上克握,一...
    開封第一講書人閱讀 52,696評論 1 312
  • 那天蕾管,我揣著相機(jī)與錄音,去河邊找鬼菩暗。 笑死掰曾,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的停团。 我是一名探鬼主播旷坦,決...
    沈念sama閱讀 41,165評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼佑稠!你這毒婦竟也來了秒梅?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 40,108評論 0 277
  • 序言:老撾萬榮一對情侶失蹤舌胶,失蹤者是張志新(化名)和其女友劉穎捆蜀,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,646評論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡辆它,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,709評論 3 342
  • 正文 我和宋清朗相戀三年誊薄,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片锰茉。...
    茶點(diǎn)故事閱讀 40,861評論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡呢蔫,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出飒筑,到底是詐尸還是另有隱情咐刨,我是刑警寧澤,帶...
    沈念sama閱讀 36,527評論 5 351
  • 正文 年R本政府宣布扬霜,位于F島的核電站定鸟,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏著瓶。R本人自食惡果不足惜联予,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,196評論 3 336
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望材原。 院中可真熱鬧沸久,春花似錦、人聲如沸余蟹。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,698評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽威酒。三九已至窑睁,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間葵孤,已是汗流浹背担钮。 一陣腳步聲響...
    開封第一講書人閱讀 33,804評論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留尤仍,地道東北人箫津。 一個(gè)月前我還...
    沈念sama閱讀 49,287評論 3 379
  • 正文 我出身青樓,卻偏偏與公主長得像宰啦,于是被迫代替她去往敵國和親苏遥。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,860評論 2 361

推薦閱讀更多精彩內(nèi)容