Tạo trò chơi web nhiều người chơi ở thể loại .io

Tạo trò chơi web nhiều người chơi ở thể loại .io
Được phát hành vào năm 2015 Agar.io đã trở thành người khai sinh ra một thể loại mới trò chơi.io, sự nổi tiếng của họ đã tăng lên rất nhiều kể từ đó. Bản thân tôi đã trải nghiệm sự phổ biến ngày càng tăng của trò chơi .io: trong ba năm qua, tôi đã tạo và bán hai trò chơi thuộc thể loại này..

Trong trường hợp bạn chưa bao giờ nghe nói về những trò chơi này trước đây thì chúng là những trò chơi web nhiều người chơi miễn phí, dễ chơi (không cần tài khoản). Họ thường đọ sức với nhiều người chơi đối địch trên một đấu trường. Các trò chơi .io nổi tiếng khác: Slither.io и Diep.io.

Trong bài viết này chúng ta sẽ tìm ra cách tạo trò chơi .io từ đầu. Để làm được điều này, chỉ cần kiến ​​thức về Javascript là đủ: bạn cần hiểu những thứ như cú pháp ES6, từ khóa this и Promises. Ngay cả khi bạn không biết rõ về Javascript, bạn vẫn có thể hiểu được hầu hết bài viết.

Ví dụ về trò chơi .io

Để được hỗ trợ đào tạo, chúng tôi sẽ tham khảo trò chơi ví dụ .io. Hãy thử chơi nó!

Tạo trò chơi web nhiều người chơi ở thể loại .io
Trò chơi khá đơn giản: bạn điều khiển một con tàu trong đấu trường cùng với những người chơi khác. Tàu của bạn sẽ tự động bắn đạn và bạn cố gắng bắn trúng những người chơi khác trong khi tránh đạn của họ.

1. Tổng quan/cấu trúc dự án

giới thiệu tải mã nguồn trò chơi ví dụ để bạn có thể theo dõi tôi.

Ví dụ sử dụng như sau:

  • Bày tỏ là khung web phổ biến nhất dành cho Node.js để quản lý máy chủ web của trò chơi.
  • ổ cắm.io — thư viện websocket để trao đổi dữ liệu giữa trình duyệt và máy chủ.
  • Gói web - người quản lý mô-đun. Bạn có thể đọc về lý do sử dụng Webpack đây.

Cấu trúc thư mục dự án trông như thế này:

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

công cộng/

Mọi thứ đều có trong thư mục public/ sẽ được truyền tĩnh bởi máy chủ. TRONG public/assets/ chứa hình ảnh được sử dụng bởi dự án của chúng tôi.

src /

Tất cả mã nguồn đều nằm trong thư mục src/. Tiêu đề client/ и server/ nói cho chính họ và shared/ chứa tệp hằng được nhập bởi cả máy khách và máy chủ.

2. Thông số lắp ráp/dự án

Như đã nêu ở trên, chúng tôi sử dụng trình quản lý mô-đun để xây dựng dự án Gói web. Chúng ta hãy xem cấu hình Webpack của chúng tôi:

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

Các dòng quan trọng nhất ở đây là:

  • src/client/index.js là điểm vào của ứng dụng khách Javascript (JS). Webpack sẽ bắt đầu từ đây và tìm kiếm đệ quy các tệp đã nhập khác.
  • JS đầu ra của bản dựng Webpack của chúng tôi sẽ nằm trong thư mục dist/. Tôi sẽ gọi tập tin này của chúng tôi Gói JS.
  • Chúng tôi sử dụng Babelvà đặc biệt là cấu hình @babel/preset-env để dịch mã JS của chúng tôi cho các trình duyệt cũ hơn.
  • Chúng tôi sử dụng một plugin để trích xuất tất cả CSS được tham chiếu bởi các tệp JS và kết hợp chúng vào một nơi. Tôi sẽ gọi nó là của chúng tôi gói CSS.

Bạn có thể nhận thấy tên tệp gói lạ '[name].[contenthash].ext'. Chúng chứa thay thế tên tập tin Gói web: [name] sẽ được thay thế bằng tên của điểm đầu vào (trong trường hợp của chúng tôi là game) và [contenthash] sẽ được thay thế bằng hàm băm của nội dung tệp. Chúng tôi làm điều này để tối ưu hóa dự án để băm - chúng tôi có thể yêu cầu trình duyệt lưu vào bộ nhớ đệm các gói JS của chúng tôi vô thời hạn vì nếu một gói thay đổi thì tên tệp của nó cũng thay đổi (thay đổi contenthash). Kết quả hoàn thành sẽ là tên file của view game.dbeee76e91a97d0c7207.js.

