リアルタイム多人参加.ioゲームのクライアント実装技術

多人参加型ウェブゲームのクライアント開発手法

.ioゲームはアカウント不要で即時参加可能な多人参加ウェブゲームの一種です。代表例としてSlither.ioDiep.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描画を実現しつつ、ネットワーク遅延によるカクつきを解消しています。

タグ: websocket HTML5Canvas リアルタイム同期 webpack Socket.IO

7月24日 04:57 投稿