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

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

深入探索一个 .io 游戏的 Javascript client-side(客户端)。

如果您以前从未听说过 .io 游戏:它们是免费的多人 web 游戏,易于加入(无需帐户),
并且通常在一个区域内让许多玩家相互竞争。其他著名的 .io 游戏包括 Slither.ioDiep.io

在本文中,我们将了解如何从头开始构建.io游戏
您所需要的只是 Javascript 的实用知识:
您应该熟悉 ES6 语法,this 关键字和 Promises之类的内容。
即使您对 Javascript 并不是最熟悉的,您仍然应该可以阅读本文的大部分内容。

一个 .io 游戏示例

为了帮助我们学习,我们将参考 https://example-io-game.victorzhou.com

这是一款非常简单的游戏:你和其他玩家一起控制竞技场中的一艘船。
你的飞船会自动发射子弹,你会试图用自己的子弹击中其他玩家,同时避开他们。

目录

这是由两部分组成的系列文章的第 1 部分。我们将在这篇文章中介绍以下内容:

  1. 项目概况/结构:项目的高级视图。
  2. 构建/项目设置:开发工具、配置和设置。
  3. Client 入口:index.html 和 index.js。
  4. Client 网络通信:与服务器通信。
  5. Client 渲染:下载 image 资源 + 渲染游戏。
  6. Client 输入:让用户真正玩游戏。
  7. Client 状态:处理来自服务器的游戏更新。

1. 项目概况/结构

我建议下载示例游戏的源代码,以便您可以更好的继续阅读。

我们的示例游戏使用了:

  • Express,Node.js 最受欢迎的 Web 框架,以为其 Web 服务器提供动力。
  • socket.io,一个 websocket 库,用于在浏览器和服务器之间进行通信。
  • Webpack,一个模块打包器。

项目目录的结构如下所示:

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

public/

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

src/

所有源代码都在 src/ 文件夹中。
client/server/ 很容易说明,shared/ 包含一个由 client 和 server 导入的常量文件。

2. 构建/项目设置

如前所述,我们正在使用 Webpack 模块打包器来构建我们的项目。让我们看一下我们的 Webpack 配置:

webpack.common.js

const path = require(\'path\');
const MiniCssExtractPlugin = require(\'mini-css-extract-plugin\');

module.exports = {
  entry: {
    game: \'./src/client/index.js\',
  },
  output: {
    filename: \'[name].[contenthash].js\',
    path: path.resolve(__dirname, \'dist\'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [\'@babel/preset-env\'],
          },
        },
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          \'css-loader\',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: \'[name].[contenthash].css\',
    }),
    new HtmlWebpackPlugin({
      filename: \'index.html\',
      template: \'src/client/html/index.html\',
    }),
  ],
};
  • src/client/index.js 是 Javascript (JS) 客户端入口点。Webpack 将从那里开始,递归地查找其他导入的文件。
  • 我们的 Webpack 构建的 JS 输出将放置在 dist/ 目录中。我将此文件称为 JS bundle。
  • 我们正在使用 Babel,特别是 @babel/preset-env 配置,来为旧浏览器编译 JS 代码。
  • 我们正在使用一个插件来提取 JS 文件引用的所有 CSS 并将其捆绑在一起。我将其称为 CSS bundle。

您可能已经注意到奇怪的 \'[name].[contenthash].ext\' 捆绑文件名。
它们包括 Webpack 文件名替换:[name] 将替换为入口点名称(这是game),[contenthash]将替换为文件内容的哈希。
我们这样做是为了优化缓存 – 我们可以告诉浏览器永远缓存我们的 JS bundle,因为如果 JS bundle 更改,其文件名也将更改(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 来优化包的大小。

本地设置

我建议在您的本地计算机上安装该项目,以便您可以按照本文的其余内容进行操作。
设置很简单:首先,确保已安装 NodeNPM。 然后,

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

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

$ npm run develop

并在网络浏览器中访问 localhost:3000
当您编辑代码时,开发服务器将自动重建 JS 和 CSS bundles – 只需刷新即可查看更改!

3. Client 入口

让我们来看看实际的游戏代码。首先,我们需要一个 index.html 页面,
这是您的浏览器访问网站时首先加载的内容。我们的将非常简单:

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 代码就会开始执行,
从我们的 JS 入口文件 src/client/index.js 开始。

index.js

import { connect, play } from \'./networking\';
import { startRendering, stopRendering } from \'./render\';
import { startCapturingInput, stopCapturingInput } from \'./input\';
import { downloadAssets } from \'./assets\';
import { initState } from \'./state\';
import { setLeaderboardHidden } from \'./leaderboard\';

import \'./css/main.css\';

const playMenu = document.getElementById(\'play-menu\');
const playButton = document.getElementById(\'play-button\');
const usernameInput = document.getElementById(\'username-input\');

Promise.all([
  connect(),
  downloadAssets(),
]).then(() => {
  playMenu.classList.remove(\'hidden\');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add(\'hidden\');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

这似乎很复杂,但实际上并没有那么多事情发生:

  • 导入一堆其他 JS 文件。
  • 导入一些 CSS(因此 Webpack 知道将其包含在我们的 CSS bundle 中)。
  • 运行 connect() 来建立到服务器的连接,运行 downloadAssets() 来下载渲染游戏所需的图像。
  • 步骤 3 完成后,显示主菜单(playMenu)。
  • 为 “PLAY” 按钮设置一个点击处理程序。如果点击,初始化游戏并告诉服务器我们准备好玩了。

客户端逻辑的核心驻留在由 index.js 导入的其他文件中。接下来我们将逐一讨论这些问题。

4. Client 网络通信

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

我们将有一个文件 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);
};

此文件中发生3件主要事情:

  • 我们尝试连接到服务器。只有建立连接后,connectedPromise 才能解析。
  • 如果连接成功,我们注册回调( processGameUpdate()onGameOver() )我们可能从服务器接收到的消息。
  • 我们导出 play()updateDirection() 以供其他文件使用。

5. Client 渲染

是时候让东西出现在屏幕上了!

但在此之前,我们必须下载所需的所有图像(资源)。让我们写一个资源管理器:

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 并不难实现!主要思想是保留一个 assets 对象,它将文件名 key 映射到一个 Image 对象值。
当一个 asset 下载完成后,我们将其保存到 assets 对象中,以便以后检索。
最后,一旦每个 asset 下载都已 resolve(意味着所有 assets 都已下载),我们就 resolve 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,
  );
}

请注意,我们如何使用前面在 asset.js 中看到的 getAsset() 方法!

如果你对其他渲染帮助函数感兴趣,请阅读 src/client/render.js 的其余部分。

6. Client 输入

版权声明:本文为hacker-linner原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/hacker-linner/p/14291459.html