hồ sơ webpack.common.js là tệp cấu hình cơ sở mà chúng tôi nhập vào quá trình phát triển và cấu hình dự án đã hoàn thành. Ví dụ: đây là cấu hình phát triển:

webpack.dev.js

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

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

Để đạt hiệu quả, chúng tôi sử dụng trong quá trình phát triển webpack.dev.js, và chuyển sang webpack.prod.js, để tối ưu hóa kích thước gói khi triển khai vào sản xuất.

Thiết lập cục bộ

Tôi khuyên bạn nên cài đặt dự án trên máy cục bộ của mình để bạn có thể làm theo các bước được liệt kê trong bài đăng này. Việc thiết lập rất đơn giản: đầu tiên, hệ thống phải có Node и NPM. Tiếp theo bạn cần làm

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

và bạn đã sẵn sàng để đi! Để khởi động máy chủ phát triển, chỉ cần chạy

$ npm run develop

và truy cập trình duyệt web của bạn localhost: 3000. Máy chủ phát triển sẽ tự động xây dựng lại các gói JS và CSS khi có thay đổi về mã - chỉ cần làm mới trang để xem tất cả các thay đổi!

3. Điểm vào của khách hàng

Chúng ta hãy đi xuống mã trò chơi. Đầu tiên chúng ta cần một trang index.html, khi bạn truy cập trang web, trình duyệt sẽ tải nó trước. Trang của chúng tôi sẽ khá đơn giản:

index.html

Một ví dụ về trò chơi .io  CHƠI

Ví dụ về mã này đã được đơn giản hóa một chút để dễ hiểu và tôi sẽ làm tương tự với nhiều ví dụ khác trong bài viết. Bạn luôn có thể xem mã đầy đủ tại Github.

Chúng ta có:

  • Phần tử Canvas HTML5 (<canvas>), mà chúng tôi sẽ sử dụng để kết xuất trò chơi.
  • <link> để thêm gói CSS của chúng tôi.
  • <script> để thêm gói Javascript của chúng tôi.
  • Menu chính với tên người dùng <input> và nút “PLAY” (<button>).

Sau khi tải trang chủ, trình duyệt sẽ bắt đầu thực thi mã Javascript, bắt đầu bằng tệp JS điểm nhập: src/client/index.js.

chỉ mục.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);
  };
});

Điều này nghe có vẻ phức tạp nhưng thực tế không có nhiều điều xảy ra ở đây:

  1. Nhập một số tệp JS khác.
  2. Nhập CSS (để Webpack biết đưa chúng vào gói CSS của chúng tôi).
  3. Ra mắt connect() để thiết lập kết nối đến máy chủ và bắt đầu downloadAssets() để tải xuống những hình ảnh cần thiết để kết xuất trò chơi.
  4. Sau khi hoàn thành giai đoạn 3 menu chính được hiển thị (playMenu).
  5. Thiết lập trình xử lý nhấp chuột vào nút “PLAY”. Khi nhấn nút, mã sẽ khởi chạy trò chơi và thông báo cho máy chủ rằng chúng tôi đã sẵn sàng chơi.

“Phần cốt lõi” chính của logic máy khách-máy chủ của chúng tôi nằm trong các tệp được tệp nhập vào index.js. Bây giờ chúng ta sẽ xem xét tất cả theo thứ tự.

4. Trao đổi dữ liệu khách hàng

Trong trò chơi này, chúng tôi sử dụng một thư viện nổi tiếng để liên lạc với máy chủ ổ cắm.io. Socket.io có hỗ trợ tích hợp WebSockets, rất phù hợp cho giao tiếp hai chiều: chúng ta có thể gửi tin nhắn đến máy chủ и máy chủ có thể gửi tin nhắn cho chúng tôi qua cùng một kết nối.

Chúng ta sẽ có một tập tin src/client/networking.jsai sẽ chăm sóc tất cả giao tiếp với máy chủ:

mạng.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);
};

Mã này cũng được rút ngắn một chút cho rõ ràng.

