การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
เปิดตัวในปี 2015 Agar.io กลายเป็นต้นกำเนิดของประเภทใหม่ เกม .ioซึ่งได้รับความนิยมเพิ่มขึ้นตั้งแต่นั้นเป็นต้นมา ฉันมีประสบการณ์ส่วนตัวกับความนิยมที่เพิ่มขึ้นของเกม .io ในช่วงสามปีที่ผ่านมา สร้างและขายสองเกมประเภทนี้.

ในกรณีที่คุณไม่เคยได้ยินเกี่ยวกับเกมเหล่านี้มาก่อน เกมเหล่านี้เป็นเกมบนเว็บฟรีสำหรับผู้เล่นหลายคนที่เล่นง่าย (ไม่ต้องใช้บัญชี) พวกเขามักจะเผชิญหน้ากับผู้เล่นฝ่ายตรงข้ามหลายคนในเวทีเดียวกัน เกม .io ที่มีชื่อเสียงอื่นๆ: Slither.io и Diep.io.

ในโพสต์นี้ เราจะมาดูกันว่า สร้างเกม .io ตั้งแต่เริ่มต้น. สำหรับสิ่งนี้ ความรู้เกี่ยวกับ Javascript เท่านั้นก็เพียงพอแล้ว คุณต้องเข้าใจสิ่งต่างๆ เช่น ไวยากรณ์ ES6, คำสำคัญ this и สัญญา. แม้ว่าความรู้ Javascript ของคุณจะไม่สมบูรณ์ แต่คุณก็ยังสามารถเข้าใจโพสต์ส่วนใหญ่ได้

ตัวอย่างเกม .io

สำหรับความช่วยเหลือในการเรียนรู้ เราจะอ้างถึง ตัวอย่างเกม .io. ลองเล่นดูสิ!

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
เกมนี้ค่อนข้างง่าย: คุณควบคุมเรือในเวทีที่มีผู้เล่นคนอื่น เรือของคุณยิงขีปนาวุธโดยอัตโนมัติ และคุณพยายามโจมตีผู้เล่นคนอื่นในขณะที่หลบกระสุนของพวกเขา

1. ภาพรวมโดยสังเขป / โครงสร้างของโครงการ

แนะนำ ดาวน์โหลดซอร์สโค้ด ตัวอย่างเกม ฝากกดติดตามด้วยนะครับ

ตัวอย่างใช้ต่อไปนี้:

  • รวดเร็ว เป็นเฟรมเวิร์กเว็บ 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 จะเริ่มต้นจากที่นี่และค้นหาไฟล์ที่นำเข้าอื่นๆ ซ้ำๆ
  • JS เอาต์พุตของบิลด์ Webpack ของเราจะอยู่ในไดเร็กทอรี dist/. ฉันจะเรียกไฟล์นี้ว่า js แพ็คเกจ.
  • เราใช้ ที่จอแจอึกทึกและโดยเฉพาะอย่างยิ่งการกำหนดค่า @babel/preset-env เพื่อแปลรหัส JS ของเราสำหรับเบราว์เซอร์รุ่นเก่า
  • เรากำลังใช้ปลั๊กอินเพื่อแยก CSS ทั้งหมดที่อ้างอิงโดยไฟล์ JS และรวมไว้ในที่เดียว ฉันจะเรียกเขาว่าของเรา แพ็คเกจ css.

คุณอาจสังเกตเห็นชื่อไฟล์แพ็คเกจแปลกๆ '[name].[contenthash].ext'. ประกอบด้วย การแทนที่ชื่อไฟล์ เว็บแพ็ค: [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.prod.jsเพื่อเพิ่มประสิทธิภาพขนาดบรรจุภัณฑ์เมื่อปรับใช้กับการผลิต

การตั้งค่าท้องถิ่น

ฉันแนะนำให้ติดตั้งโครงการบนเครื่องท้องถิ่น เพื่อให้คุณสามารถทำตามขั้นตอนที่แสดงในโพสต์นี้ การตั้งค่าทำได้ง่าย: ก่อนอื่นต้องติดตั้งระบบ โหนด и NPM. ต่อไปคุณต้องทำ

$ 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

ตัวอย่างเกม .io  เล่น

ตัวอย่างโค้ดนี้ได้รับการทำให้ง่ายขึ้นเล็กน้อยเพื่อความชัดเจน และฉันจะทำเช่นเดียวกันกับตัวอย่างโพสต์อื่นๆ อีกมากมาย สามารถดูโค้ดฉบับเต็มได้ที่ Github.

เรามี:

  • องค์ประกอบผ้าใบ HTML5 (<canvas>) ที่เราจะใช้ในการเรนเดอร์เกม
  • <link> เพื่อเพิ่มแพ็คเกจ CSS ของเรา
  • <script> เพื่อเพิ่มแพ็คเกจ Javascript ของเรา
  • เมนูหลักพร้อมชื่อผู้ใช้ <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);
  };
});

