マルチプレイヤーの.io Webゲームを作成する

画像






2015年にリリースされたAgar.ioは、 ゲーム.ioの新しいジャンルの先駆者となり、その人気はその後大きく成長しました。 私が経験した.ioゲームの人気の高まり:過去3年間で、このジャンルの2つのゲームを作成して販売しました。



このようなゲームを聞いたことがない場合:これらは、参加しやすい無料のマルチプレイヤーWebゲームです(アカウントは不要です)。 通常、彼らは同じアリーナで多くの敵プレイヤーに立ち向かいます。 .ioジャンルの他の有名なゲームは、 Slither.ioDiep.ioです。



この投稿では、.ioゲームをゼロから作成する方法理解します 。 このためには、Javascriptの知識だけで十分です。ES6構文、 this



Promisesなどを理解する必要があります。 Javascriptを完全に知っていなくても、ほとんどの投稿を理解できます。



ゲーム例.io



学習を支援するために、サンプルの.ioゲームを参照します。 プレイしてみてください!









ゲームは非常に簡単です。他のプレイヤーがいるアリーナで船を操作します。 あなたの船は自動的に砲弾を発射し、他のプレイヤーを攻撃しようとしながら、彼らの砲弾を避けます。



1.概要/プロジェクト構造



サンプルゲームのソースコードをダウンロードして、フォローしてください。


この例では次を使用します。





プロジェクトディレクトリの構造は次のとおりです。



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





ここで最も重要なのは、次の行です。





奇妙なパッケージファイル名'[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で表示できます。



私たちが持っています:





ホームページを読み込んだ後、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); }; });
      
      





これは複雑に思えるかもしれませんが、実際にはここでは多くのアクションが発生していません。



  1. 他のいくつかのJSファイルをインポートします。
  2. CSSをインポートします(WebpackがCSSパッケージにそれらを含めることを認識します)。
  3. connect()



    を実行してサーバーへの接続を確立し、 downloadAssets()



    を実行してゲームのレンダリングに必要な画像をダウンロードします。
  4. 手順3を完了すると、メインメニュー( playMenu



    )がplayMenu



  5. 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つの主要なアクションがあります。





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ページに描画します。 私たちのゲームは非常にシンプルなので、以下を描くだけです。



  1. 背景
  2. プレイヤーシップ
  3. ゲーム内の他のプレイヤー


上記の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つの同一のフィールドが含まれます。





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つのケースを処理します。



  1. base < 0



    は、現在のレンダリング時間の更新がないことを意味します(上記の実装を参照getBaseUpdate()



    )。これは、レンダリングの遅延により、ゲームの開始時に発生する可能性があります。この場合、最新の更新を使用します。
  2. base



    これは最新のアップデートです。これは、ネットワーク遅延またはインターネット接続の低下が原因である可能性があります。この場合、最新の更新も使用します。
  3. 現在のレンダリング時間の前後に更新があるため、補間できます


残って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つの方法で適用します。





別の重要なタスク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









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()



は、おそらくサーバー側のロジックの最も重要な部分が含まれています。順番に、彼が行うすべてをリストします。



  1. , dt



    update()



    .
  2. . . , bullet.update()



    true



    ,
    ( ).
  3. . — player.update()



    Bullet



    .
  4. applyCollisions()



    , , . , ( player.onDealtDamage()



    ), bullets



    .
  5. .
  6. 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



次の拡張のみに追加しました





に移りましょう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()



プレーヤーにヒットするすべてのシェルを返すメソッドを実装する必要があります。幸いなことに、これはそれほど難しくありません。なぜなら、





衝突認識の実装は次のようになります。



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つの円の中心間の距離がそれらの半径の合計に正確に等しい場合は次のとおりです。









ここでは、いくつかの側面を慎重に検討する必要があります。





終わり



以上です! .io Webゲームを作成するために知っておく必要のあるすべてを網羅しました。 次は? 独自の.ioゲームをビルドしてください!



サンプルコードはすべてオープンソースであり、Githubに投稿されています。



All Articles