Có ba điều chính xảy ra trong tập tin này:

  • Chúng tôi đang cố gắng kết nối với máy chủ. connectedPromise chỉ được phép khi chúng tôi đã thiết lập kết nối.
  • Nếu kết nối thành công, chúng tôi đăng ký các hàm gọi lại (processGameUpdate() и onGameOver()) đối với các tin nhắn mà chúng tôi có thể nhận được từ máy chủ.
  • Chúng tôi xuất khẩu play() и updateDirection()để các tập tin khác có thể sử dụng chúng.

5. Kết xuất ứng dụng khách

Đã đến lúc hiển thị hình ảnh trên màn hình!

...nhưng trước khi có thể thực hiện việc này, chúng tôi cần tải xuống tất cả hình ảnh (tài nguyên) cần thiết cho việc này. Hãy viết một trình quản lý tài nguyên:

tài sản.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];

Quản lý tài nguyên không khó thực hiện! Điểm chính là lưu trữ một đối tượng assets, nó sẽ liên kết khóa tên tệp với giá trị đối tượng Image. Khi tài nguyên được tải, chúng tôi lưu nó vào một đối tượng assets để nhận được nhanh chóng trong tương lai. Khi nào việc tải xuống từng tài nguyên riêng lẻ sẽ được phép (nghĩa là sẽ tải xuống tất cả tài nguyên), chúng tôi cho phép downloadPromise.

Sau khi tải xuống tài nguyên, bạn có thể bắt đầu kết xuất. Như đã nói trước đó, để vẽ trên một trang web, chúng tôi sử dụng Vải HTML5 (<canvas>). Trò chơi của chúng tôi khá đơn giản, vì vậy chúng tôi chỉ cần kết xuất như sau:

  1. Bối cảnh
  2. Tàu chơi
  3. Những người chơi khác trong trò chơi
  4. vỏ sò

Dưới đây là những đoạn quan trọng src/client/render.js, rút ​​ra chính xác bốn điểm được liệt kê ở trên:

kết xuất.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);
}

Mã này cũng được rút ngắn cho rõ ràng.

render() là chức năng chính của tập tin này. startRendering() и stopRendering() kiểm soát việc kích hoạt chu trình kết xuất ở tốc độ 60 FPS.

Việc triển khai cụ thể các chức năng trợ giúp hiển thị riêng lẻ (ví dụ: renderBullet()) không quan trọng lắm, nhưng đây là một ví dụ đơn giản:

kết xuất.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,
  );
}

Lưu ý rằng chúng tôi đang sử dụng phương pháp getAsset(), điều đã được thấy trước đây ở asset.js!

Nếu bạn muốn khám phá các hàm trợ giúp hiển thị khác, hãy đọc phần còn lại của src/client/render.js.

6. Đầu vào của khách hàng

Đã đến lúc tạo ra một trò chơi có thể chơi được! Sơ đồ điều khiển sẽ rất đơn giản: để thay đổi hướng chuyển động, bạn có thể sử dụng chuột (trên máy tính) hoặc chạm vào màn hình (trên thiết bị di động). Để thực hiện điều này chúng ta sẽ đăng ký Người nghe sự kiện cho các sự kiện Chuột và Chạm.
Sẽ lo tất cả việc này src/client/input.js:

đầu vào.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() là Người nghe sự kiện gọi updateDirection() (từ networking.js) khi một sự kiện đầu vào xảy ra (ví dụ: khi di chuyển chuột). updateDirection() xử lý việc trao đổi tin nhắn với máy chủ, xử lý sự kiện đầu vào và cập nhật trạng thái trò chơi tương ứng.

7. Trạng thái khách hàng

Phần này là khó nhất trong phần đầu của bài viết. Đừng nản lòng nếu bạn không hiểu nó ngay lần đầu đọc! Bạn thậm chí có thể bỏ qua nó và quay lại sau.

Mảnh ghép cuối cùng cần thiết để hoàn thành mã máy khách-máy chủ là nhà nước. Bạn có nhớ đoạn mã từ phần Kết xuất ứng dụng khách không?

kết xuất.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}

getCurrentState() có thể cung cấp cho chúng tôi trạng thái trò chơi hiện tại trong ứng dụng khách bất cứ lúc nào dựa trên các cập nhật nhận được từ máy chủ. Dưới đây là ví dụ về bản cập nhật trò chơi mà máy chủ có thể gửi:

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

