第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統合: リアルタイム同期
  • 遅延補償: 補間、クライアントサイド予測

次章では、デプロイとセキュリティを学びます。