อาจฟังดูซับซ้อน แต่ไม่มีอะไรเกิดขึ้นที่นี่:

  1. นำเข้าไฟล์ JS อื่น ๆ หลายไฟล์
  2. การนำเข้า CSS (ดังนั้น Webpack จึงทราบที่จะรวมไว้ในแพ็คเกจ CSS ของเรา)
  3. ยิง connect() เพื่อสร้างการเชื่อมต่อกับเซิร์ฟเวอร์และเรียกใช้ downloadAssets() เพื่อดาวน์โหลดภาพที่จำเป็นในการเรนเดอร์เกม
  4. หลังจากเสร็จสิ้นขั้นตอนที่ 3 เมนูหลักจะปรากฏขึ้น (playMenu).
  5. การตั้งค่าตัวจัดการสำหรับการกดปุ่ม "PLAY" เมื่อกดปุ่ม รหัสจะเริ่มต้นเกมและบอกเซิร์ฟเวอร์ว่าเราพร้อมที่จะเล่น

"เนื้อ" หลักของตรรกะไคลเอ็นต์เซิร์ฟเวอร์ของเราอยู่ในไฟล์ที่นำเข้าโดยไฟล์ index.js. ตอนนี้เราจะพิจารณาทั้งหมดตามลำดับ

4. การแลกเปลี่ยนข้อมูลลูกค้า

ในเกมนี้ เราใช้ไลบรารีที่รู้จักกันดีในการสื่อสารกับเซิร์ฟเวอร์ socket.io. Socket.io มีการสนับสนุนแบบเนทีฟ WebSocketsซึ่งเหมาะสำหรับการสื่อสารสองทาง: เราสามารถส่งข้อความไปยังเซิร์ฟเวอร์ и เซิร์ฟเวอร์สามารถส่งข้อความถึงเราในการเชื่อมต่อเดียวกัน

เราจะมีไฟล์เดียว 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);
};

รหัสนี้ได้รับการย่อให้สั้นลงเล็กน้อยเพื่อความชัดเจน

มีสามการกระทำหลักในไฟล์นี้:

  • เรากำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ 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>). เกมของเราค่อนข้างเรียบง่าย ดังนั้นเราจึงจำเป็นต้องวาดสิ่งต่อไปนี้:

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

โปรดทราบว่าเรากำลังใช้วิธีการ getAsset()ที่เคยดูใน asset.js!

หากคุณสนใจที่จะเรียนรู้เกี่ยวกับตัวช่วยการเรนเดอร์อื่นๆ โปรดอ่านส่วนที่เหลือ src/ไคลเอนต์/render.js.

6. การป้อนข้อมูลลูกค้า

ได้เวลาสร้างเกมแล้ว เล่นได้! รูปแบบการควบคุมจะง่ายมาก: หากต้องการเปลี่ยนทิศทางการเคลื่อนไหว คุณสามารถใช้เมาส์ (บนคอมพิวเตอร์) หรือแตะที่หน้าจอ (บนอุปกรณ์พกพา) ในการดำเนินการนี้ เราจะลงทะเบียน ผู้ฟังเหตุการณ์ สำหรับกิจกรรม Mouse and 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. สถานะลูกค้า

ส่วนนี้ยากที่สุดในส่วนแรกของโพสต์ อย่าท้อแท้หากอ่านครั้งแรกไม่เข้าใจ! คุณสามารถข้ามไปและกลับมาดูในภายหลังได้