Mỗi bản cập nhật trò chơi chứa năm trường giống hệt nhau:

  • t: Dấu thời gian của máy chủ cho biết thời điểm bản cập nhật này được tạo.
  • me: Thông tin về người chơi nhận được bản cập nhật này.
  • loại khác: Mảng thông tin về những người chơi khác tham gia cùng một trò chơi.
  • đạn: mảng thông tin về đạn trong game.
  • bảng dẫn: Dữ liệu bảng xếp hạng hiện tại. Chúng tôi sẽ không tính đến chúng trong bài viết này.

7.1 Trạng thái ngây thơ của khách hàng

Triển khai ngây thơ getCurrentState() chỉ có thể trả lại trực tiếp dữ liệu từ bản cập nhật trò chơi nhận được gần đây nhất.

ngây thơ-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Đẹp và rõ ràng! Nhưng nếu chỉ có nó đơn giản như vậy. Một trong những lý do khiến việc triển khai này có vấn đề: nó giới hạn tốc độ khung hình kết xuất ở tốc độ xung nhịp của máy chủ.

Tỷ lệ khung hình: số lượng khung hình (tức là các cuộc gọi render()) mỗi giây hoặc FPS. Các trò chơi thường cố gắng đạt ít nhất 60 FPS.

Tỷ lệ đánh dấu: Tần suất máy chủ gửi bản cập nhật trò chơi cho khách hàng. Nó thường thấp hơn tốc độ khung hình. Trong trò chơi của chúng tôi, máy chủ chạy ở tốc độ 30 tích tắc mỗi giây.

Nếu chúng ta chỉ render bản cập nhật game mới nhất thì FPS về cơ bản sẽ không bao giờ vượt quá 30 vì chúng tôi không bao giờ nhận được nhiều hơn 30 bản cập nhật mỗi giây từ máy chủ. Kể cả khi chúng ta gọi render() 60 lần mỗi giây, thì một nửa số lệnh gọi này sẽ chỉ vẽ lại nội dung tương tự, về cơ bản không làm gì cả. Một vấn đề khác với cách triển khai đơn giản là nó có thể bị chậm trễ. Ở tốc độ Internet lý tưởng, khách hàng sẽ nhận được bản cập nhật trò chơi chính xác cứ sau 33 mili giây (30 mỗi giây):

Tạo trò chơi web nhiều người chơi ở thể loại .io
Thật không may, không có gì là hoàn hảo. Một hình ảnh thực tế hơn sẽ là:
Tạo trò chơi web nhiều người chơi ở thể loại .io
Việc triển khai ngây thơ gần như là trường hợp xấu nhất khi nói đến độ trễ. Nếu nhận được bản cập nhật trò chơi có độ trễ 50 mili giây thì khách hàng bị chậm lại thêm 50 mili giây vì nó vẫn hiển thị trạng thái trò chơi từ bản cập nhật trước. Bạn có thể tưởng tượng điều này gây bất tiện cho người chơi như thế nào: do tình trạng chạy chậm tùy ý, trò chơi sẽ có vẻ giật và không ổn định.

7.2 Cải thiện trạng thái máy khách

Chúng tôi sẽ thực hiện một số cải tiến đối với việc triển khai đơn giản. Đầu tiên, chúng tôi sử dụng độ trễ kết xuất thêm 100 mili giây. Điều này có nghĩa là trạng thái "hiện tại" của máy khách sẽ luôn chậm hơn 100 mili giây so với trạng thái trò chơi trên máy chủ. Ví dụ: nếu thời gian của máy chủ là 150, thì máy khách sẽ hiển thị trạng thái của máy chủ vào thời điểm đó 50:

Tạo trò chơi web nhiều người chơi ở thể loại .io
Điều này mang lại cho chúng tôi bộ đệm 100ms để tồn tại trong thời gian cập nhật trò chơi không thể đoán trước:

Tạo trò chơi web nhiều người chơi ở thể loại .io
Giá cho việc này sẽ là vĩnh viễn độ trễ đầu vào thêm 100 mili giây. Đây là một sự hy sinh nhỏ để có được lối chơi mượt mà - hầu hết người chơi (đặc biệt là những người chơi bình thường) thậm chí sẽ không nhận thấy độ trễ này. Mọi người sẽ dễ dàng điều chỉnh độ trễ 100 mili giây không đổi hơn là chơi với độ trễ không thể đoán trước.

