/** * ============================================================================ * BASE BATTLE SCENE - Foundation for turn-based combat & quiz scenes * ============================================================================ * * Provides the complete lifecycle for turn-based battle games: * card battlers, quiz battles, educational games, auto-battlers, etc. * * TURN CYCLE (State Machine): * INTRO -> PLAYER_TURN -> [QUIZ_PHASE] -> FEEDBACK_PHASE -> * ACTION_PHASE -> ENEMY_TURN -> CHECK_END -> (loop or END) * * ARCHITECTURE: State Machine + Protected Hooks with Default Implementations * * The base class owns the TURN STATE MACHINE (phase transitions, win/loss * checks, combo tracking). All UI and game-specific logic is done through * PROTECTED methods that can be fully overridden. * * HOOKS WITH DEFAULTS (override to customize or replace): * - prepareHand(): Discard all, draw fresh. Override for keep-hand games. * - executeEnemyTurn(): Auto-attack after delay. Override for interactive * enemy turns (e.g., defense quiz). Must call completeEnemyTurn(). * - resolveCardAction(card, success): Apply card effects. Override for * custom card types or damage formulas. * - showFloatingText(): Popup text utility. * * DEFAULT BEHAVIOR (provided by base class): * - prepareHand() discards old hand, draws handSize cards at turn start * - showFloatingText() for damage/status popups * - Combo multiplier tracking * - Deck management with auto-reshuffle * - Phase state machine with hook dispatch * * HOOK METHODS (override in subclass): * -- Setup -- * - initializeBattle(): Set up battle state (REQUIRED) * - createBackground(): Set scene background * - createCombatants(): Create player/enemy visual displays * - createHUD(): Create health bars, combo display * - createHandArea(): Set up the card hand UI region * - setupInputs(): Custom key bindings * - getBackgroundMusicKey(): Return music audio key * * -- Turn Flow -- * - onBattleStart(): Called when battle begins * - onTurnStart(turnNumber): New turn begins * - onPlayerTurnStart(): Player's turn to act * - onCardSelected(card): Player chose a card * - onQuizPresented(question): Quiz modal shown * - onQuizAnswered(correct, question, answer): Quiz result * - onFeedbackShown(question, correct): Educational feedback displayed * - onActionExecuted(card, damage, combo): Card effect applied * - onEnemyTurnStart(): Enemy's turn * - onEnemyAction(): Define enemy AI behavior (REQUIRED for battles) * - onTurnEnd(turnNumber): Turn completed * - onBattleEnd(victory): Battle concluded * * -- Combat Events -- * - onPlayerDamaged(damage): Player took damage * - onEnemyDamaged(damage): Enemy took damage * - onComboChanged(newCombo, multiplier): Combo streak changed * - onPlayerDefeated(): Player HP reached 0 * - onEnemyDefeated(): Enemy HP reached 0 * * -- Config -- * - getCardDeck(): Define available cards * - getQuestionBank(): Define quiz questions (or load from JSON) * - getEnemyConfig(): Define enemy stats and behavior * * Usage: * export class DuelScene extends BaseBattleScene { * constructor() { super({ key: 'DuelScene' }); } * * protected initializeBattle(): void { * this.playerHP = 100; * this.enemyHP = 80; * } * * protected getCardDeck(): CardConfig[] { * return [ * { id: 'fireball', name: 'Fireball', type: 'attack', value: 25 }, * { id: 'shield', name: 'Shield', type: 'defend', value: 15 }, * { id: 'heal', name: 'Heal', type: 'heal', value: 20 }, * ]; * } * } * * !! COMMON MISTAKES TO AVOID !! * * 1. CONFIG ACCESS: * This class does NOT have a "this.gameConfig" property. * To read gameConfig.json, import it directly at the top of YOUR file: * import gameConfig from '../gameConfig.json'; * const battleConfig = gameConfig.battleConfig ?? {}; * Then access via .value: battleConfig.playerMaxHP.value (use .value accessor) * NEVER write: this.gameConfig.getValue('...') <-- DOES NOT EXIST * * 2. FLOATING TEXT: * FloatingText is a STATIC utility. Use the inherited helper: * this.showFloatingText('text', x, y, { color: '#ff0000' }); * NEVER write: new FloatingText(...) <-- NOT a constructor * * 3. TWEEN PRESETS: * TweenPresets exports individual functions, NOT a class: * import { fadeIn, shake } from '../ui/TweenPresets'; * NEVER write: import { TweenPresets } from '../ui/TweenPresets'; * * 4. VISIBILITY RULES: * When overriding base class methods, ALWAYS use "protected override". * NEVER use "private" to shadow a base-class protected method. * NEVER re-declare dealDamageToPlayer/dealDamageToEnemy as private. * * 5. SCENE CLEANUP: * Phaser.Scene does NOT have an overridable shutdown() method. * Use event listeners instead: * this.events.once('shutdown', () => { cleanup code }); * this.events.once('destroy', () => { cleanup code }); * * 6. TYPE IMPORTS: * All interfaces/types MUST use the "type" keyword when imported: * import { type CardConfig, type QuizQuestion } from './BaseBattleScene'; * This prevents runtime module resolution errors in Vite/esbuild. */ import Phaser from 'phaser'; import { FloatingText, type FloatingTextConfig } from '../ui/FloatingText'; // ============================================================================ // TYPES & INTERFACES // ============================================================================ /** Turn phase identifiers */ export type BattlePhase = | 'INTRO' | 'PLAYER_TURN' | 'QUIZ_PHASE' | 'FEEDBACK_PHASE' | 'ACTION_PHASE' | 'ENEMY_TURN' | 'CHECK_END' | 'VICTORY' | 'DEFEAT'; /** Card type categories */ export type CardType = | 'attack' | 'heavy_attack' | 'defend' | 'heal' | 'special'; /** Card configuration */ export interface CardConfig { /** Unique card identifier */ id: string; /** Display name */ name: string; /** Card type */ type: CardType; /** Effect value (damage, heal amount, shield points) */ value: number; /** Card description text */ description?: string; /** Texture key for card art */ textureKey?: string; /** Quiz subject tag (e.g., 'math', 'science') */ quizSubject?: string; /** Cost to play (optional for resource systems) */ cost?: number; } /** Quiz question format */ export interface QuizQuestion { /** Question text */ question: string; /** Answer options */ options: string[]; /** Index of correct answer (0-based) */ correctIndex: number; /** Educational explanation shown after answering */ explanation: string; /** Difficulty (1-5) */ difficulty?: number; /** Subject category */ subject?: string; } /** Enemy configuration */ export interface EnemyBattleConfig { /** Display name */ name: string; /** Max HP */ maxHP: number; /** Texture key */ textureKey: string; /** Damage range [min, max] */ damageRange: [number, number]; /** Available actions (for AI) */ actions?: string[]; } // ============================================================================ // BASE CLASS // ============================================================================ export abstract class BaseBattleScene extends Phaser.Scene { // -- Battle state -- protected currentPhase: BattlePhase = 'INTRO'; protected turnNumber: number = 0; protected isBattleActive: boolean = false; // -- Combatant state -- protected playerHP: number = 100; protected playerMaxHP: number = 100; protected enemyHP: number = 100; protected enemyMaxHP: number = 100; protected playerShield: number = 0; // -- Combo state -- protected comboStreak: number = 0; protected comboMultiplier: number = 1.0; // -- Card state -- protected deck: CardConfig[] = []; protected hand: CardConfig[] = []; protected discardPile: CardConfig[] = []; protected handSize: number = 3; // -- Question bank -- protected questionBank: QuizQuestion[] = []; // -- Current turn state -- protected selectedCard?: CardConfig; protected currentQuestion?: QuizQuestion; // -- Audio -- protected backgroundMusic?: Phaser.Sound.BaseSound; // ============================================================================ // LIFECYCLE (Template Method Pattern) // ============================================================================ create(): void { // Reset mutable state (Phaser reuses scene instances on scene.start, // so constructor field initializers do NOT run again) this.turnNumber = 0; this.currentPhase = 'INTRO'; this.isBattleActive = false; this.playerHP = 100; this.playerMaxHP = 100; this.enemyHP = 100; this.enemyMaxHP = 100; this.playerShield = 0; this.comboStreak = 0; this.comboMultiplier = 1; this.deck = []; this.hand = []; this.discardPile = []; this.handSize = 3; this.questionBank = []; this.selectedCard = undefined; this.currentQuestion = undefined; // Stop music before clearing the reference to prevent orphaned playback if (this.backgroundMusic?.isPlaying) { this.backgroundMusic.stop(); } this.backgroundMusic = undefined; // initializeBattle MUST run before createHUD so HP values are // correct when status bars are created (maxValue, initialValue). this.initializeBattle(); this.createBackground(); this.createCombatants(); this.createHUD(); this.createHelpPanel(); this.createHandArea(); this.setupInputs(); this.playBackgroundMusic(); this.startBattle(); } update(time: number, delta: number): void { this.onUpdate(time, delta); } // ============================================================================ // HOOKS - Setup // ============================================================================ /** HOOK (REQUIRED): Initialize battle state. */ protected abstract initializeBattle(): void; /** HOOK: Create the scene background. */ protected createBackground(): void {} /** HOOK: Create player and enemy visual displays. */ protected createCombatants(): void {} /** HOOK: Create the HUD (health bars, combo display). */ protected createHUD(): void {} /** HOOK: Create the card hand display area. */ protected createHandArea(): void {} /** HOOK: Set up custom input bindings. */ protected setupInputs(): void {} /** HOOK: Return the audio key for background music. */ protected getBackgroundMusicKey(): string | undefined { return undefined; } /** * HOOK: Return gameplay hint lines to display in the top-right corner. * Override to provide game-specific hints explaining card effects, controls, etc. * Return an empty array to hide the help panel. * * Example: * return [ * 'Attack: Deal damage', * 'Defend: Add shield', * 'Heal: Restore HP', * 'Correct = Full effect', * 'Wrong = 30% effect', * ]; */ protected getGameplayHints(): string[] { return [ 'Select a card to use', 'Answer the quiz question', 'Correct = full effect', 'Wrong = reduced effect', ]; } // ============================================================================ // HOOKS - Turn Flow // ============================================================================ /** HOOK: Called when battle begins (after intro). */ protected onBattleStart(): void {} /** HOOK: Called at the start of each turn. */ protected onTurnStart(turnNumber: number): void {} /** * HOOK: Player's turn begins. Cards are drawn and interactable. * Default implementation draws cards. Override to add custom behavior. */ protected onPlayerTurnStart(): void {} /** HOOK: A card was drawn from deck into hand. */ protected onCardDrawn(card: CardConfig): void {} /** HOOK: Quiz phase begins (after card selection). */ protected onQuizPhaseStart(): void {} /** HOOK: Resolve phase (apply card effect, then enemy turn). */ protected onResolvePhase(): void {} /** HOOK: Player selected a card from hand. */ protected onCardSelected(card: CardConfig): void {} /** HOOK: A quiz question is presented to the player. */ protected onQuizPresented(question: QuizQuestion): void {} /** HOOK: Player answered the quiz question. */ protected onQuizAnswered( correct: boolean, question: QuizQuestion, selectedIndex: number, ): void {} /** HOOK: Educational feedback is shown after quiz. */ protected onFeedbackShown(question: QuizQuestion, correct: boolean): void {} /** HOOK: The selected card's action is executed. */ protected onActionExecuted( card: CardConfig, finalDamage: number, combo: number, ): void {} /** HOOK: Enemy's turn begins (visual indicators, animations). */ protected onEnemyTurnStart(): void {} /** * HOOK (has default): Execute the full enemy turn sequence. * * DEFAULT: Waits 500ms, calls onEnemyAction(), then auto-progresses * to turn end via completeEnemyTurn(). * * Override for enemy turns that require player interaction (e.g., a * defense quiz where the player must answer correctly to block damage). * When overriding, you MUST call completeEnemyTurn() when your custom * enemy turn logic finishes. */ protected executeEnemyTurn(): void { this.time.delayedCall(500, () => { this.onEnemyAction(); this.completeEnemyTurn(); }); } /** HOOK: Define enemy AI behavior (called by default executeEnemyTurn). */ protected onEnemyAction(): void {} /** HOOK: Called at the end of each turn. */ protected onTurnEnd(turnNumber: number): void {} /** HOOK: Called when battle concludes. */ protected onBattleEnd(victory: boolean): void {} // ============================================================================ // HOOKS - Combat Events // ============================================================================ /** HOOK: Player took damage. */ protected onPlayerDamaged(damage: number, remainingHP: number): void {} /** HOOK: Enemy took damage. */ protected onEnemyDamaged(damage: number, remainingHP: number): void {} /** HOOK: Combo streak changed. */ protected onComboChanged(newCombo: number, multiplier: number): void {} /** HOOK: Player HP reached 0. */ protected onPlayerDefeated(): void { this.setPhase('DEFEAT'); this.onBattleEnd(false); } /** HOOK: Enemy HP reached 0. */ protected onEnemyDefeated(): void { this.setPhase('VICTORY'); this.onBattleEnd(true); } // ============================================================================ // HOOKS - Config // ============================================================================ /** HOOK: Define available cards for the deck. */ protected getCardDeck(): CardConfig[] { return []; } /** HOOK: Define quiz questions. */ protected getQuestionBank(): QuizQuestion[] { return []; } /** HOOK: Define enemy configuration. */ protected getEnemyConfig(): EnemyBattleConfig | undefined { return undefined; } // ============================================================================ // HOOKS - Per-frame // ============================================================================ /** HOOK: Custom per-frame logic. */ protected onUpdate(time: number, delta: number): void {} // ============================================================================ // HELP PANEL // ============================================================================ /** * Create a semi-transparent gameplay hints panel in the top-right corner. * Uses getGameplayHints() hook for content. Subclasses override hints, not this. */ private createHelpPanel(): void { const hints = this.getGameplayHints(); if (!hints || hints.length === 0) return; const cam = this.cameras.main; const padding = 10; const lineHeight = 18; const panelWidth = 180; const panelHeight = padding * 2 + hints.length * lineHeight + 4; // Panel background const panelX = cam.width - panelWidth - 12; const panelY = 74; const bg = this.add.rectangle( panelX + panelWidth / 2, panelY + panelHeight / 2, panelWidth, panelHeight, 0x000000, 0.5, ); bg.setDepth(500); bg.setScrollFactor(0); // Rounded corners via stroke const border = this.add.rectangle( panelX + panelWidth / 2, panelY + panelHeight / 2, panelWidth, panelHeight, ); border.setStrokeStyle(1, 0x666666, 0.6); border.setFillStyle(0x000000, 0); border.setDepth(500); border.setScrollFactor(0); // Hint lines hints.forEach((line, i) => { const text = this.add.text( panelX + padding, panelY + padding + i * lineHeight, line, { fontSize: '12px', color: '#cccccc', fontFamily: 'Arial', }, ); text.setDepth(501); text.setScrollFactor(0); }); } // ============================================================================ // PROTECTED UTILITIES (available to subclasses) // ============================================================================ /** * Complete the enemy turn and progress to the next turn. * Call this after your custom enemy turn logic finishes * (e.g., after a defense quiz is answered). */ protected completeEnemyTurn(): void { if (!this.isBattleActive) return; // CRITICAL: Check if battle ended from enemy turn damage (e.g., defense quiz). // Without this, the game freezes if player HP reaches 0 during enemy turn. if (this.checkBattleEnd()) return; this.time.delayedCall(800, () => { this.onTurnEnd(this.turnNumber); if (!this.checkBattleEnd()) { this.beginNewTurn(); } }); } /** * Start the battle. Override startBattle() or useTurnCycle for custom flow. * * For PVP / buzzer-race games that do NOT use the turn cycle: * protected override get useTurnCycle(): boolean { return false; } * Then drive your own round system (e.g., this.startNextRound()). */ protected startBattle(): void { this.isBattleActive = true; this.deck = this.shuffleArray([...this.getCardDeck()]); this.questionBank = [...this.getQuestionBank()]; this.onBattleStart(); if (this.useTurnCycle) { this.beginNewTurn(); } } /** * Whether the base class should automatically run the turn-based phase cycle * (PLAYER_TURN → QUIZ → ENEMY_TURN → CHECK_END → loop). * * Override to `false` for real-time or round-based PVP games where you * manage the game flow yourself (e.g., buzzer race, simultaneous answer). * * Default: true (standard turn-based flow). */ protected get useTurnCycle(): boolean { return true; } /** Begin a new turn cycle. */ protected beginNewTurn(): void { if (!this.isBattleActive) return; this.turnNumber++; this.selectedCard = undefined; this.currentQuestion = undefined; this.onTurnStart(this.turnNumber); // Prepare hand for new turn (default: discard all, draw fresh) this.prepareHand(); this.setPhase('PLAYER_TURN'); } /** * HOOK (has default): Prepare the player's hand for a new turn. * * DEFAULT: Discards entire hand to discard pile, then draws handSize cards. * Override for games that keep cards between turns, allow selective discard, * or have a different hand management system. */ protected prepareHand(): void { this.hand.forEach((card) => this.discardPile.push(card)); this.hand = []; this.drawCards(this.handSize); } /** Set the current battle phase and dispatch to phase hooks. */ protected setPhase(phase: BattlePhase): void { this.currentPhase = phase; switch (phase) { case 'PLAYER_TURN': this.onPlayerTurnStart(); break; case 'QUIZ_PHASE': this.onQuizPhaseStart(); break; case 'ENEMY_TURN': this.onEnemyTurnStart(); this.executeEnemyTurn(); break; case 'ACTION_PHASE': this.onResolvePhase(); break; case 'VICTORY': case 'DEFEAT': this.isBattleActive = false; break; default: break; } } /** * Handle a card being selected by the player. * This is the main entry point for the card->quiz->action flow. * Call this from your card UI's 'selected' event handler. */ protected handleCardPlayed(card: CardConfig): void { if (this.currentPhase !== 'PLAYER_TURN') return; this.selectedCard = card; this.discardCard(card); this.onCardSelected(card); // If we have quiz questions, proceed to quiz phase if (this.questionBank.length > 0) { this.currentQuestion = this.getRandomQuestion(); this.setPhase('QUIZ_PHASE'); } else { // No quiz - directly resolve the card action this.resolveCardAction(card, true); } } /** * Handle quiz answer result. Call this from your QuizModal's 'answered' event. * * Guarded against double-fire: clears selectedCard immediately so a * second call (e.g., from a race condition in the QuizModal) is a no-op. */ protected handleQuizAnswered(correct: boolean, selectedIndex: number): void { if (!this.currentQuestion || !this.selectedCard) return; // Capture and clear immediately to guard against double-fire const card = this.selectedCard; this.selectedCard = undefined; this.updateCombo(correct); this.onQuizAnswered(correct, this.currentQuestion, selectedIndex); // Resolve the card action based on quiz result this.time.delayedCall(500, () => { this.resolveCardAction(card, correct); }); } /** * HOOK (has default): Resolve a card's effect (damage, heal, shield, etc.) * * DEFAULT: Handles 'attack', 'heavy_attack', 'defend', 'heal', 'special' * card types with basic math. Override to add custom card types, different * damage formulas, or game-specific mechanics. * * After resolution, automatically proceeds to enemy turn. * If you override, remember to call setPhase('ENEMY_TURN') when ready. */ protected resolveCardAction(card: CardConfig, successful: boolean): void { const baseDamage = successful ? card.value : Math.floor(card.value * 0.3); // Track the actual effect applied (combo only applies to attacks via dealDamageToEnemy) let actualEffect = baseDamage; switch (card.type) { case 'attack': case 'heavy_attack': this.dealDamageToEnemy(baseDamage); // dealDamageToEnemy applies combo multiplier internally actualEffect = Math.floor(baseDamage * this.comboMultiplier); break; case 'defend': // Correct: full shield. Wrong: reduced shield (30%). this.addPlayerShield(baseDamage); actualEffect = baseDamage; break; case 'heal': // Correct: full heal. Wrong: reduced heal (30%). this.healPlayer(baseDamage); actualEffect = baseDamage; break; case 'special': // Subclass handles special cards via onActionExecuted actualEffect = baseDamage; break; } this.onActionExecuted(card, actualEffect, this.comboStreak); // Proceed to enemy turn this.time.delayedCall(800, () => { if (!this.checkBattleEnd()) { this.setPhase('ENEMY_TURN'); } }); } /** * Deal damage to player (used by enemy actions). * VISIBILITY: protected -- subclasses can call but MUST NOT re-declare as private. * If you need custom damage logic, override this method with "protected override". */ protected dealDamageToPlayer(damage: number): void { const actualDamage = Math.max(0, damage - this.playerShield); this.playerHP = Math.max(0, this.playerHP - actualDamage); this.playerShield = Math.max(0, this.playerShield - damage); this.onPlayerDamaged(actualDamage, this.playerHP); } /** * Deal damage to enemy (used by card actions). * VISIBILITY: protected -- subclasses can call but MUST NOT re-declare as private. * If you need custom damage logic, override this method with "protected override". */ protected dealDamageToEnemy(damage: number): void { const finalDamage = Math.floor(damage * this.comboMultiplier); this.enemyHP = Math.max(0, this.enemyHP - finalDamage); this.onEnemyDamaged(finalDamage, this.enemyHP); } /** Heal the player. */ protected healPlayer(amount: number): void { this.playerHP = Math.min(this.playerMaxHP, this.playerHP + amount); } /** Add shield to player. */ protected addPlayerShield(amount: number): void { this.playerShield += amount; } /** Update combo streak. */ protected updateCombo(correct: boolean): void { if (correct) { this.comboStreak++; } else { this.comboStreak = 0; } const tiers = [1.0, 1.2, 1.5, 2.0]; this.comboMultiplier = tiers[Math.min(this.comboStreak, tiers.length - 1)]; this.onComboChanged(this.comboStreak, this.comboMultiplier); } /** Draw cards from deck into hand. */ protected drawCards(count: number): void { for (let i = 0; i < count; i++) { if (this.deck.length === 0) { this.deck = this.shuffleArray([...this.discardPile]); this.discardPile = []; } if (this.deck.length > 0) { const card = this.deck.pop()!; this.hand.push(card); this.onCardDrawn(card); } } } /** Discard a card from hand. */ protected discardCard(card: CardConfig): void { const idx = this.hand.indexOf(card); if (idx >= 0) { this.hand.splice(idx, 1); this.discardPile.push(card); } } /** Check if battle should end. Returns true if battle is over. */ protected checkBattleEnd(): boolean { // Already ended? Don't re-trigger. if (!this.isBattleActive) return true; if (this.currentPhase === 'VICTORY' || this.currentPhase === 'DEFEAT') return true; if (this.playerHP <= 0) { this.onPlayerDefeated(); return true; } if (this.enemyHP <= 0) { this.onEnemyDefeated(); return true; } return false; } /** * Get a random question from the bank WITHOUT removing it. * The same question may be returned again in future calls. * Use popRandomQuestion() instead for no-repeat behavior. */ protected getRandomQuestion(): QuizQuestion { const idx = Math.floor(Math.random() * this.questionBank.length); return this.questionBank[idx]; } /** * Pick a random question and REMOVE it from the bank (no repeats). * When the bank is exhausted, it auto-refills from getQuestionBank(). * Prefer this over getRandomQuestion() for better player experience. */ protected popRandomQuestion(): QuizQuestion { if (this.questionBank.length === 0) { this.questionBank = [...this.getQuestionBank()]; } const idx = Math.floor(Math.random() * this.questionBank.length); return this.questionBank.splice(idx, 1)[0]; } /** * Show floating text at a position (for damage numbers, status, etc.) * Uses the FloatingText STATIC utility component. * * Subclasses: just call this.showFloatingText(). Do NOT create a private * showFloatingText method or instantiate FloatingText via "new". */ protected showFloatingText( text: string, x: number, y: number, config?: FloatingTextConfig, ): void { FloatingText.show(this, x, y, text, config); } // ============================================================================ // PRIVATE UTILITIES // ============================================================================ /** Fisher-Yates shuffle. */ private shuffleArray(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** Play background music with fade-in. */ private playBackgroundMusic(): void { const key = this.getBackgroundMusicKey(); if (!key) return; try { if (this.sound.get(key)) { this.backgroundMusic = this.sound.get(key); } else { this.backgroundMusic = this.sound.add(key, { loop: true, volume: 0 }); } this.backgroundMusic?.play(); this.tweens.add({ targets: this.backgroundMusic, volume: 0.5, duration: 1000, }); } catch (e) { console.warn(`[BaseBattleScene] Could not play music: ${key}`, e); } } }