ปริศนาชิ้นสุดท้ายที่จำเป็นในการกรอกรหัสไคลเอนต์/เซิร์ฟเวอร์คือ รัฐ. จำข้อมูลโค้ดจากส่วนการแสดงผลลูกค้าได้หรือไม่

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: การประทับเวลาของเซิร์ฟเวอร์ที่ระบุเมื่อมีการสร้างการอัปเดตนี้
  • me: ข้อมูลเกี่ยวกับผู้เล่นที่ได้รับการอัพเดตนี้
  • คนอื่น ๆ: ข้อมูลมากมายเกี่ยวกับผู้เล่นคนอื่นที่เข้าร่วมในเกมเดียวกัน
  • สัญลักษณ์แสดงหัวข้อย่อย: อาร์เรย์ของข้อมูลเกี่ยวกับขีปนาวุธในเกม
  • ลีดเดอร์: ข้อมูลลีดเดอร์บอร์ดปัจจุบัน ในโพสต์นี้เราจะไม่พิจารณาพวกเขา

7.1 สถานะไคลเอนต์ไร้เดียงสา

การใช้งานที่ไร้เดียงสา getCurrentState() สามารถส่งคืนข้อมูลของการอัปเดตเกมที่ได้รับล่าสุดได้โดยตรงเท่านั้น

ไร้เดียงสา-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 ต่อวินาที):

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
น่าเสียดายที่ไม่มีอะไรสมบูรณ์แบบ ภาพที่สมจริงยิ่งขึ้นจะเป็น:
การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
การใช้งานที่ไร้เดียงสาเป็นกรณีที่เลวร้ายที่สุดเมื่อพูดถึงเวลาแฝง หากได้รับการอัปเดตเกมโดยมีความล่าช้า 50 มิลลิวินาที แผงลอยลูกค้า เพิ่มอีก 50ms เนื่องจากยังคงแสดงสถานะเกมจากการอัปเดตครั้งก่อน คุณสามารถจินตนาการได้ว่าสิ่งนี้ทำให้ผู้เล่นอึดอัดเพียงใด: การเบรกโดยพลการจะทำให้เกมกระตุกและไม่เสถียร

7.2 ปรับปรุงสถานะไคลเอนต์

เราจะทำการปรับปรุงการใช้งานที่ไร้เดียงสา อันดับแรก เราใช้ ความล่าช้าในการแสดงผล เป็นเวลา 100 มิลลิวินาที ซึ่งหมายความว่าสถานะ "ปัจจุบัน" ของไคลเอ็นต์จะล้าหลังกว่าสถานะของเกมบนเซิร์ฟเวอร์ 100ms เสมอ ตัวอย่างเช่น หากเวลาบนเซิร์ฟเวอร์คือ 150จากนั้นไคลเอนต์จะแสดงสถานะของเซิร์ฟเวอร์ในขณะนั้น 50:

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
สิ่งนี้ทำให้เรามีบัฟเฟอร์ 100 มิลลิวินาทีเพื่อให้รอดพ้นจากเวลาอัปเดตเกมที่คาดเดาไม่ได้:

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
ผลตอบแทนสำหรับสิ่งนี้จะเป็นแบบถาวร ความล่าช้าในการป้อนข้อมูล เป็นเวลา 100 มิลลิวินาที นี่เป็นการเสียสละเล็กน้อยสำหรับการเล่นเกมที่ราบรื่น - ผู้เล่นส่วนใหญ่ (โดยเฉพาะผู้เล่นทั่วไป) จะไม่สังเกตเห็นความล่าช้านี้ด้วยซ้ำ ผู้คนสามารถปรับให้มีเวลาแฝงคงที่ 100 มิลลิวินาทีได้ง่ายกว่าที่จะเล่นกับเวลาแฝงที่คาดเดาไม่ได้

เรายังสามารถใช้อีกเทคนิคหนึ่งที่เรียกว่า การคาดการณ์ฝั่งไคลเอ็นต์ซึ่งช่วยลดเวลาในการตอบสนองการรับรู้ได้ดี แต่จะไม่กล่าวถึงในโพสต์นี้