Chúng ta có thể sử dụng một kỹ thuật khác gọi là "dự báo phía khách hàng", điều này thực hiện tốt công việc giảm độ trễ nhận thấy nhưng sẽ không được thảo luận trong bài đăng này.

Một cải tiến khác mà chúng tôi sử dụng là phép nội suy tuyến tính. Do độ trễ kết xuất, chúng tôi thường cập nhật trước ít nhất một bản cập nhật so với thời điểm hiện tại trong ứng dụng khách. Khi được gọi getCurrentState(), chúng tôi có thể đáp ứng phép nội suy tuyến tính giữa các bản cập nhật trò chơi ngay trước và sau thời điểm hiện tại trong máy khách:

Tạo trò chơi web nhiều người chơi ở thể loại .io
Điều này giải quyết vấn đề về tốc độ khung hình: giờ đây chúng ta có thể hiển thị các khung hình duy nhất ở bất kỳ tốc độ khung hình nào chúng ta cần!

7.3 Triển khai trạng thái máy khách được cải thiện

Ví dụ triển khai trong src/client/state.js sử dụng cả độ trễ hiển thị và nội suy tuyến tính, nhưng điều này không kéo dài. Hãy chia mã thành hai phần. Đây là cái đầu tiên:

state.js, phần 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;
}

Điều đầu tiên bạn cần làm là tìm hiểu xem nó làm gì currentServerTime(). Như chúng ta đã thấy trước đó, mọi bản cập nhật trò chơi đều có dấu thời gian của máy chủ. Chúng tôi muốn sử dụng độ trễ kết xuất để hiển thị hình ảnh phía sau máy chủ 100 mili giây, nhưng chúng tôi sẽ không bao giờ biết thời gian hiện tại trên máy chủ, bởi vì chúng tôi không thể biết phải mất bao lâu để bất kỳ bản cập nhật nào đến được với chúng tôi. Internet không thể đoán trước được và tốc độ của nó có thể thay đổi rất nhiều!

Để giải quyết vấn đề này, chúng ta có thể sử dụng một phép tính gần đúng hợp lý: chúng ta hãy giả vờ như bản cập nhật đầu tiên đến ngay lập tức. Nếu điều này đúng thì chúng tôi sẽ biết thời gian của máy chủ tại thời điểm cụ thể đó! Chúng tôi lưu trữ dấu thời gian của máy chủ trong firstServerTimestamp và cứu chúng tôi địa phương (khách hàng) dấu thời gian tại cùng một thời điểm trong gameStart.

Ồ, đợi một chút. Không nên có thời gian trên máy chủ = thời gian trên máy khách sao? Tại sao chúng ta phân biệt giữa "dấu thời gian máy chủ" và "dấu thời gian máy khách"? Đâ là một câu hỏi tuyệt vời! Hóa ra những điều này không giống nhau. Date.now() sẽ trả về các dấu thời gian khác nhau trong máy khách và máy chủ và điều này phụ thuộc vào các yếu tố cục bộ của các máy này. Đừng bao giờ cho rằng dấu thời gian sẽ giống nhau trên tất cả các máy.

Bây giờ chúng ta đã hiểu nó làm gì currentServerTime(): nó trở lại dấu thời gian của máy chủ về thời gian kết xuất hiện tại. Nói cách khác, đây là thời gian hiện tại của máy chủ (firstServerTimestamp <+ (Date.now() - gameStart)) trừ đi độ trễ hiển thị (RENDER_DELAY).

Bây giờ hãy xem cách chúng tôi xử lý các bản cập nhật trò chơi. Khi nhận được bản cập nhật từ máy chủ, nó được gọi processGameUpdate()và chúng tôi lưu bản cập nhật mới vào một mảng gameUpdates. Sau đó, để kiểm tra mức sử dụng bộ nhớ, chúng tôi xóa tất cả các bản cập nhật cũ thành cập nhật cơ sởvì chúng ta không cần chúng nữa.

"Bản cập nhật cốt lõi" là gì? Cái này bản cập nhật đầu tiên chúng tôi tìm thấy bằng cách di chuyển ngược lại thời gian của máy chủ hiện tại. Bạn còn nhớ sơ đồ này không?

