第5章:Pongゲーム実装
はじめに
Pongは1972年にAtariがリリースした歴史的なビデオゲームです。ft_transcendenceでは、このクラシックゲームをWebブラウザでリアルタイムマルチプレイヤーとして実装します。本章では、Canvas描画、ゲームループ、物理演算、ネットワーク同期を学びます。
---
1. Canvas基礎
1.1 HTML5 Canvas
// src/game/canvas.ts
export class GameCanvas {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
readonly width = 800;
readonly height = 600;
constructor(container: HTMLElement) {
this.canvas = document.createElement('canvas');
this.canvas.width = this.width;
this.canvas.height = this.height;
this.canvas.className = 'game-canvas';
this.ctx = this.canvas.getContext('2d')!;
container.appendChild(this.canvas);
}
clear(): void {
this.ctx.fillStyle = '#0f172a';
this.ctx.fillRect(0, 0, this.width, this.height);
}
drawRect(
x: number,
y: number,
width: number,
height: number,
color: string
): void {
this.ctx.fillStyle = color;
this.ctx.fillRect(x, y, width, height);
}
drawCircle(x: number, y: number, radius: number, color: string): void {
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.fill();
this.ctx.closePath();
}
drawText(
text: string,
x: number,
y: number,
options: {
color?: string;
font?: string;
align?: CanvasTextAlign;
} = {}
): void {
const { color = '#fff', font = '24px Arial', align = 'center' } = options;
this.ctx.fillStyle = color;
this.ctx.font = font;
this.ctx.textAlign = align;
this.ctx.fillText(text, x, y);
}
drawDashedLine(
x: number,
y1: number,
y2: number,
color: string
): void {
this.ctx.beginPath();
this.ctx.setLineDash([10, 10]);
this.ctx.strokeStyle = color;
this.ctx.lineWidth = 2;
this.ctx.moveTo(x, y1);
this.ctx.lineTo(x, y2);
this.ctx.stroke();
this.ctx.setLineDash([]);
}
getCanvas(): HTMLCanvasElement {
return this.canvas;
}
}
1.2 ゲームオブジェクト
// src/game/objects.ts
export interface Position {
x: number;
y: number;
}
export interface Velocity {
vx: number;
vy: number;
}
export class Paddle {
x: number;
y: number;
width: number;
height: number;
color: string;
speed: number;
constructor(
x: number,
y: number,
options: {
width?: number;
height?: number;
color?: string;
speed?: number;
} = {}
) {
this.x = x;
this.y = y;
this.width = options.width || 10;
this.height = options.height || 100;
this.color = options.color || '#fff';
this.speed = options.speed || 8;
}
moveUp(canvasHeight: number): void {
this.y = Math.max(0, this.y - this.speed);
}
moveDown(canvasHeight: number): void {
this.y = Math.min(canvasHeight - this.height, this.y + this.speed);
}
setY(y: number, canvasHeight: number): void {
this.y = Math.max(0, Math.min(canvasHeight - this.height, y));
}
getBounds(): { left: number; right: number; top: number; bottom: number } {
return {
left: this.x,
right: this.x + this.width,
top: this.y,
bottom: this.y + this.height,
};
}
}
export class Ball {
x: number;
y: number;
radius: number;
vx: number;
vy: number;
speed: number;
color: string;
constructor(
x: number,
y: number,
options: {
radius?: number;
speed?: number;
color?: string;
} = {}
) {
this.x = x;
this.y = y;
this.radius = options.radius || 10;
this.speed = options.speed || 7;
this.color = options.color || '#fff';
// ランダムな方向で開始
const angle = (Math.random() * Math.PI / 2) - Math.PI / 4;
const direction = Math.random() > 0.5 ? 1 : -1;
this.vx = Math.cos(angle) * this.speed * direction;
this.vy = Math.sin(angle) * this.speed;
}
update(): void {
this.x += this.vx;
this.y += this.vy;
}
reset(canvasWidth: number, canvasHeight: number, direction: number): void {
this.x = canvasWidth / 2;
this.y = canvasHeight / 2;
const angle = (Math.random() * Math.PI / 2) - Math.PI / 4;
this.vx = Math.cos(angle) * this.speed * direction;
this.vy = Math.sin(angle) * this.speed;
}
bounceY(): void {
this.vy *= -1;
}
bounceX(paddleY: number, paddleHeight: number): void {
// パドルのどこに当たったかで角度を変える
const hitPosition = (this.y - paddleY) / paddleHeight;
const angle = (hitPosition - 0.5) * Math.PI / 2;
const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
const direction = this.vx > 0 ? -1 : 1;
this.vx = Math.cos(angle) * speed * direction;
this.vy = Math.sin(angle) * speed;
// 速度を少し上げる
this.vx *= 1.05;
this.vy *= 1.05;
}
}
---
2. ゲームエンジン
2.1 ゲームステート
// src/game/state.ts
export type GameStatus = 'waiting' | 'countdown' | 'playing' | 'paused' | 'finished';
export interface Player {
id: number;
username: string;
score: number;
ready: boolean;
}
export interface GameState {
id: string;
status: GameStatus;
player1: Player;
player2: Player;
paddle1Y: number;
paddle2Y: number;
ball: {
x: number;
y: number;
vx: number;
vy: number;
};
winner: number | null;
countdown: number;
}
export function createInitialState(
gameId: string,
player1: Player,
player2: Player | null,
canvasWidth: number,
canvasHeight: number
): GameState {
return {
id: gameId,
status: 'waiting',
player1: { ...player1, score: 0, ready: false },
player2: player2
? { ...player2, score: 0, ready: false }
: { id: 0, username: 'Waiting...', score: 0, ready: false },
paddle1Y: canvasHeight / 2 - 50,
paddle2Y: canvasHeight / 2 - 50,
ball: {
x: canvasWidth / 2,
y: canvasHeight / 2,
vx: 0,
vy: 0,
},
winner: null,
countdown: 3,
};
}
2.2 ゲームエンジン(クライアント)
// src/game/engine.ts
import { GameCanvas } from './canvas';
import { Paddle, Ball } from './objects';
import { GameState, GameStatus } from './state';
import { gameSocket } from '../socket';
export class PongEngine {
private canvas: GameCanvas;
private paddle1: Paddle;
private paddle2: Paddle;
private ball: Ball;
private state: GameState | null = null;
private animationId: number | null = null;
private lastFrameTime: number = 0;
private isLocalPlayer1: boolean = false;
private keys: Set<string> = new Set();
// 設定
readonly config = {
paddleWidth: 10,
paddleHeight: 100,
paddleOffset: 20,
ballRadius: 10,
winScore: 11,
};
constructor(container: HTMLElement) {
this.canvas = new GameCanvas(container);
// パドル初期化
this.paddle1 = new Paddle(
this.config.paddleOffset,
this.canvas.height / 2 - this.config.paddleHeight / 2,
{ height: this.config.paddleHeight }
);
this.paddle2 = new Paddle(
this.canvas.width - this.config.paddleOffset - this.config.paddleWidth,
this.canvas.height / 2 - this.config.paddleHeight / 2,
{ height: this.config.paddleHeight }
);
// ボール初期化
this.ball = new Ball(
this.canvas.width / 2,
this.canvas.height / 2,
{ radius: this.config.ballRadius }
);
this.setupInputHandlers();
}
private setupInputHandlers(): void {
document.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'w', 's'].includes(e.key)) {
e.preventDefault();
this.keys.add(e.key);
}
});
document.addEventListener('keyup', (e) => {
this.keys.delete(e.key);
});
}
setState(state: GameState): void {
this.state = state;
// パドル位置を同期
this.paddle1.y = state.paddle1Y;
this.paddle2.y = state.paddle2Y;
// ボール位置を同期
this.ball.x = state.ball.x;
this.ball.y = state.ball.y;
this.ball.vx = state.ball.vx;
this.ball.vy = state.ball.vy;
}
setLocalPlayer(isPlayer1: boolean): void {
this.isLocalPlayer1 = isPlayer1;
}
start(): void {
this.lastFrameTime = performance.now();
this.gameLoop(this.lastFrameTime);
}
stop(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
private gameLoop(currentTime: number): void {
const deltaTime = (currentTime - this.lastFrameTime) / 1000;
this.lastFrameTime = currentTime;
this.update(deltaTime);
this.render();
this.animationId = requestAnimationFrame((t) => this.gameLoop(t));
}
private update(deltaTime: number): void {
if (!this.state || this.state.status !== 'playing') return;
// 入力処理
this.handleInput();
// クライアントサイド予測(オプション)
// ボールの移動を予測して滑らかに表示
if (this.state.status === 'playing') {
this.ball.x += this.ball.vx * deltaTime * 60;
this.ball.y += this.ball.vy * deltaTime * 60;
// 壁との衝突
if (this.ball.y - this.ball.radius <= 0 ||
this.ball.y + this.ball.radius >= this.canvas.height) {
this.ball.vy *= -1;
}
}
}
private handleInput(): void {
if (!this.state || this.state.status !== 'playing') return;
const paddle = this.isLocalPlayer1 ? this.paddle1 : this.paddle2;
let moved = false;
if (this.keys.has('ArrowUp') || this.keys.has('w')) {
paddle.moveUp(this.canvas.height);
moved = true;
}
if (this.keys.has('ArrowDown') || this.keys.has('s')) {
paddle.moveDown(this.canvas.height);
moved = true;
}
// サーバーにパドル位置を送信
if (moved && this.state) {
gameSocket.paddleMove(this.state.id, paddle.y);
}
}
private render(): void {
// 背景クリア
this.canvas.clear();
// 中央線
this.canvas.drawDashedLine(
this.canvas.width / 2,
0,
this.canvas.height,
'#334155'
);
// パドル描画
this.canvas.drawRect(
this.paddle1.x,
this.paddle1.y,
this.paddle1.width,
this.paddle1.height,
'#3b82f6'
);
this.canvas.drawRect(
this.paddle2.x,
this.paddle2.y,
this.paddle2.width,
this.paddle2.height,
'#ef4444'
);
// ボール描画
this.canvas.drawCircle(
this.ball.x,
this.ball.y,
this.ball.radius,
'#fff'
);
// スコア描画
if (this.state) {
this.canvas.drawText(
this.state.player1.score.toString(),
this.canvas.width / 4,
80,
{ font: '64px Arial', color: '#3b82f6' }
);
this.canvas.drawText(
this.state.player2.score.toString(),
(this.canvas.width * 3) / 4,
80,
{ font: '64px Arial', color: '#ef4444' }
);
// プレイヤー名
this.canvas.drawText(
this.state.player1.username,
this.canvas.width / 4,
30,
{ font: '16px Arial', color: '#94a3b8' }
);
this.canvas.drawText(
this.state.player2.username,
(this.canvas.width * 3) / 4,
30,
{ font: '16px Arial', color: '#94a3b8' }
);
}
// ステータス表示
this.renderStatus();
}
private renderStatus(): void {
if (!this.state) return;
switch (this.state.status) {
case 'waiting':
this.renderOverlay('Waiting for opponent...');
break;
case 'countdown':
this.renderOverlay(this.state.countdown.toString(), '128px Arial');
break;
case 'paused':
this.renderOverlay('PAUSED');
break;
case 'finished':
const winner = this.state.winner === this.state.player1.id
? this.state.player1.username
: this.state.player2.username;
this.renderOverlay(`${winner} WINS!`);
break;
}
}
private renderOverlay(text: string, font: string = '32px Arial'): void {
// 半透明オーバーレイ
this.canvas.drawRect(
0,
0,
this.canvas.width,
this.canvas.height,
'rgba(0, 0, 0, 0.7)'
);
this.canvas.drawText(
text,
this.canvas.width / 2,
this.canvas.height / 2,
{ font, color: '#fff' }
);
}
destroy(): void {
this.stop();
this.canvas.getCanvas().remove();
}
}
---
3. サーバーサイドゲームロジック
3.1 ゲームルーム管理
// backend/src/game/game.room.ts
import { EventEmitter } from 'events';
export interface GameRoom {
id: string;
player1: {
id: number;
username: string;
socketId: string;
score: number;
ready: boolean;
paddleY: number;
};
player2: {
id: number;
username: string;
socketId: string;
score: number;
ready: boolean;
paddleY: number;
} | null;
ball: {
x: number;
y: number;
vx: number;
vy: number;
};
status: 'waiting' | 'countdown' | 'playing' | 'paused' | 'finished';
winner: number | null;
countdown: number;
createdAt: Date;
}
export class GameRoomManager {
private rooms: Map<string, GameRoom> = new Map();
private playerRooms: Map<number, string> = new Map();
createRoom(
gameId: string,
player1Id: number,
player1Username: string,
player1SocketId: string
): GameRoom {
const room: GameRoom = {
id: gameId,
player1: {
id: player1Id,
username: player1Username,
socketId: player1SocketId,
score: 0,
ready: false,
paddleY: 250, // 初期位置
},
player2: null,
ball: {
x: 400,
y: 300,
vx: 0,
vy: 0,
},
status: 'waiting',
winner: null,
countdown: 3,
createdAt: new Date(),
};
this.rooms.set(gameId, room);
this.playerRooms.set(player1Id, gameId);
return room;
}
joinRoom(
gameId: string,
player2Id: number,
player2Username: string,
player2SocketId: string
): GameRoom | null {
const room = this.rooms.get(gameId);
if (!room || room.player2) return null;
room.player2 = {
id: player2Id,
username: player2Username,
socketId: player2SocketId,
score: 0,
ready: false,
paddleY: 250,
};
this.playerRooms.set(player2Id, gameId);
return room;
}
getRoom(gameId: string): GameRoom | null {
return this.rooms.get(gameId) || null;
}
getRoomByPlayerId(playerId: number): GameRoom | null {
const gameId = this.playerRooms.get(playerId);
if (!gameId) return null;
return this.rooms.get(gameId) || null;
}
removeRoom(gameId: string): void {
const room = this.rooms.get(gameId);
if (room) {
this.playerRooms.delete(room.player1.id);
if (room.player2) {
this.playerRooms.delete(room.player2.id);
}
this.rooms.delete(gameId);
}
}
}
3.2 ゲーム物理演算(サーバー)
// backend/src/game/game.physics.ts
export class GamePhysics {
private readonly WIDTH = 800;
private readonly HEIGHT = 600;
private readonly PADDLE_WIDTH = 10;
private readonly PADDLE_HEIGHT = 100;
private readonly PADDLE_OFFSET = 20;
private readonly BALL_RADIUS = 10;
private readonly BALL_SPEED = 7;
private readonly WIN_SCORE = 11;
update(room: GameRoom): { scored: 'player1' | 'player2' | null } {
if (room.status !== 'playing') {
return { scored: null };
}
// ボール移動
room.ball.x += room.ball.vx;
room.ball.y += room.ball.vy;
// 上下の壁との衝突
if (room.ball.y - this.BALL_RADIUS <= 0) {
room.ball.y = this.BALL_RADIUS;
room.ball.vy = Math.abs(room.ball.vy);
}
if (room.ball.y + this.BALL_RADIUS >= this.HEIGHT) {
room.ball.y = this.HEIGHT - this.BALL_RADIUS;
room.ball.vy = -Math.abs(room.ball.vy);
}
// パドル1との衝突(左側)
if (room.ball.x - this.BALL_RADIUS <= this.PADDLE_OFFSET + this.PADDLE_WIDTH) {
if (room.ball.y >= room.player1.paddleY &&
room.ball.y <= room.player1.paddleY + this.PADDLE_HEIGHT) {
room.ball.x = this.PADDLE_OFFSET + this.PADDLE_WIDTH + this.BALL_RADIUS;
// 反射角度計算
const hitPos = (room.ball.y - room.player1.paddleY) / this.PADDLE_HEIGHT;
const angle = (hitPos - 0.5) * Math.PI / 2;
const speed = Math.sqrt(
room.ball.vx * room.ball.vx + room.ball.vy * room.ball.vy
) * 1.05;
room.ball.vx = Math.cos(angle) * speed;
room.ball.vy = Math.sin(angle) * speed;
}
}
// パドル2との衝突(右側)
if (room.ball.x + this.BALL_RADIUS >=
this.WIDTH - this.PADDLE_OFFSET - this.PADDLE_WIDTH) {
if (room.player2 &&
room.ball.y >= room.player2.paddleY &&
room.ball.y <= room.player2.paddleY + this.PADDLE_HEIGHT) {
room.ball.x = this.WIDTH - this.PADDLE_OFFSET -
this.PADDLE_WIDTH - this.BALL_RADIUS;
const hitPos = (room.ball.y - room.player2.paddleY) / this.PADDLE_HEIGHT;
const angle = (hitPos - 0.5) * Math.PI / 2;
const speed = Math.sqrt(
room.ball.vx * room.ball.vx + room.ball.vy * room.ball.vy
) * 1.05;
room.ball.vx = -Math.cos(angle) * speed;
room.ball.vy = Math.sin(angle) * speed;
}
}
// スコア判定
if (room.ball.x < 0) {
// Player2得点
if (room.player2) {
room.player2.score++;
this.resetBall(room, -1);
return { scored: 'player2' };
}
}
if (room.ball.x > this.WIDTH) {
// Player1得点
room.player1.score++;
this.resetBall(room, 1);
return { scored: 'player1' };
}
return { scored: null };
}
private resetBall(room: GameRoom, direction: number): void {
room.ball.x = this.WIDTH / 2;
room.ball.y = this.HEIGHT / 2;
const angle = (Math.random() - 0.5) * Math.PI / 2;
room.ball.vx = Math.cos(angle) * this.BALL_SPEED * direction;
room.ball.vy = Math.sin(angle) * this.BALL_SPEED;
}
checkWinner(room: GameRoom): number | null {
if (room.player1.score >= this.WIN_SCORE) {
return room.player1.id;
}
if (room.player2 && room.player2.score >= this.WIN_SCORE) {
return room.player2.id;
}
return null;
}
startBall(room: GameRoom): void {
const direction = Math.random() > 0.5 ? 1 : -1;
const angle = (Math.random() - 0.5) * Math.PI / 2;
room.ball.vx = Math.cos(angle) * this.BALL_SPEED * direction;
room.ball.vy = Math.sin(angle) * this.BALL_SPEED;
}
}
3.3 ゲームゲートウェイ
// backend/src/game/game.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { GameRoomManager, GameRoom } from './game.room';
import { GamePhysics } from './game.physics';
import { MatchmakingService } from './matchmaking.service';
import { GameService } from './game.service';
@WebSocketGateway({ namespace: 'game' })
export class GameGateway {
@WebSocketServer()
server: Server;
private roomManager = new GameRoomManager();
private physics = new GamePhysics();
private gameLoops: Map<string, NodeJS.Timer> = new Map();
constructor(
private matchmaking: MatchmakingService,
private gameService: GameService,
) {
// マッチメイキング成功時
this.matchmaking.on('match.created', (data) => {
this.createMatch(data.player1, data.player2);
});
}
@SubscribeMessage('game:queue:join')
handleJoinQueue(@ConnectedSocket() client: Socket) {
const user = client.data.user;
this.matchmaking.addToQueue({
id: user.id,
username: user.username,
rating: user.ladderLevel * 100 + user.wins - user.losses,
joinedAt: new Date(),
socketId: client.id,
});
return { success: true, message: 'Joined queue' };
}
@SubscribeMessage('game:queue:leave')
handleLeaveQueue(@ConnectedSocket() client: Socket) {
const user = client.data.user;
this.matchmaking.removeFromQueue(user.id);
return { success: true };
}
@SubscribeMessage('game:ready')
handleReady(
@ConnectedSocket() client: Socket,
@MessageBody() data: { gameId: string }
) {
const room = this.roomManager.getRoom(data.gameId);
if (!room) return { success: false, error: 'Game not found' };
const user = client.data.user;
if (room.player1.id === user.id) {
room.player1.ready = true;
} else if (room.player2?.id === user.id) {
room.player2.ready = true;
}
// 両者準備完了
if (room.player1.ready && room.player2?.ready) {
this.startCountdown(room);
}
this.broadcastGameState(room);
return { success: true };
}
@SubscribeMessage('game:paddle:move')
handlePaddleMove(
@ConnectedSocket() client: Socket,
@MessageBody() data: { gameId: string; y: number }
) {
const room = this.roomManager.getRoom(data.gameId);
if (!room || room.status !== 'playing') return;
const user = client.data.user;
// パドル位置を更新
if (room.player1.id === user.id) {
room.player1.paddleY = Math.max(0, Math.min(500, data.y));
} else if (room.player2?.id === user.id) {
room.player2.paddleY = Math.max(0, Math.min(500, data.y));
}
// 相手にのみ送信(本人には送らない)
client.to(`game:${data.gameId}`).emit('game:paddle:update', {
playerId: user.id,
y: data.y,
});
}
private createMatch(player1: any, player2: any): void {
const gameId = `game_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const room = this.roomManager.createRoom(
gameId,
player1.id,
player1.username,
player1.socketId
);
this.roomManager.joinRoom(
gameId,
player2.id,
player2.username,
player2.socketId
);
// プレイヤーをルームに参加
const socket1 = this.server.sockets.get(player1.socketId);
const socket2 = this.server.sockets.get(player2.socketId);
socket1?.join(`game:${gameId}`);
socket2?.join(`game:${gameId}`);
// 初期状態を送信
this.server.to(`game:${gameId}`).emit('game:matched', {
gameId,
player1: { id: player1.id, username: player1.username },
player2: { id: player2.id, username: player2.username },
});
}
private startCountdown(room: GameRoom): void {
room.status = 'countdown';
room.countdown = 3;
const countdownInterval = setInterval(() => {
room.countdown--;
this.broadcastGameState(room);
if (room.countdown <= 0) {
clearInterval(countdownInterval);
this.startGame(room);
}
}, 1000);
}
private startGame(room: GameRoom): void {
room.status = 'playing';
this.physics.startBall(room);
this.broadcastGameState(room);
// ゲームループ開始(60fps)
const gameLoop = setInterval(() => {
const result = this.physics.update(room);
if (result.scored) {
// スコア変更を通知
this.broadcastGameState(room);
// 勝者チェック
const winner = this.physics.checkWinner(room);
if (winner) {
this.endGame(room, winner);
clearInterval(gameLoop);
this.gameLoops.delete(room.id);
}
} else {
// ボール位置を送信(頻度を下げることも可)
this.server.to(`game:${room.id}`).emit('game:ball:update', room.ball);
}
}, 1000 / 60);
this.gameLoops.set(room.id, gameLoop);
}
private async endGame(room: GameRoom, winnerId: number): Promise<void> {
room.status = 'finished';
room.winner = winnerId;
// ゲーム結果を保存
await this.gameService.saveGameResult(room);
this.broadcastGameState(room);
// 一定時間後にルームを削除
setTimeout(() => {
this.roomManager.removeRoom(room.id);
}, 10000);
}
private broadcastGameState(room: GameRoom): void {
this.server.to(`game:${room.id}`).emit('game:state', {
id: room.id,
status: room.status,
player1: {
id: room.player1.id,
username: room.player1.username,
score: room.player1.score,
ready: room.player1.ready,
},
player2: room.player2 ? {
id: room.player2.id,
username: room.player2.username,
score: room.player2.score,
ready: room.player2.ready,
} : null,
paddle1Y: room.player1.paddleY,
paddle2Y: room.player2?.paddleY || 250,
ball: room.ball,
winner: room.winner,
countdown: room.countdown,
});
}
}
---
4. ゲームビュー
4.1 ゲームページ
// src/views/Game.ts
import { View } from '../components/base/Component';
import { PongEngine } from '../game/engine';
import { store, socket } from '../main';
import { gameSocket } from '../socket';
export default class GameView extends View {
private engine: PongEngine | null = null;
private unsubscribes: (() => void)[] = [];
private inQueue: boolean = false;
render(): HTMLElement {
const container = document.createElement('div');
container.className = 'game-view';
container.innerHTML = `
<div class="game-container">
<div class="game-header">
<h1>Pong</h1>
<div class="game-controls">
<button id="queue-btn" class="btn-primary">
Find Match
</button>
<button id="leave-queue-btn" class="btn-secondary" style="display: none;">
Leave Queue
</button>
</div>
</div>
<div id="game-canvas-container" class="game-canvas-container">
<div class="game-placeholder">
<p>Click "Find Match" to start playing</p>
<div class="controls-info">
<p><strong>Controls:</strong></p>
<p>W / Arrow Up - Move Up</p>
<p>S / Arrow Down - Move Down</p>
</div>
</div>
</div>
<div id="queue-status" class="queue-status" style="display: none;">
<div class="spinner"></div>
<p>Searching for opponent...</p>
<p id="queue-position"></p>
</div>
</div>
`;
return container;
}
afterMount(): void {
const queueBtn = document.getElementById('queue-btn');
const leaveQueueBtn = document.getElementById('leave-queue-btn');
queueBtn?.addEventListener('click', () => this.joinQueue());
leaveQueueBtn?.addEventListener('click', () => this.leaveQueue());
// WebSocket イベント
const unsubMatched = socket.on('game:matched', (data: any) => {
this.onMatchFound(data);
});
const unsubState = socket.on('game:state', (state: any) => {
this.onGameState(state);
});
const unsubBall = socket.on('game:ball:update', (ball: any) => {
if (this.engine) {
const state = store.getState().game;
if (state) {
state.ball = ball;
this.engine.setState(state);
}
}
});
const unsubPaddle = socket.on('game:paddle:update', (data: any) => {
if (this.engine) {
const state = store.getState().game;
if (state) {
if (data.playerId === state.player1.id) {
state.paddle1Y = data.y;
} else {
state.paddle2Y = data.y;
}
this.engine.setState(state);
}
}
});
this.unsubscribes.push(unsubMatched, unsubState, unsubBall, unsubPaddle);
}
private async joinQueue(): Promise<void> {
try {
await gameSocket.joinQueue();
this.inQueue = true;
this.updateQueueUI(true);
} catch (error) {
console.error('Failed to join queue:', error);
}
}
private leaveQueue(): void {
gameSocket.leaveQueue();
this.inQueue = false;
this.updateQueueUI(false);
}
private updateQueueUI(inQueue: boolean): void {
const queueBtn = document.getElementById('queue-btn');
const leaveQueueBtn = document.getElementById('leave-queue-btn');
const queueStatus = document.getElementById('queue-status');
if (queueBtn) queueBtn.style.display = inQueue ? 'none' : 'block';
if (leaveQueueBtn) leaveQueueBtn.style.display = inQueue ? 'block' : 'none';
if (queueStatus) queueStatus.style.display = inQueue ? 'flex' : 'none';
}
private onMatchFound(data: { gameId: string; player1: any; player2: any }): void {
this.inQueue = false;
this.updateQueueUI(false);
const user = store.getState().user;
const isPlayer1 = data.player1.id === user?.id;
// ゲームエンジン初期化
const container = document.getElementById('game-canvas-container');
if (container) {
container.innerHTML = '';
this.engine = new PongEngine(container);
this.engine.setLocalPlayer(isPlayer1);
this.engine.start();
}
// Ready ボタン表示
this.showReadyButton(data.gameId);
}
private showReadyButton(gameId: string): void {
const container = document.getElementById('game-canvas-container');
if (!container) return;
const readyBtn = document.createElement('button');
readyBtn.id = 'ready-btn';
readyBtn.className = 'btn-primary ready-btn';
readyBtn.textContent = 'READY';
readyBtn.addEventListener('click', () => {
gameSocket.ready(gameId);
readyBtn.disabled = true;
readyBtn.textContent = 'Waiting for opponent...';
});
container.appendChild(readyBtn);
}
private onGameState(state: any): void {
store.setState({ game: state });
if (this.engine) {
this.engine.setState(state);
}
// ゲーム開始時にReady ボタンを削除
if (state.status === 'playing' || state.status === 'countdown') {
const readyBtn = document.getElementById('ready-btn');
readyBtn?.remove();
}
// ゲーム終了時
if (state.status === 'finished') {
this.showGameResult(state);
}
}
private showGameResult(state: any): void {
const user = store.getState().user;
const isWinner = state.winner === user?.id;
const container = document.getElementById('game-canvas-container');
if (!container) return;
const resultDiv = document.createElement('div');
resultDiv.className = 'game-result';
resultDiv.innerHTML = `
<h2>${isWinner ? 'Victory!' : 'Defeat'}</h2>
<p>${state.player1.score} - ${state.player2.score}</p>
<button class="btn-primary" id="play-again-btn">Play Again</button>
`;
container.appendChild(resultDiv);
document.getElementById('play-again-btn')?.addEventListener('click', () => {
resultDiv.remove();
this.engine?.destroy();
this.engine = null;
// プレースホルダーを再表示
container.innerHTML = `
<div class="game-placeholder">
<p>Click "Find Match" to start playing</p>
</div>
`;
});
}
beforeUnmount(): void {
this.unsubscribes.forEach(unsub => unsub());
if (this.inQueue) {
gameSocket.leaveQueue();
}
this.engine?.destroy();
}
}
---
5. ネットワーク同期
5.1 遅延補償
// src/game/interpolation.ts
interface PositionSnapshot {
x: number;
y: number;
timestamp: number;
}
export class Interpolator {
private snapshots: PositionSnapshot[] = [];
private readonly BUFFER_SIZE = 10;
private readonly INTERPOLATION_DELAY = 100; // ms
addSnapshot(x: number, y: number): void {
const snapshot: PositionSnapshot = {
x,
y,
timestamp: Date.now(),
};
this.snapshots.push(snapshot);
// バッファサイズを超えたら古いものを削除
if (this.snapshots.length > this.BUFFER_SIZE) {
this.snapshots.shift();
}
}
getInterpolatedPosition(): { x: number; y: number } | null {
if (this.snapshots.length < 2) {
return this.snapshots[0] || null;
}
const renderTime = Date.now() - this.INTERPOLATION_DELAY;
// 補間するスナップショットを見つける
let older: PositionSnapshot | null = null;
let newer: PositionSnapshot | null = null;
for (let i = 0; i < this.snapshots.length - 1; i++) {
if (this.snapshots[i].timestamp <= renderTime &&
this.snapshots[i + 1].timestamp >= renderTime) {
older = this.snapshots[i];
newer = this.snapshots[i + 1];
break;
}
}
if (!older || !newer) {
return this.snapshots[this.snapshots.length - 1];
}
// 線形補間
const t = (renderTime - older.timestamp) /
(newer.timestamp - older.timestamp);
return {
x: older.x + (newer.x - older.x) * t,
y: older.y + (newer.y - older.y) * t,
};
}
}
5.2 クライアントサイド予測
// src/game/prediction.ts
export class ClientPrediction {
private pendingInputs: Array<{
sequence: number;
y: number;
timestamp: number;
}> = [];
private inputSequence: number = 0;
// 入力を記録して送信
recordInput(paddleY: number): number {
const sequence = ++this.inputSequence;
this.pendingInputs.push({
sequence,
y: paddleY,
timestamp: Date.now(),
});
return sequence;
}
// サーバーからの確認を処理
processServerAck(lastProcessedSequence: number, serverPaddleY: number): number {
// 確認された入力を削除
this.pendingInputs = this.pendingInputs.filter(
input => input.sequence > lastProcessedSequence
);
// 未確認の入力を再適用
let predictedY = serverPaddleY;
for (const input of this.pendingInputs) {
predictedY = input.y;
}
return predictedY;
}
}
---
まとめ
本章で学んだこと:
- Canvas基礎: 描画コンテキスト、図形描画
- ゲームオブジェクト: パドル、ボール
- ゲームエンジン: ゲームループ、入力処理
- サーバーサイド: ルーム管理、物理演算
- WebSocket統合: リアルタイム同期
- 遅延補償: 補間、クライアントサイド予測
次章では、デプロイとセキュリティを学びます。