การปรับปรุงอื่นที่เราใช้อยู่คือ การแก้ไขเชิงเส้น. เนื่องจากความล่าช้าในการแสดงผล เรามักจะอัปเดตอย่างน้อยหนึ่งรายการก่อนเวลาปัจจุบันในไคลเอ็นต์ เมื่อถูกเรียก getCurrentState()เราสามารถดำเนินการได้ การแก้ไขเชิงเส้น ระหว่างการอัปเดตเกมก่อนและหลังเวลาปัจจุบันในไคลเอนต์:

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
วิธีนี้ช่วยแก้ปัญหาอัตราเฟรม: ตอนนี้เราสามารถเรนเดอร์เฟรมเฉพาะที่อัตราเฟรมใดก็ได้ที่เราต้องการ!

7.3 การใช้สถานะไคลเอนต์ที่ปรับปรุงแล้ว

ตัวอย่างการใช้งานใน 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(). ดังที่เราเห็นก่อนหน้านี้ การอัปเดตเกมทุกครั้งจะมีการประทับเวลาของเซิร์ฟเวอร์ เราต้องการใช้เวลาแฝงในการเรนเดอร์เพื่อเรนเดอร์รูปภาพ 100ms หลังเซิร์ฟเวอร์ แต่ เราจะไม่มีทางรู้เวลาปัจจุบันบนเซิร์ฟเวอร์เนื่องจากเราไม่สามารถทราบได้ว่าต้องใช้เวลานานแค่ไหนกว่าที่การอัปเดตใดๆ จะมาถึงเรา อินเทอร์เน็ตเป็นสิ่งที่คาดเดาไม่ได้และความเร็วอาจแตกต่างกันไปอย่างมาก!

เพื่อแก้ไขปัญหานี้ เราสามารถใช้ค่าประมาณที่เหมาะสม: เรา แสร้งทำเป็นว่าการอัปเดตครั้งแรกมาถึงทันที. หากเป็นความจริง เราจะรู้เวลาเซิร์ฟเวอร์ในช่วงเวลานี้โดยเฉพาะ! เราเก็บการประทับเวลาของเซิร์ฟเวอร์ไว้ใน firstServerTimestamp และรักษาของเรา ท้องถิ่น (ไคลเอนต์) เวลาประทับในขณะเดียวกันใน gameStart.

โอ้รอ มันไม่ควรเป็นเวลาเซิร์ฟเวอร์ = เวลาไคลเอนต์? เหตุใดเราจึงแยกความแตกต่างระหว่าง "การประทับเวลาของเซิร์ฟเวอร์" และ "การประทับเวลาของไคลเอ็นต์" นี่เป็นคำถามที่ดี! ปรากฎว่าไม่ใช่สิ่งเดียวกัน Date.now() จะส่งคืนการประทับเวลาที่ต่างกันในไคลเอ็นต์และเซิร์ฟเวอร์ และขึ้นอยู่กับปัจจัยภายในเครื่องเหล่านี้ อย่าคิดว่าการประทับเวลาจะเหมือนกันในทุกเครื่อง

ตอนนี้เราเข้าใจสิ่งที่ทำ currentServerTime(): มันกลับมา การประทับเวลาของเซิร์ฟเวอร์ของเวลาเรนเดอร์ปัจจุบัน. นี่คือเวลาปัจจุบันของเซิร์ฟเวอร์ (firstServerTimestamp <+ (Date.now() - gameStart)) ลบการหน่วงการแสดงผล (RENDER_DELAY).

ตอนนี้เรามาดูวิธีที่เราจัดการกับการอัปเดตเกม เมื่อได้รับจากเซิร์ฟเวอร์อัพเดท จะถูกเรียก processGameUpdate()และเราบันทึกการอัปเดตใหม่ลงในอาร์เรย์ gameUpdates. จากนั้น เพื่อตรวจสอบการใช้หน่วยความจำ เราจะลบการอัปเดตเก่าทั้งหมดก่อนหน้านี้ การปรับปรุงฐานเพราะเราไม่ต้องการมันอีกต่อไป

"การอัปเดตพื้นฐาน" คืออะไร นี้ การอัปเดตครั้งแรกที่เราพบโดยการย้อนกลับไปจากเวลาปัจจุบันของเซิร์ฟเวอร์. จำไดอะแกรมนี้ได้ไหม

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
การอัปเดตเกมทางด้านซ้ายของ "Client Render Time" เป็นการอัปเดตพื้นฐาน