Tạo trò chơi web nhiều người chơi ở thể loại .io
Bản cập nhật trò chơi ngay bên trái "Thời gian kết xuất của khách hàng" là bản cập nhật cơ bản.

Bản cập nhật cơ sở được sử dụng để làm gì? Tại sao chúng ta có thể bỏ cập nhật xuống cơ sở? Để hiểu điều này, chúng ta hãy cuối cùng chúng ta hãy nhìn vào việc thực hiện getCurrentState():

state.js, phần 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),
    };
  }
}

Chúng tôi xử lý ba trường hợp:

  1. base < 0 có nghĩa là không có bản cập nhật nào cho đến thời điểm hiển thị hiện tại (xem phần triển khai ở trên getBaseUpdate()). Điều này có thể xảy ra ngay khi bắt đầu trò chơi do độ trễ kết xuất. Trong trường hợp này, chúng tôi sử dụng bản cập nhật mới nhất nhận được.
  2. base là bản cập nhật mới nhất mà chúng tôi có. Điều này có thể xảy ra do độ trễ mạng hoặc kết nối internet kém. Trong trường hợp này, chúng tôi cũng sử dụng bản cập nhật mới nhất mà chúng tôi có.
  3. Chúng tôi có bản cập nhật cả trước và sau thời gian kết xuất hiện tại, vì vậy chúng tôi có thể suy ra!

Tất cả những gì còn lại trong state.js là một cách thực hiện phép nội suy tuyến tính, một phép toán đơn giản (nhưng nhàm chán). Nếu bạn muốn tự mình khám phá thì hãy mở state.js trên Github.

Phần 2. Máy chủ phụ trợ

Trong phần này, chúng ta sẽ xem xét phần phụ trợ của Node.js điều khiển ví dụ về trò chơi .io.

1. Điểm vào máy chủ

Để quản lý máy chủ web, chúng tôi sẽ sử dụng một khung web phổ biến cho Node.js có tên là Bày tỏ. Nó sẽ được cấu hình bởi tệp điểm vào máy chủ của chúng tôi src/server/server.js:

