多人参加型ウェブゲームのクライアント開発手法
.ioゲームはアカウント不要で即時参加可能な多人参加ウェブゲームの一種です。代表例としてSlither.ioやDiep.ioが挙げられます。本稿ではJavaScriptを用いたリアルタイム多人参加ゲームのクライアント実装に焦点を当てます。
プロジェクト構成
開発環境は以下で構築されています:
- Express:Node.jsベースのWebサーバー
- Socket.IO:双方向通信ライブラリ
- Webpack:モジュールバンドラー
ディレクトリ構造は以下の通りです:
public/
assets/ # ゲームリソース格納
src/
client/ # クライアント実装
server/ # サーバー実装
shared/ # 共通定数定義
ビルド環境設定
Webpackによるモジュールバンドリングを実装しています。開発用設定例:
const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base');
module.exports = merge(baseConfig, {
mode: 'development',
devtool: 'eval-source-map',
devServer: {
static: './dist',
hot: true
}
});
ハッシュ付きファイル名(例: main.a1b2c3d4.js)を採用し、ブラウザキャッシュ最適化を実現しています。
エントリーポイント実装
HTML側の主要構成要素:
<body>
<canvas id="game-screen"></canvas>
<div id="lobby" class="inactive">
<input type="text" id="player-name" placeholder="名前を入力">
<button id="start-button">開始</button>
</div>
<script src="/bundle.js"></script>
</body>
JavaScript初期化処理:
import { initializeConnection } from './network';
import { loadResources } from './assets';
const lobby = document.getElementById('lobby');
const startBtn = document.getElementById('start-button');
Promise.all([
initializeConnection(),
loadResources()
]).then(() => {
lobby.classList.remove('inactive');
startBtn.addEventListener('click', () => {
const name = document.getElementById('player-name').value;
startGame(name);
lobby.classList.add('inactive');
activateInput();
startRenderLoop();
});
});
ネットワーク通信実装
Socket.IOを用いた双方向通信の実装例:
import io from 'socket.io-client';
const socket = io();
const connectionReady = new Promise(resolve => {
socket.on('connect', resolve);
});
export const initializeConnection = () => connectionReady;
export const startGame = name => {
socket.emit('join_request', name);
};
export const sendDirection = angle => {
socket.emit('player_direction', angle);
};
export const registerUpdateHandler = (updateCallback) => {
socket.on('game_state', updateCallback);
};
グラフィックレンダリング
HTML5 Canvasを用いた描画処理:
const canvas = document.getElementById('game-screen');
const ctx = canvas.getContext('2d');
function renderFrame(gameState) {
if (!gameState.player) return;
// 背景描画
drawBackground(gameState.player);
// プレイヤー描画
gameState.players.forEach(p => renderPlayer(p, gameState.player));
// 弾丸描画
gameState.projectiles.forEach(b => renderProjectile(b, gameState.player));
}
function renderPlayer(target, viewer) {
const offsetX = canvas.width/2 + target.x - viewer.x;
const offsetY = canvas.height/2 + target.y - viewer.y;
ctx.drawImage(
getResource('ship.png'),
offsetX - 15,
offsetY - 15,
30,
30
);
}
入力処理システム
マウス/タッチ入力の統合実装:
function handlePointerMove(e) {
const rect = canvas.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const angle = Math.atan2(
e.clientX - centerX,
centerY - e.clientY
);
sendDirection(angle);
}
export function activateInput() {
canvas.addEventListener('mousemove', handlePointerMove);
canvas.addEventListener('touchmove', e => {
e.preventDefault();
handlePointerMove(e.touches[0]);
});
}
状態同期技術
ネットワーク遅延対策としてのレンダリング遅延と線形補間:
const RENDER_OFFSET = 100; // ミリ秒単位のレンダリング遅延
const stateHistory = [];
export function processGameState(update) {
stateHistory.push(update);
// 不要な履歴を削除
const baseIndex = findBaseState();
if (baseIndex > 0) {
stateHistory.splice(0, baseIndex);
}
}
function findBaseState() {
const currentTime = getAdjustedServerTime();
for (let i = stateHistory.length - 1; i >= 0; i--) {
if (stateHistory[i].timestamp <= currentTime) {
return i;
}
}
return -1;
}
export function getCurrentState() {
const baseIndex = findBaseState();
if (baseIndex < 0 || baseIndex === stateHistory.length - 1) {
return stateHistory[stateHistory.length - 1];
}
const base = stateHistory[baseIndex];
const next = stateHistory[baseIndex + 1];
const ratio = (getAdjustedServerTime() - base.timestamp) /
(next.timestamp - base.timestamp);
return interpolateStates(base, next, ratio);
}
この手法により、サーバーtickレート(30Hz)を超える60FPS描画を実現しつつ、ネットワーク遅延によるカクつきを解消しています。