
2015年にリリースされたAgar.ioは、 ゲーム.ioの新しいジャンルの先駆者となり、その人気はその後大きく成長しました。 私が経験した.ioゲームの人気の高まり:過去3年間で、このジャンルの2つのゲームを作成して販売しました。 。
このようなゲームを聞いたことがない場合:これらは、参加しやすい無料のマルチプレイヤーWebゲームです(アカウントは不要です)。 通常、彼らは同じアリーナで多くの敵プレイヤーに立ち向かいます。 .ioジャンルの他の有名なゲームは、 Slither.ioとDiep.ioです。
この投稿では、.ioゲームをゼロから作成する方法を理解します 。 このためには、Javascriptの知識だけで十分です。ES6構文、
this
Promisesなどを理解する必要があります。 Javascriptを完全に知っていなくても、ほとんどの投稿を理解できます。
ゲーム例.io
学習を支援するために、サンプルの.ioゲームを参照します。 プレイしてみてください!

ゲームは非常に簡単です。他のプレイヤーがいるアリーナで船を操作します。 あなたの船は自動的に砲弾を発射し、他のプレイヤーを攻撃しようとしながら、彼らの砲弾を避けます。
1.概要/プロジェクト構造
サンプルゲームのソースコードをダウンロードして、フォローしてください。
この例では次を使用します。
- Expressは、ゲームのWebサーバーを管理するNode.js用の最も人気のあるWebフレームワークです。
- 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が起動し、インポートされた他のファイルを再帰的に検索します。 - Webpackアセンブリの出力JSは
dist/
ディレクトリにあります。 このファイルをJSパッケージと呼びます 。 - Babel 、特に@ babel / preset -env構成を使用して、古いブラウザー用にJSコードを変換します。
- プラグインを使用して、JSファイルによって参照されるすべてのCSSを取得し、それらを1つの場所に結合します。 CSSパッケージと呼びます 。
奇妙なパッケージファイル名
'[name].[contenthash].ext'
気付いたかもしれません。 これらには、Webpack ファイルの名前の置換が含まれます 。
[name]
は入力ポイントの名前に置き換えられ(この場合、これは
game
)、
[contenthash]
はファイルコンテンツのハッシュに置き換えられます。 プロジェクトをハッシュ用に最適化するためにこれを行います- パッケージが変更されるとファイル名が変更される(
contenthash
が変更される)ため、JSパッケージを無期限にキャッシュするようブラウザに指示できます。 最終的な結果は、
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.dev.js
を使用し、
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
Webブラウザでlocalhost:3000にアクセスします。 開発サーバーは、コードを変更するプロセスでJSおよびCSSパッケージを自動的に再構築します。すべての変更を確認するには、ページを更新するだけです!
3.顧客のエントリポイント
ゲームのコード自体に取りかかりましょう。 まず、
index.html
ページが必要です。サイトにアクセスすると、ブラウザーが最初にページを読み込みます。 私たちのページは非常にシンプルです:
index.html
<!DOCTYPE html> <html> <head> <title> .ioゲームの例</ title> <link type = "text / css" rel = "stylesheet" href = "/ game.bundle.css"> </ head> <本体> <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">プレイ</ button> </ div> </ body> </ html>
このコード例は、わかりやすくするために若干簡略化されています。他の多くの投稿例でも同じことをします。 完全なコードは、常にGithubで表示できます。
私たちが持っています:
- HTML5 Canvas要素 (
<canvas>
)。これを使用してゲームをレンダリングします。 -
<link>
を使用して、CSSパッケージを追加します。 -
<script>
を使用して、Javascriptパッケージを追加します。 - ユーザー名
<input>
および "PLAY"(<button>
)<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パッケージにそれらを含めることを認識します)。
-
connect()
を実行してサーバーへの接続を確立し、downloadAssets()
を実行してゲームのレンダリングに必要な画像をダウンロードします。 - 手順3を完了すると、メインメニュー(
playMenu
)がplayMenu
。 - PLAYボタンを押すためのハンドラーを構成します。 ボタンが押されると、コードはゲームを初期化し、プレイする準備ができたことをサーバーに伝えます。
クライアントサーバーロジックの主な「肉」は、
index.js
によってインポートされたファイルにあります。 次に、すべてを順番に検討します。
4.顧客データの交換
このゲームでは、よく知られているsocket.ioライブラリを使用してサーバーと通信します。 Socket.ioには、双方向通信に適したWebSocketの組み込みサポートがあります。サーバーにメッセージを送信でき、サーバーは同じ接続を介してメッセージを送信できます。
サーバーとのすべての通信を処理する
src/client/networking.js
ファイルが1つあります。
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.クライアントレンダリング
画面に画像を表示するときが来ました!
...しかし、これを行う前に、これに必要なすべてのイメージ(リソース)をダウンロードする必要があります。 リソースマネージャーを作成しましょう。
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
を有効にし
downloadPromise
。
リソースをダウンロードしたら、レンダリングを開始できます。 前述したように、 HTML5 Canvas (
<canvas>
)を使用してWebページに描画します。 私たちのゲームは非常にシンプルなので、以下を描くだけです。
- 背景
- プレイヤーシップ
- ゲーム内の他のプレイヤー
- 貝
上記の4つのポイントを正確にレンダリングする重要な
src/client/render.js
を
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()
)の特定の実装はそれほど重要ではありませんが、ここに1つの簡単な例を示します。
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()
メソッドを使用していることに注意して
asset.js
!
他の補助レンダリング関数の調査に興味がある場合は、残りのsrc / client / render.jsをお読みください 。
6.クライアント入力
ゲームをプレイ可能にする時です ! 制御スキームは非常に簡単です。マウスを使用して(コンピューターで)または画面をタッチして(モバイルデバイスで)移動の方向を変更できます。 これを実装するには、マウスイベントとタッチイベントのイベントリスナーを登録します。
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()
呼び出すイベントリスナーです。
updateDirection()
は入力イベントを処理し、それに応じてゲームの状態を更新するサーバーとのメッセージングに関与しています。
7.顧客の状態
このセクションは、投稿の最初の部分で最も難しいです。 あなたが最初の読書からそれを理解していないなら、落胆しないでください! スキップして、後で戻ることもできます。
クライアントとサーバーのコードを完成させるために必要なパズルの最後のピースはstateです。 クライアントレンダリングセクションのコードスニペットを覚えていますか?
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 } ] }
各ゲームの更新には、5つの同一のフィールドが含まれます。
- t :この更新が作成された時刻のサーバーのタイムスタンプ。
- me :このアップデートを受け取ったプレイヤーに関する情報。
- その他 :同じゲームに参加している他のプレイヤーに関する情報の配列。
- bullets :ゲーム内のシェルに関する情報の配列。
- リーダーボード :現在のリーダーボードデータ。 この投稿では、それらを考慮しません。
7.1クライアントの素朴な状態
getCurrentState()
の単純な実装は、受信した最新のゲームアップデートからのデータのみを直接返すことができます。
naive-state.js
let lastGameUpdate = null; // Handle a newly received game update. export function processGameUpdate(update) { lastGameUpdate = update; } export function getCurrentState() { return lastGameUpdate; }
美しくクリア! しかし、すべてがとても簡単だった場合。 この実装に問題がある理由の1つは、 レンダリングのフレームレートをサーバークロックの周波数に制限することです。
フレームレート :フレーム数(つまり、1秒あたりのrender()
呼び出し、またはFPS)。 ゲームは通常、少なくとも60 FPSに達する傾向があります。
ティックレート :サーバーがクライアントにゲームの更新を送信する頻度。 多くの場合、フレームレートよりも低くなります。 このゲームでは、サーバーは1秒あたり30サイクルの頻度で実行されます。
ゲームの最新の更新をレンダリングするだけの場合、FPSは実際には30を超えることはできません。 サーバーから毎秒30を超える更新を受け取ることはないからです。 1秒間に60回
render()
を呼び出しても、これらの呼び出しの半分は単純に同じものを再描画し、本質的には何もしません。 別の素朴な実装問題はそれが遅れる傾向があるということです。 理想的なインターネット速度では、クライアントは正確に33 ms(1秒あたり30)ごとにゲームの更新を受け取ります。
残念ながら、完璧なものはありません。 より現実的な画像は次のとおりです。
単純な実装は、遅延に関してはほとんど最悪のケースです。 ゲームの更新が50ミリ秒の遅延で受信されると、 クライアントは以前の更新からのゲームの状態をレンダリングするため、さらに50ミリ秒遅くなります 。 プレイヤーにとってどれほど不便なことか想像できます。braking意的なブレーキングのため、ゲームは不安定で不安定に見えます。
7.2顧客ステータスの改善
単純な実装にいくつかの改善を加えます。 まず、100 msのレンダリング遅延を使用します 。 これは、クライアントの「現在の」状態が常にサーバー上のゲームの状態より100ミリ秒遅れることを意味します。 たとえば、サーバーの時刻が150の場合、サーバーが時刻50にあった状態がクライアントに表示されます。
これにより、100ミリ秒のバッファーが提供され、ゲームの更新を受信する予測不可能な時間を乗り切ることができます。
これに対する支払いは、 100ミリ秒の一定の入力遅延(入力遅延)です。 これはスムーズなゲームプレイのための小さな犠牲です-ほとんどのプレイヤー(特にカジュアルなプレイヤー)はこの遅延に気付かないでしょう。 予測できない遅延で遊ぶよりも、100ミリ秒の一定の遅延に適応する方がはるかに簡単です。
「クライアント側の予測」と呼ばれる別の手法を使用することもできます。これは、知覚される遅延を減らすのに良い仕事をしますが、この記事では考慮しません。
私たちが使用する別の改善点は、 線形補間です。 レンダリングの遅延により、通常、少なくとも1回の更新でクライアントの現在の時間を追い越します。
getCurrentState()
が呼び出されると、クライアントの現在の時刻の直前と直後にゲーム更新間の線形補間を実行できます。
これにより、フレームレートの問題が解決します。必要な任意の周波数で一意のフレームをレンダリングできるようになりました。
7.3拡張クライアントステータスの実装
src/client/state.js
実装例では、レンダリング遅延と線形補間の両方を使用し
src/client/state.js
が、これは長くは
src/client/state.js
ません。 コードを2つの部分に分けましょう。 これが最初のものです:
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()
が何をするかを理解することです。 前に見たように、サーバーのタイムスタンプはすべてのゲーム更新に含まれます。 レンダリング遅延を使用して、サーバーの100ミリ秒遅れて画像をレンダリングしますが、サーバー上の現在の時刻を知ることはできません。これは、更新がどれくらいの時間で行われたかわからないためです。 インターネットは予測不可能であり、その速度は大きく異なる可能性があります!
この問題を回避するために、合理的な近似を使用できます。 最初の更新が即座に到着したと仮定します。 これが当てはまる場合、この特定の瞬間のサーバー時間を知ることができます! サーバーのタイムスタンプを
firstServerTimestamp
に保存し、同時にローカル (クライアント)タイムスタンプを
firstServerTimestamp
に保存します。
ちょっと待って サーバーの時間=クライアントの時間があるべきではないでしょうか? 「サーバータイムスタンプ」と「クライアントタイムスタンプ」を区別する理由 これは素晴らしい質問です! これは同じものではないことがわかりました。
Date.now()
は、クライアントとサーバーで異なるタイムスタンプを返します。これは、これらのマシンのローカル要因に依存します。 タイムスタンプがすべてのマシンで同じであると思い込まないでください。
これで
currentServerTime()
が何をするのか理解できました 。 現在のレンダリング時間のサーバータイムスタンプを返します。 つまり、これは現在のサーバー時間(
firstServerTimestamp <+ (Date.now() - gameStart)
)からレンダリング遅延(
RENDER_DELAY
)を引いたものです。
次に、ゲームの更新をどのように処理するかを見てみましょう。サーバーから更新を受信すると、それが呼び出され
processGameUpdate()
、新しい更新を配列に保存し
gameUpdates
ます。次に、メモリ使用量を確認するために、ベースアップデートに対する古いアップデートをすべて削除します。これらは不要になったためです。
「基本更新」とは何ですか?これは、現在のサーバー時刻から逆方向に移動する最初の更新です。この回路を覚えていますか?
ゲームの更新は、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), }; } }
3つのケースを処理します。
-
base < 0
は、現在のレンダリング時間の更新がないことを意味します(上記の実装を参照getBaseUpdate()
)。これは、レンダリングの遅延により、ゲームの開始時に発生する可能性があります。この場合、最新の更新を使用します。 -
base
これは最新のアップデートです。これは、ネットワーク遅延またはインターネット接続の低下が原因である可能性があります。この場合、最新の更新も使用します。 - 現在のレンダリング時間の前後に更新があるため、補間できます!
残って
state.js
いるのは、単純な(しかし退屈な)数学である線形補間の実装です。自分で学習したい場合は
state.js
、Githubで開いてください。
パート2.バックエンドサーバー
このパートでは、.ioゲームの例を実行するNode.jsバックエンドを見ていきます。
1.サーバーエントリポイント
Webサーバーを制御するために、Expressと呼ばれるNode.js用の一般的なWebフレームワークを使用します。サーバーのエントリポイントファイルによって構成されます
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構成を使用します。それらを2つの方法で適用します。
- webpack-dev-middlewareを使用して、開発パッケージを自動的に再構築するか、または
dist/
本番のアセンブリ後にWebpackがファイルを書き込むフォルダーを静的に転送します。
別の重要なタスク
server.js
は、単にExpressサーバーに接続するsocket.ioサーバーを構成することです。
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接続を正常に確立した後、新しいソケットのイベントハンドラーを構成します。イベントハンドラは、シングルトンオブジェクトへの委任によってクライアントから受信したメッセージを処理します
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ジャンルのゲームを作成するため、1つのインスタンス
Game
(「ゲーム」)のみが必要です。すべてのプレイヤーが同じアリーナでプレイします!次のセクションでは、このクラスがどのように機能するかを見ていきます
Game
。
2.ゲームサーバー
このクラスに
Game
は、最も重要なサーバー側のロジックが含まれています。プレイヤー管理とゲームシミュレーションの 2つの主なタスクがあります。
最初のタスク-プレーヤー管理から始めましょう。
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
ため、これについて心配する必要はありません。彼のプレイヤーIDに電話します。
それを念頭に置いて、クラスのインスタンス変数を調べてみましょう
Game
。
-
sockets
プレーヤーに関連付けられているソケットにプレーヤーIDをバインドするオブジェクトです。これにより、一定時間、プレーヤーIDを使用してソケットにアクセスできます。 -
players
プレーヤーIDをコードにバインドするオブジェクト>プレーヤーオブジェクト
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()
は、おそらくサーバー側のロジックの最も重要な部分が含まれています。順番に、彼が行うすべてをリストします。
- ,
dt
update()
. - . . ,
bullet.update()
true
, ( ). - . —
player.update()
Bullet
. -
applyCollisions()
, , . , (player.onDealtDamage()
),bullets
. - .
-
update()
.shouldSendUpdate
.update()
60 /, 30 /. , 30 / ( ).
? . 30 – !
なぜ、update()
毎秒30回電話しないのですか?ゲームのシミュレーションを改善するため。より頻繁に呼び出されるupdate()
ほど、ゲームのシミュレーションはより正確になります。ただし、呼び出し回数に夢中update()
になりすぎないでください。これは計算コストの高いタスクであるため、1秒あたり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()
非常に簡単です-ポイント数でプレーヤーをソートし、ベスト5を取得して、各ユーザー名とスコアを返します。プレーヤーに渡されるゲームの更新を作成するために
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
次の拡張のみに追加しました。
- shortidパッケージを使用して、
id
発射物をランダムに生成します。 parentID
この発射物を作成したプレイヤーを追跡できるようにフィールドを追加します。- に戻り値を追加します
update()
。これはtrue
、発射物がアリーナの外側にある場合に等しくなります(これについては前のセクションで説明しましたか?)。
に移りましょう
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, }; } }
プレーヤーはシェルよりも複雑なので、このクラスにはさらにいくつかのフィールドを格納する必要があります。彼のメソッド
update()
は多くの作業を行い、特に、新しく作成されたシェルが残っていない場合はそれを返します
fireCooldown
(これについては前のセクションで説明しましたか?)。また
serializeForUpdate()
、ゲームの更新にプレーヤーの追加フィールドを含める必要があるため、メソッドを拡張します。
基本クラスを持つこと
Object
は、コードの再現性を避けるための重要なステップです。たとえば、クラスがない場合、
Object
すべてのゲームオブジェクトの実装は同じである必要があり、
distanceTo()
これらのすべての実装を複数のファイルでコピーアンドペーストするのは悪夢です。これは、拡張
Object
クラスの数が増えると、大規模プロジェクトで特に重要になります。
4.紛争の認識
私たちに残された唯一のことは、砲弾がプレイヤーに命中したことを認識することです!
update()
クラスのメソッドの次のコードを思い出してください
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), ); // ... } }
applyCollisions()
プレーヤーにヒットするすべてのシェルを返すメソッドを実装する必要があります。幸いなことに、これはそれほど難しくありません。なぜなら、
- 衝突するオブジェクトはすべて円であり、これは衝突認識を実装する最も単純な図です。
distanceTo()
前のセクションのクラスで実装したメソッドがすでにありObject
ます。
衝突認識の実装は次のようになります。
collisions.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; }
この単純な衝突認識は、中心間の距離が半径の合計よりも小さい場合に2つの円が衝突するという事実に基づいています。2つの円の中心間の距離がそれらの半径の合計に正確に等しい場合は次のとおりです。
ここでは、いくつかの側面を慎重に検討する必要があります。
- 発射物は、それを作成したプレイヤーに落ちてはいけません。これは、比較することによって達成することができる
bullet.parentID
とplayer.id
。 - 発射体は、複数のプレイヤーが同時に衝突する極端な場合に一度だけヒットする必要があります。オペレーターの助けを借りてこの問題を解決
break
します。発射体と衝突したプレイヤーが見つかるとすぐに、検索を停止して次の発射体に進みます。
終わり
以上です! .io Webゲームを作成するために知っておく必要のあるすべてを網羅しました。 次は? 独自の.ioゲームをビルドしてください!
サンプルコードはすべてオープンソースであり、Githubに投稿されています。