/** * ============================================================================ * DUAL PLAYER SYSTEM - Two-player real-time interaction manager * ============================================================================ * * Manages two-player local gameplay: input splitting, per-player state, * buzzer/race mechanics, and score tracking. * * NOT a base class. This is a COMPOSABLE SYSTEM that a battle scene can * instantiate and use. It does NOT modify BaseBattleScene's turn flow. * Instead, the subclass uses BaseBattleScene's existing hooks (especially * executeEnemyTurn) to integrate Player 2's turn. * * SUPPORTED MODES: * 1. TURN_BASED: Players alternate turns. P1 plays, then P2, repeat. * Integration: Override executeEnemyTurn() to run P2's card/quiz turn. * 2. BUZZER_RACE: Both see the same question. First to buzz gets to answer. * Integration: In onQuizPhaseStart(), call dualSystem.startBuzzerRound(). * 3. SIMULTANEOUS: Both answer independently. Score by correctness + speed. * Integration: In onQuizPhaseStart(), call dualSystem.startSimultaneousRound(). * * EVENTS (via Phaser.Events.EventEmitter): * - 'playerBuzzed': (playerId: string) => void * - 'playerAnswered': (playerId: string, answerIndex: number, timeMs: number) => void * - 'roundResult': (result: RoundResult) => void * - 'scoreChanged': (playerId: string, newScore: number) => void * - 'gameOver': (winnerId: string) => void * * USAGE (in a BaseBattleScene subclass): * // In initializeBattle(): * this.dualSystem = new DualPlayerSystem(this, { * mode: 'BUZZER_RACE', * scoreToWin: 10, * player1: { id: 'P1', name: 'Player 1', color: 0x4488ff, * keys: { buzz: Phaser.Input.Keyboard.KeyCodes.Q, * answers: [ONE, TWO, THREE, FOUR] } }, * player2: { id: 'P2', name: 'Player 2', color: 0xff4444, * keys: { buzz: Phaser.Input.Keyboard.KeyCodes.P, * answers: [SEVEN, EIGHT, NINE, ZERO] } }, * }); * * // Listen for events (IMPORTANT: store buzzed player for later use): * this.dualSystem.on('playerBuzzed', (playerId) => { * this.lastBuzzedPlayerId = playerId; // MUST store for damage attribution * }); * this.dualSystem.on('roundResult', (result) => { updateScoreUI(result); }); * * // In onQuizPhaseStart() (BUZZER_RACE mode): * this.dualSystem.startBuzzerRound(this.currentQuestion!); * * // In executeEnemyTurn() (TURN_BASED mode): * this.dualSystem.startPlayer2Turn(); */ import Phaser from 'phaser'; import { type QuizQuestion } from '../scenes/BaseBattleScene'; // ============================================================================ // TYPES & INTERFACES // ============================================================================ /** Dual-player game mode */ export type DualPlayerMode = 'TURN_BASED' | 'BUZZER_RACE' | 'SIMULTANEOUS'; /** Key binding configuration for one player */ export interface PlayerKeyConfig { /** Buzzer key (used in BUZZER_RACE mode) */ buzz: number; /** Answer keys [option0, option1, option2, option3] (used in SIMULTANEOUS mode) */ answers: number[]; } /** Player configuration */ export interface DualPlayerConfig { /** Player identifier ('P1' or 'P2', or custom) */ id: string; /** Display name */ name: string; /** Player color (hex) for UI elements */ color: number; /** Key bindings */ keys: PlayerKeyConfig; } /** System configuration */ export interface DualPlayerSystemConfig { /** Game mode */ mode: DualPlayerMode; /** Score needed to win (0 = no win condition, scene handles it) */ scoreToWin?: number; /** Player 1 config */ player1: DualPlayerConfig; /** Player 2 config */ player2: DualPlayerConfig; /** Time limit per buzzer round in ms (0 = no limit) */ buzzerTimeLimit?: number; /** Points for correct answer */ correctPoints?: number; /** Points deducted for wrong answer */ wrongPenalty?: number; /** Bonus points for faster answer in SIMULTANEOUS mode */ speedBonus?: number; } /** Result of a single round */ export interface RoundResult { /** Round number */ round: number; /** Who won the round (null = tie or no winner) */ winnerId: string | null; /** Per-player details */ details: { playerId: string; answered: boolean; correct: boolean; timeMs: number; pointsEarned: number; }[]; } /** Per-player state */ interface PlayerState { config: DualPlayerConfig; score: number; correctCount: number; wrongCount: number; streak: number; bestStreak: number; buzzKey?: Phaser.Input.Keyboard.Key; answerKeys: Phaser.Input.Keyboard.Key[]; } // ============================================================================ // SYSTEM CLASS // ============================================================================ export class DualPlayerSystem extends Phaser.Events.EventEmitter { private scene: Phaser.Scene; private config: DualPlayerSystemConfig; private players: Map = new Map(); private roundNumber: number = 0; private isRoundActive: boolean = false; private currentQuestion?: QuizQuestion; // Buzzer state private buzzedPlayerId?: string; private roundStartTime: number = 0; private buzzerTimer?: Phaser.Time.TimerEvent; private answerTimer?: Phaser.Time.TimerEvent; // Simultaneous state private playerAnswers: Map = new Map(); constructor(scene: Phaser.Scene, config: DualPlayerSystemConfig) { super(); this.scene = scene; this.config = { scoreToWin: 0, buzzerTimeLimit: 0, correctPoints: 1, wrongPenalty: 0, speedBonus: 0, ...config, }; this.setupPlayers(); } // ============================================================================ // SETUP // ============================================================================ private setupPlayers(): void { [this.config.player1, this.config.player2].forEach((pc) => { const state: PlayerState = { config: pc, score: 0, correctCount: 0, wrongCount: 0, streak: 0, bestStreak: 0, answerKeys: [], }; // Register buzz key if (this.scene.input.keyboard) { state.buzzKey = this.scene.input.keyboard.addKey(pc.keys.buzz); state.answerKeys = pc.keys.answers.map((k) => this.scene.input.keyboard!.addKey(k), ); } this.players.set(pc.id, state); }); } // ============================================================================ // BUZZER RACE MODE // ============================================================================ /** * Start a buzzer race round. Both players see the question. * First to press their buzz key gets to answer. * Call this from onQuizPhaseStart() or similar hook. */ startBuzzerRound(question: QuizQuestion): void { this.roundNumber++; this.isRoundActive = true; this.buzzedPlayerId = undefined; this.currentQuestion = question; this.roundStartTime = Date.now(); // Listen for buzz keys this.players.forEach((state, id) => { state.buzzKey?.once('down', () => { if (this.isRoundActive && !this.buzzedPlayerId) { this.buzzedPlayerId = id; this.emit('playerBuzzed', id); this.waitForBuzzerAnswer(id); } }); }); // Optional time limit if (this.config.buzzerTimeLimit && this.config.buzzerTimeLimit > 0) { this.buzzerTimer = this.scene.time.delayedCall( this.config.buzzerTimeLimit, () => { if (this.isRoundActive && !this.buzzedPlayerId) { // Nobody buzzed - round expires this.isRoundActive = false; this.emit('roundResult', this.buildRoundResult(null, [])); } }, ); } } /** * After a player buzzes, listen for their answer key press. * Starts an answer timeout (uses buzzerTimeLimit) — if the buzzed player * doesn't answer in time, the round ends with a wrong-answer penalty. */ private waitForBuzzerAnswer(playerId: string): void { const state = this.players.get(playerId); if (!state || !this.currentQuestion) return; // Disable the other player's buzz key this.players.forEach((s, id) => { if (id !== playerId) { s.buzzKey?.removeAllListeners('down'); } }); // Cancel buzzer timer if (this.buzzerTimer) { this.buzzerTimer.remove(); this.buzzerTimer = undefined; } // Listen for answer keys state.answerKeys.forEach((key, answerIndex) => { key.once('down', () => { if (!this.isRoundActive) return; // Cancel answer timeout if (this.answerTimer) { this.answerTimer.remove(); this.answerTimer = undefined; } this.isRoundActive = false; const timeMs = Date.now() - this.roundStartTime; this.handleBuzzerAnswer(playerId, answerIndex, timeMs); }); }); // Answer timeout: if the buzzed player doesn't answer in time, treat as wrong if (this.config.buzzerTimeLimit && this.config.buzzerTimeLimit > 0) { this.answerTimer = this.scene.time.delayedCall( this.config.buzzerTimeLimit, () => { if (!this.isRoundActive) return; this.isRoundActive = false; this.cleanupRoundListeners(); // Penalize the buzzed player for not answering state.wrongCount++; state.streak = 0; const penalty = -(this.config.wrongPenalty ?? 0); state.score = Math.max(0, state.score + penalty); this.emit('scoreChanged', playerId, state.score); this.emit( 'roundResult', this.buildRoundResult(null, [ { playerId, answered: false, correct: false, timeMs: this.config.buzzerTimeLimit!, pointsEarned: penalty, }, ]), ); this.checkWinCondition(); }, ); } } private handleBuzzerAnswer( playerId: string, answerIndex: number, timeMs: number, ): void { if (!this.currentQuestion) return; const correct = answerIndex === this.currentQuestion.correctIndex; const state = this.players.get(playerId)!; // Clean up all listeners this.cleanupRoundListeners(); // Update stats let pointsEarned = 0; if (correct) { pointsEarned = this.config.correctPoints ?? 1; state.correctCount++; state.streak++; state.bestStreak = Math.max(state.bestStreak, state.streak); } else { pointsEarned = -(this.config.wrongPenalty ?? 0); state.wrongCount++; state.streak = 0; } state.score = Math.max(0, state.score + pointsEarned); this.emit('playerAnswered', playerId, answerIndex, timeMs); this.emit('scoreChanged', playerId, state.score); // Build result const result = this.buildRoundResult(correct ? playerId : null, [ { playerId, answered: true, correct, timeMs, pointsEarned, }, ]); this.emit('roundResult', result); // Check win condition this.checkWinCondition(); } // ============================================================================ // SIMULTANEOUS MODE // ============================================================================ /** * Start a simultaneous answer round. Both players answer independently. * Points awarded based on correctness. Faster correct answer gets bonus. * Call this from onQuizPhaseStart() or similar hook. */ startSimultaneousRound(question: QuizQuestion): void { this.roundNumber++; this.isRoundActive = true; this.currentQuestion = question; this.playerAnswers.clear(); this.roundStartTime = Date.now(); // Listen for both players' answer keys this.players.forEach((state, playerId) => { state.answerKeys.forEach((key, answerIndex) => { key.once('down', () => { if (!this.isRoundActive || this.playerAnswers.has(playerId)) return; const timeMs = Date.now() - this.roundStartTime; this.playerAnswers.set(playerId, { index: answerIndex, timeMs }); this.emit('playerAnswered', playerId, answerIndex, timeMs); // Check if both answered if (this.playerAnswers.size >= 2) { this.resolveSimultaneousRound(); } }); }); }); // Time limit if (this.config.buzzerTimeLimit && this.config.buzzerTimeLimit > 0) { this.buzzerTimer = this.scene.time.delayedCall( this.config.buzzerTimeLimit, () => { if (this.isRoundActive) { this.resolveSimultaneousRound(); } }, ); } } private resolveSimultaneousRound(): void { if (!this.isRoundActive || !this.currentQuestion) return; this.isRoundActive = false; if (this.buzzerTimer) { this.buzzerTimer.remove(); this.buzzerTimer = undefined; } this.cleanupRoundListeners(); const details: RoundResult['details'] = []; let winnerId: string | null = null; let bestTime = Infinity; this.players.forEach((state, playerId) => { const answer = this.playerAnswers.get(playerId); const answered = !!answer; const correct = answered && answer!.index === this.currentQuestion!.correctIndex; const timeMs = answer?.timeMs ?? 0; let pointsEarned = 0; if (answered) { if (correct) { pointsEarned = this.config.correctPoints ?? 1; state.correctCount++; state.streak++; state.bestStreak = Math.max(state.bestStreak, state.streak); } else { pointsEarned = -(this.config.wrongPenalty ?? 0); state.wrongCount++; state.streak = 0; } } // Track fastest correct answer for speed bonus if (correct && timeMs < bestTime) { bestTime = timeMs; winnerId = playerId; } state.score = Math.max(0, state.score + pointsEarned); this.emit('scoreChanged', playerId, state.score); details.push({ playerId, answered, correct, timeMs, pointsEarned }); }); // Speed bonus for fastest correct if (winnerId && (this.config.speedBonus ?? 0) > 0) { const winnerState = this.players.get(winnerId)!; winnerState.score += this.config.speedBonus!; this.emit('scoreChanged', winnerId, winnerState.score); // Update detail const winnerDetail = details.find((d) => d.playerId === winnerId); if (winnerDetail) winnerDetail.pointsEarned += this.config.speedBonus!; } const result = this.buildRoundResult(winnerId, details); this.emit('roundResult', result); this.checkWinCondition(); } // ============================================================================ // TURN-BASED MODE HELPERS // ============================================================================ /** * Signal that Player 2's turn is starting (TURN_BASED mode). * The battle scene's executeEnemyTurn() override should call this, * then present a card/quiz UI to Player 2. * The scene handles the actual UI; this just tracks state. */ startPlayer2Turn(): void { this.roundNumber++; } /** * Record Player 2's quiz answer in TURN_BASED mode. * Call from the scene's quiz answer handler when it's P2's turn. */ recordTurnAnswer(playerId: string, correct: boolean): void { const state = this.players.get(playerId); if (!state) return; if (correct) { state.correctCount++; state.streak++; state.bestStreak = Math.max(state.bestStreak, state.streak); state.score += this.config.correctPoints ?? 1; } else { state.wrongCount++; state.streak = 0; state.score = Math.max(0, state.score - (this.config.wrongPenalty ?? 0)); } this.emit('scoreChanged', playerId, state.score); } // ============================================================================ // QUERY // ============================================================================ /** Get a player's current score. */ getScore(playerId: string): number { return this.players.get(playerId)?.score ?? 0; } /** Get a player's config. */ getPlayerConfig(playerId: string): DualPlayerConfig | undefined { return this.players.get(playerId)?.config; } /** Get all player IDs. */ getPlayerIds(): string[] { return [...this.players.keys()]; } /** Get current round number. */ getRound(): number { return this.roundNumber; } /** Get player stats. */ getPlayerStats(playerId: string): | { score: number; correct: number; wrong: number; streak: number; bestStreak: number; } | undefined { const s = this.players.get(playerId); if (!s) return undefined; return { score: s.score, correct: s.correctCount, wrong: s.wrongCount, streak: s.streak, bestStreak: s.bestStreak, }; } /** Determine the winner (by score). Returns null if tied. */ getWinner(): DualPlayerConfig | null { const ids = this.getPlayerIds(); if (ids.length < 2) return null; const s1 = this.players.get(ids[0])!; const s2 = this.players.get(ids[1])!; if (s1.score > s2.score) return s1.config; if (s2.score > s1.score) return s2.config; return null; } /** Get the game mode. */ getMode(): DualPlayerMode { return this.config.mode; } /** Check if a round is currently active. */ isActive(): boolean { return this.isRoundActive; } // ============================================================================ // RESET // ============================================================================ /** Reset all scores and stats. */ reset(): void { this.players.forEach((state) => { state.score = 0; state.correctCount = 0; state.wrongCount = 0; state.streak = 0; state.bestStreak = 0; }); this.roundNumber = 0; this.isRoundActive = false; this.buzzedPlayerId = undefined; this.currentQuestion = undefined; this.playerAnswers.clear(); } /** Clean up keyboard listeners and timers. Call when scene shuts down. */ destroy(): void { this.cleanupRoundListeners(); if (this.buzzerTimer) { this.buzzerTimer.remove(); this.buzzerTimer = undefined; } if (this.answerTimer) { this.answerTimer.remove(); this.answerTimer = undefined; } this.removeAllListeners(); super.destroy(); } // ============================================================================ // INTERNAL // ============================================================================ private cleanupRoundListeners(): void { this.players.forEach((state) => { state.buzzKey?.removeAllListeners('down'); state.answerKeys.forEach((k) => k.removeAllListeners('down')); }); } private buildRoundResult( winnerId: string | null, details: RoundResult['details'], ): RoundResult { return { round: this.roundNumber, winnerId, details, }; } private checkWinCondition(): void { if (!this.config.scoreToWin || this.config.scoreToWin <= 0) return; // Find the first player who reached the win threshold. // Only emit one 'gameOver' event (the highest scorer wins if both qualify). let winnerId: string | null = null; let highestScore = -1; this.players.forEach((state, id) => { if ( state.score >= this.config.scoreToWin! && state.score > highestScore ) { highestScore = state.score; winnerId = id; } }); if (winnerId) { this.emit('gameOver', winnerId); } } }