server.js, phần 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}`);

Hãy nhớ rằng trong phần đầu tiên chúng ta đã thảo luận về Webpack? Đây là nơi chúng tôi sẽ sử dụng cấu hình Webpack của mình. Chúng ta sẽ áp dụng chúng theo hai cách:

  • Để sử dụng webpack-dev-middleware để tự động xây dựng lại các gói phát triển của chúng tôi, hoặc
  • Chuyển tĩnh một thư mục dist/, trong đó Webpack sẽ ghi các tệp của chúng tôi sau khi xây dựng sản xuất.

Một nhiệm vụ quan trọng khác server.js bao gồm việc thiết lập máy chủ ổ cắm.iochỉ đơn giản là kết nối với máy chủ Express:

server.js, phần 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);
});

Sau khi thiết lập thành công kết nối socket.io với máy chủ, chúng tôi định cấu hình trình xử lý sự kiện cho socket mới. Trình xử lý sự kiện xử lý các tin nhắn nhận được từ máy khách bằng cách ủy quyền cho một đối tượng đơn lẻ game:

server.js, phần 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);
}

Chúng tôi đang tạo một trò chơi .io nên chúng tôi sẽ chỉ cần một bản sao Game (“Trò chơi”) – tất cả người chơi đều chơi trong cùng một đấu trường! Trong phần tiếp theo chúng ta sẽ xem lớp này hoạt động như thế nào Game.

2. Máy chủ trò chơi

lớp Game chứa logic phía máy chủ quan trọng nhất. Nó có hai nhiệm vụ chính: quản lý người chơi и trò chơi mô phỏng.

Hãy bắt đầu với nhiệm vụ đầu tiên - quản lý người chơi.

game.js, phần 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);
    }
  }

  // ...
}

Trong trò chơi này, chúng tôi sẽ xác định người chơi theo lĩnh vực id socket socket.io của họ (nếu bạn bối rối, hãy quay lại server.js). Bản thân Socket.io gán cho mỗi socket một mã duy nhất id, nên chúng ta không cần phải lo lắng về điều đó. Tôi sẽ gọi anh ấy ID người chơi.

Với ý nghĩ đó, chúng ta hãy kiểm tra các biến thể hiện trong lớp Game:

  • sockets là một đối tượng liên kết ID trình phát với ổ cắm được liên kết với trình phát. Nó cho phép chúng tôi truy cập vào các ổ cắm bằng ID người chơi của chúng theo thời gian.
  • players là một đối tượng liên kết ID người chơi với mã>Đối tượng người chơi

bullets là một mảng các đối tượng Bullet, không có thứ tự cụ thể.
lastUpdateTime - Đây là dấu thời gian của bản cập nhật trò chơi cuối cùng. Chúng ta sẽ sớm thấy nó được sử dụng như thế nào.
shouldSendUpdate là một biến phụ. Chúng ta cũng sẽ sớm thấy công dụng của nó.
Phương pháp addPlayer(), removePlayer() и handleInput() không cần phải giải thích, chúng được sử dụng trong server.js. Nếu bạn cần bồi dưỡng lại, hãy quay lại cao hơn một chút.

Dòng cuối cùng constructor() khởi nghiệp chu kỳ cập nhật trò chơi (với tần suất 60 cập nhật/giây):

game.js, phần 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;
    }
  }

  // ...
}

Phương thức update() có lẽ chứa phần quan trọng nhất của logic phía máy chủ. Hãy liệt kê mọi thứ nó làm theo thứ tự:

  1. Tính xem bây giờ là mấy giờ dt nó đã xảy ra kể từ lần cuối cùng update().
  2. Làm mới từng viên đạn và phá hủy chúng nếu cần thiết. Chúng ta sẽ thấy việc thực hiện chức năng này sau. Bây giờ chỉ cần chúng ta biết là đủ bullet.update() trả lại true, nếu đạn phải bị phá hủy (anh ấy đã đi ra ngoài đấu trường).
  3. Cập nhật từng người chơi và tạo đường đạn nếu cần. Chúng ta cũng sẽ thấy việc triển khai này sau - player.update() có thể trả lại một đối tượng Bullet.
  4. Kiểm tra va chạm giữa đạn và người chơi bằng cách sử dụng applyCollisions(), trả về một loạt đạn bắn trúng người chơi. Đối với mỗi viên đạn được trả về, chúng tôi sẽ tăng điểm của người chơi đã bắn nó (sử dụng player.onDealtDamage()), sau đó loại bỏ đường đạn khỏi mảng bullets.
  5. Thông báo và tiêu diệt tất cả người chơi bị giết.
  6. Gửi bản cập nhật trò chơi cho tất cả người chơi mỗi giây lần khi được gọi update(). Biến phụ được đề cập ở trên giúp chúng tôi theo dõi điều này shouldSendUpdate. Tại vì update() được gọi là 60 lần/giây, chúng tôi gửi thông tin cập nhật trò chơi 30 lần/giây. Như vậy, tần số đồng hồ máy chủ là 30 chu kỳ xung nhịp/s (chúng ta đã nói về tần số xung nhịp ở phần đầu tiên).

Tại sao chỉ gửi bản cập nhật trò chơi qua thời gian ? Để lưu kênh. 30 cập nhật trò chơi mỗi giây là rất nhiều!

Tại sao không gọi ngay sau đó? update() 30 lần một giây? Để cải thiện mô phỏng trò chơi. Nó càng được gọi thường xuyên hơn update(), mô phỏng trò chơi sẽ càng chính xác. Nhưng đừng quá bị cuốn theo số lượng thử thách update(), bởi vì đây là một nhiệm vụ tốn kém về mặt tính toán - 60 mỗi giây là khá đủ.

Phần còn lại của lớp Game bao gồm các phương thức trợ giúp được sử dụng trong update():

game.js, phần 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() Việc này khá đơn giản - nó sắp xếp người chơi theo điểm số, chọn ra XNUMX người đứng đầu và trả về tên người dùng cũng như điểm số cho từng người.

createUpdate() Được dùng trong update() để tạo các bản cập nhật trò chơi được phân phối cho người chơi. Nhiệm vụ chính của nó là gọi các phương thức serializeForUpdate(), được triển khai cho các lớp Player и Bullet. Lưu ý rằng nó chỉ truyền dữ liệu tới mỗi người chơi về gần nhất người chơi và đạn - không cần truyền thông tin về các đối tượng trò chơi ở xa người chơi!

3. Đối tượng game trên máy chủ

Trong trò chơi của chúng tôi, đạn và người chơi thực sự rất giống nhau: chúng là những vật thể trò chơi chuyển động tròn trừu tượng. Để tận dụng sự tương đồng này giữa người chơi và đạn, hãy bắt đầu bằng cách triển khai một lớp cơ sở 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,
    };
  }
}

Không có gì phức tạp xảy ra ở đây. Lớp này sẽ là điểm khởi đầu tốt cho việc mở rộng. Hãy xem lớp học thế nào Bullet sử dụng Object:

đạn.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;
  }
}

Thực hiện Bullet rất ngắn! Chúng tôi đã thêm vào Object chỉ các phần mở rộng sau:

  • Sử dụng gói ngắn gọn để tạo ngẫu nhiên id đường đạn.
  • Thêm một trường parentID, để bạn có thể theo dõi người chơi đã tạo ra đường đạn này.
  • Thêm giá trị trả về vào update(), bằng nhau true, nếu đường đạn ở bên ngoài đấu trường (bạn có nhớ chúng ta đã nói về điều này ở phần trước không?).

Hãy chuyển sang 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,
    };
  }
}

Người chơi phức tạp hơn đạn, vì vậy lớp này nên lưu trữ thêm một vài trường. Phương pháp của anh ấy update() thực hiện được nhiều công việc hơn, đặc biệt là trả lại đường đạn mới được tạo nếu không còn đường đạn nào fireCooldown (bạn có nhớ chúng ta đã nói về điều này ở phần trước không?). Nó cũng mở rộng phương pháp serializeForUpdate(), vì chúng tôi cần đưa các trường bổ sung cho người chơi vào bản cập nhật trò chơi.

Sự sẵn có của một lớp cơ sở Object - một bước quan trọng để tránh lặp lại mã. Ví dụ, không có lớp Object mọi đối tượng trò chơi phải có cách triển khai giống nhau distanceTo()và sao chép-dán tất cả các cách triển khai này trên nhiều tệp sẽ là một cơn ác mộng. Điều này trở nên đặc biệt quan trọng đối với các dự án lớn, khi số lượng mở rộng Object lớp học ngày càng phát triển.

4. Phát hiện va chạm

Điều duy nhất còn lại chúng ta phải làm là nhận biết thời điểm đạn bắn trúng người chơi! Hãy nhớ đoạn mã này từ phương thức update() Trong lớp 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),
    );

    // ...
  }
}

Chúng ta cần thực hiện phương pháp applyCollisions(), trả về tất cả các đường đạn bắn trúng người chơi. May mắn thay, điều này không khó thực hiện vì

  • Tất cả các vật thể va chạm đều có hình tròn và đây là hình dạng đơn giản nhất để thực hiện phát hiện va chạm.
  • Chúng tôi đã có phương pháp rồi distanceTo(), mà chúng ta đã triển khai trong lớp ở phần trước Object.

Việc triển khai phát hiện va chạm của chúng tôi trông như thế này:

va chạm.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;
}

Việc phát hiện va chạm đơn giản này dựa trên thực tế là hai đường tròn va chạm nhau nếu khoảng cách giữa tâm của chúng nhỏ hơn tổng bán kính của chúng. Đây là trường hợp khoảng cách giữa tâm của hai đường tròn chính xác bằng tổng bán kính của chúng:

Tạo trò chơi web nhiều người chơi ở thể loại .io
Ở đây bạn cần chú ý đến một số khía cạnh nữa:

  • Đạn không được trúng người chơi đã tạo ra nó. Điều này có thể đạt được bằng cách so sánh bullet.parentID с player.id.
  • Đạn chỉ được bắn trúng một lần trong trường hợp cực đoan là bắn trúng nhiều người chơi cùng lúc. Chúng ta sẽ giải quyết vấn đề này bằng cách sử dụng toán tử break: Sau khi tìm thấy người chơi va chạm với một viên đạn, chúng tôi sẽ ngừng tìm kiếm và chuyển sang viên đạn tiếp theo.

Đầu

Đó là tất cả! Chúng tôi đã đề cập đến mọi thứ bạn cần biết để tạo trò chơi trên web .io. Cái gì tiếp theo? Xây dựng trò chơi .io của riêng bạn!

Tất cả mã ví dụ đều là mã nguồn mở và được đăng trên Github.

Nguồn: www.habr.com

Thêm một lời nhận xét