การอัปเดตพื้นฐานใช้สำหรับอะไร เหตุใดเราจึงปล่อยการอัปเดตเป็นข้อมูลพื้นฐานได้ เพื่อคิดออกกันเถอะ ในที่สุด พิจารณาดำเนินการ 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.js บน Github.

ส่วนที่ 2 เซิร์ฟเวอร์ส่วนหลัง

ในส่วนนี้ เราจะดูที่แบ็กเอนด์ Node.js ที่ควบคุมของเรา ตัวอย่างเกม .io.

1. จุดเริ่มต้นเซิร์ฟเวอร์

ในการจัดการเว็บเซิร์ฟเวอร์ เราจะใช้เว็บเฟรมเวิร์กยอดนิยมสำหรับ Node.js ที่เรียกว่า รวดเร็ว . มันจะถูกกำหนดค่าโดยไฟล์จุดเริ่มต้นเซิร์ฟเวอร์ของเรา 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-มิดเดิลแวร์ เพื่อสร้างแพ็คเกจการพัฒนาของเราใหม่โดยอัตโนมัติ หรือ
  • ถ่ายโอนโฟลเดอร์แบบคงที่ 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เราจึงไม่ต้องกังวลเรื่องนั้น ฉันจะโทรหาเขา รหัสผู้เล่น.

เมื่อทราบแล้ว เรามาสำรวจตัวแปรอินสแตนซ์ในคลาสกัน Game:

  • sockets เป็นอ็อบเจกต์ที่ผูก ID ผู้เล่นเข้ากับซ็อกเก็ตที่เกี่ยวข้องกับผู้เล่น ช่วยให้เราเข้าถึงซ็อกเก็ตด้วยรหัสผู้เล่นในเวลาคงที่
  • players เป็นวัตถุที่ผูกรหัสผู้เล่นกับรหัส>วัตถุผู้เล่น

bullets เป็นอาร์เรย์ของวัตถุ Bulletซึ่งไม่มีลำดับที่แน่นอน
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() ค่อนข้างเรียบง่าย - มันจัดเรียงผู้เล่นตามคะแนน เลือกห้าอันดับแรก และส่งคืนชื่อผู้ใช้และคะแนนสำหรับแต่ละคน

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 เฉพาะนามสกุลต่อไปนี้:

  • การใช้แพ็คเกจ สั้น สำหรับการสุ่มรุ่น 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;
}

การตรวจจับการชนอย่างง่ายนี้ขึ้นอยู่กับข้อเท็จจริงที่ว่า วงกลมสองวงชนกันถ้าระยะห่างระหว่างจุดศูนย์กลางน้อยกว่าผลรวมของรัศมี. นี่คือกรณีที่ระยะห่างระหว่างจุดศูนย์กลางของวงกลมสองวงเท่ากับผลรวมของรัศมี:

การสร้างเกมบนเว็บ .io สำหรับผู้เล่นหลายคน
มีอีกสองสามประเด็นที่ต้องพิจารณาที่นี่:

  • กระสุนจะต้องไม่โดนผู้เล่นที่สร้างมันขึ้นมา สิ่งนี้สามารถทำได้โดยการเปรียบเทียบ bullet.parentID с player.id.
  • โพรเจกไทล์จะต้องโดนเพียงครั้งเดียวในกรณีที่ผู้เล่นหลายคนชนกันในเวลาเดียวกัน เราจะแก้ปัญหานี้โดยใช้ตัวดำเนินการ break: ทันทีที่พบผู้เล่นที่ชนกับโพรเจกไทล์ เราจะหยุดการค้นหาและไปยังโพรเจกไทล์ถัดไป

ท้ายที่สุด

นั่นคือทั้งหมด! เราได้ครอบคลุมทุกสิ่งที่คุณจำเป็นต้องรู้เพื่อสร้างเกมบนเว็บ .io อะไรต่อไป? สร้างเกม .io ของคุณเอง!

โค้ดตัวอย่างทั้งหมดเป็นโอเพ่นซอร์สและโพสต์บน Github.

ที่มา: will.com

เพิ่มความคิดเห็น