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
的其餘部分。