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
来优化包的大小。
本地设置
我建议在您的本地计算机上安装该项目,以便您可以按照本文的其余内容进行操作。设置很简单:首先,确保已安装 Node
和 NPM
。然后,
$ 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>
)绘制到我们的网页上。我们的游戏非常简单,所以我们需要画的是:
- 背景
- 我们玩家的飞船
- 游戏中的其他玩家
- 子弹
这是 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
的其余部分。