/** * ============================================================================ * BASE CHAPTER SCENE - Foundation for narrative/dialogue-driven scenes * ============================================================================ * * This is the MOST IMPORTANT base class in the ui_heavy module. * It provides the complete lifecycle for dialogue-driven game chapters: * visual novels, galgames, interactive fiction, tutorial sequences, etc. * * ARCHITECTURE: State Machine + Protected Hooks with Default Implementations * * The base class owns the DIALOGUE STATE MACHINE (entry processing, advance, * choice resolution, branching). All UI RENDERING is done through PROTECTED * methods that have sensible defaults but can be fully overridden. * * LIFECYCLE (Template Method Pattern): * create() calls in order: * 1. createBackground() -- HOOK: set scene background * 2. createCharacters() -- HOOK: register characters * 3. createDialogueUI() -- HOOK (has default): create dialogue display * 4. createUI() -- HOOK: add scene-specific UI elements * 5. setupDefaultInputs() -- HOOK (has default): click/Enter/Space * 6. setupInputs() -- HOOK: bind custom key/click handlers * 7. playBackgroundMusic() -- uses getBackgroundMusicKey() hook * 8. initializeScene() -- HOOK: final setup before dialogue * 9. initializeDialogues() -- HOOK (REQUIRED): define dialogue content * 10. startDialogue() -- begins dialogue playback * * HOOKS WITH DEFAULTS (override to customize or replace): * - createDialogueUI(): Creates DialogueBox + ChoicePanel. Override to use * different UI components or skip entirely. * - setupDefaultInputs(): Binds click/Enter/Space to advance. Override to * change input scheme. * - showDialogueText(speaker, text, expression): Displays text using * DialogueBox. Override for speech bubbles, custom text displays, etc. * - showChoiceUI(prompt, options): Shows choices via ChoicePanel. Override * for custom choice presentation. * - handleCharacterEnter(id, config, position): Creates CharacterPortrait * and animates entry. Override for custom character display. * - handleCharacterExit(id): Animates portrait exit. Override to customize. * - getDialogueBoxConfig(): Returns DialogueBox config. Override to restyle. * * PURE HOOKS (no default, override as needed): * - initializeDialogues(): REQUIRED, define dialogue content * - createBackground(): Set scene background * - createCharacters(): Register character configs * - createUI(): Additional UI elements * - setupInputs(): Additional input bindings * - initializeScene(): Final setup * - getBackgroundMusicKey(): Audio key * - onDialogueEvent(action, data): React to events * - onChoiceMade(choiceId, option): React to choices * - onCharacterEnter(id, position): Called AFTER character enter * - onCharacterExit(id): Called AFTER character exit * - onChapterComplete(): All dialogues finished * - onUpdate(time, delta): Per-frame logic * * Usage: * export class Chapter1Scene extends BaseChapterScene { * constructor() { super({ key: 'Chapter1Scene' }); } * * protected initializeDialogues(): DialogueEntry[] { * return [ * { type: 'text', speaker: 'narrator', text: 'Once upon a time...' }, * { type: 'character', action: 'enter', characterId: 'hero', position: 'left' }, * { type: 'text', speaker: 'hero', text: 'Where am I?' }, * { type: 'choice', id: 'first_choice', prompt: 'What do you do?', options: [ * { text: 'Look around', effects: { curiosity: +1 } }, * { text: 'Call for help', effects: { courage: -1 } }, * ]}, * ]; * } * } * * SAFETY NOTES: * - All type/interface imports MUST use the "type" keyword: * import { type DialogueEntry } from './BaseChapterScene'; * - Config access: import directly from gameConfig.json: * import gameConfig from '../gameConfig.json'; * const dialogueConfig = gameConfig.dialogueConfig ?? {}; * dialogueConfig.textSpeed.value // use .value accessor * - Scene cleanup: use this.events.once('shutdown', cb), NOT override shutdown() * - Scene keys in scene.start('KEY') MUST match main.ts registration */ import Phaser from 'phaser'; import { DialogueBox, type DialogueBoxConfig } from '../ui/DialogueBox'; import { CharacterPortrait, type PortraitConfig, } from '../ui/CharacterPortrait'; import { ChoicePanel, type ChoiceDisplayOption } from '../ui/ChoicePanel'; // ============================================================================ // TYPES & INTERFACES // ============================================================================ /** A single dialogue entry in the sequence */ export interface DialogueEntry { /** Entry type */ type: 'text' | 'choice' | 'event' | 'character' | 'branch' | 'wait'; // -- For type: 'text' -- /** Speaker name (or 'narrator' for narration) */ speaker?: string; /** Dialogue text content */ text?: string; /** Speaker expression key (e.g., 'happy', 'angry') */ expression?: string; // -- For type: 'choice' -- /** Unique choice identifier */ id?: string; /** Choice prompt text */ prompt?: string; /** Available options */ options?: ChoiceOption[]; // -- For type: 'event' -- /** Event action name */ action?: string; /** Event payload data */ data?: Record; // -- For type: 'character' -- /** Character ID for enter/exit/expression changes */ characterId?: string; /** Screen position: 'left', 'center', 'right' */ position?: 'left' | 'center' | 'right'; // -- For type: 'branch' -- /** Condition function to evaluate which branch to take */ condition?: () => boolean; /** Dialogues if condition is true */ trueBranch?: DialogueEntry[]; /** Dialogues if condition is false */ falseBranch?: DialogueEntry[]; // -- For type: 'wait' -- /** Duration in ms */ duration?: number; } /** A single choice option */ export interface ChoiceOption { /** Display text */ text: string; /** Jump label or next dialogue index */ next?: string; /** Side effects to apply on selection */ effects?: Record; /** Condition for this option to be visible */ condition?: () => boolean; } /** Character display configuration */ export interface ChapterCharacterConfig { /** Character unique ID */ id: string; /** Default texture key */ textureKey: string; /** Display name shown in dialogue box */ displayName: string; /** Available expression texture keys */ expressions?: Record; /** Default screen position */ defaultPosition?: 'left' | 'center' | 'right'; } // ============================================================================ // BASE CLASS // ============================================================================ export abstract class BaseChapterScene extends Phaser.Scene { // -- Dialogue state (managed by base class state machine) -- protected dialogues: DialogueEntry[] = []; protected currentDialogueIndex: number = 0; protected isDialoguePlaying: boolean = false; protected isWaitingForInput: boolean = false; protected isChoiceActive: boolean = false; /** Guard: true while a delayed auto-advance is pending (character/wait entries). */ private isAutoAdvancing: boolean = false; // -- Character state -- protected characters: Map = new Map(); protected activeCharacters: Map = new Map(); // -- Default UI components (created by default hooks, subclass may replace) -- protected dialogueBox?: DialogueBox; protected choicePanel?: ChoicePanel; // -- 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.dialogues = []; this.currentDialogueIndex = 0; this.isDialoguePlaying = false; this.isWaitingForInput = false; this.isChoiceActive = false; this.isAutoAdvancing = false; this.activeCharacters.clear(); this.characters.clear(); this.dialogueBox = undefined; this.choicePanel = undefined; // Stop music before clearing the reference to prevent orphaned playback if (this.backgroundMusic?.isPlaying) { this.backgroundMusic.stop(); } this.backgroundMusic = undefined; this.createBackground(); this.createCharacters(); this.createDialogueUI(); this.createUI(); this.createHelpPanel(); this.setupDefaultInputs(); this.setupInputs(); this.playBackgroundMusic(); this.initializeScene(); // Get dialogue content from subclass this.dialogues = this.initializeDialogues(); this.startDialogue(); } update(time: number, delta: number): void { this.onUpdate(time, delta); } // ============================================================================ // HOOKS - REQUIRED (must override) // ============================================================================ /** * HOOK (REQUIRED): Define the dialogue content for this chapter. * Return an array of DialogueEntry objects that define the scene flow. */ protected abstract initializeDialogues(): DialogueEntry[]; // ============================================================================ // HOOKS WITH DEFAULT IMPLEMENTATION (override to customize or replace) // ============================================================================ /** * HOOK (has default): Create the dialogue UI components. * * DEFAULT: Creates a DialogueBox at the bottom of the screen and a * ChoicePanel at the center. Override this entirely to use different * UI components (e.g., speech bubbles, custom panels) or skip dialogue UI. * * If you override this, also override showDialogueText() and showChoiceUI() * to use your custom components. */ protected createDialogueUI(): void { const cam = this.cameras.main; const boxConfig = this.getDialogueBoxConfig(); // Create dialogue box this.dialogueBox = new DialogueBox(this, boxConfig); this.dialogueBox.setBoxVisible(false); // Listen for advance events from dialogue box this.dialogueBox.on('advance', () => { this.advanceDialogue(); }); // Create choice panel (centered above dialogue box) this.choicePanel = new ChoicePanel( this, cam.width / 2, cam.height / 2 - 30, ); this.choicePanel.setVisible(false); // Listen for choice selection this.choicePanel.on('selected', (index: number) => { this.resolveChoice(index); }); } /** * HOOK (has default): Set up default input bindings for dialogue advancement. * * DEFAULT: Click, Enter, and Space advance dialogue / complete typewriter. * Override to change the input scheme (e.g., swipe, custom keys). */ protected setupDefaultInputs(): void { // Click to advance / complete typewriter this.input.on('pointerdown', () => { if (this.isChoiceActive) return; if (this.isWaitingForInput) { this.handleDialogueInput(); } }); // Enter / Space to advance const enterKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.ENTER, ); const spaceKey = this.input.keyboard?.addKey( Phaser.Input.Keyboard.KeyCodes.SPACE, ); enterKey?.on('down', () => { if (this.isChoiceActive) return; if (this.isWaitingForInput) { this.handleDialogueInput(); } }); spaceKey?.on('down', () => { if (this.isChoiceActive) return; if (this.isWaitingForInput) { this.handleDialogueInput(); } }); } /** * HOOK (has default): Display a dialogue text entry. * * DEFAULT: Uses this.dialogueBox to show text with typewriter effect, * highlights the speaking character, dims others. * Override to display text differently (speech bubbles, subtitle bar, etc.) * * @param speaker - Speaker name or 'narrator' * @param text - Text content to display * @param expression - Optional expression key for the speaker */ protected showDialogueText( speaker: string, text: string, expression?: string, ): void { // Update character expression if provided if (speaker && expression) { this.setCharacterExpression(speaker, expression); } // Highlight the speaking character, dim others this.activeCharacters.forEach((portrait, id) => { portrait.setSpeakerActive(id === speaker); }); // Resolve speaker display name let speakerName = speaker; const charConfig = this.characters.get(speaker); if (charConfig) { speakerName = charConfig.displayName; } // Show text in dialogue box if (this.dialogueBox) { this.dialogueBox.showText(speakerName, text); } } /** * HOOK (has default): Display choice buttons for the player. * * DEFAULT: Uses this.choicePanel to show clickable buttons with prompt. * Override to display choices differently (radial menu, cards, etc.) * * When overriding, call this.resolveChoice(index) when the player selects. * * @param prompt - Choice prompt text * @param options - Available options (already filtered for visibility) */ protected showChoiceUI(prompt: string, options: ChoiceDisplayOption[]): void { if (this.dialogueBox) { this.dialogueBox.setBoxVisible(false); } if (this.choicePanel) { this.choicePanel.showChoices(prompt, options); } } /** * HOOK (has default): Handle a character entering the scene visually. * * DEFAULT: Creates a CharacterPortrait from the registered config, * animates it sliding in, and stores it in activeCharacters. * Override to display characters differently (sprites, 3D models, etc.) * * @param characterId - The character's registered ID * @param config - The character's registered config * @param position - Screen position for the character */ protected handleCharacterEnter( characterId: string, config: ChapterCharacterConfig, position: 'left' | 'center' | 'right', ): void { // Create portrait if not already active if (!this.activeCharacters.has(characterId)) { const portrait = new CharacterPortrait(this, { id: config.id, textureKey: config.textureKey, displayName: config.displayName, expressions: config.expressions, position: position, }); this.activeCharacters.set(characterId, portrait); } // Animate entry const portrait = this.activeCharacters.get(characterId)!; portrait.enter(); } /** * HOOK (has default): Handle a character exiting the scene visually. * * DEFAULT: Animates the CharacterPortrait sliding out and removes it. * Override for custom exit animations. * * @param characterId - The character's registered ID */ protected handleCharacterExit(characterId: string): void { const portrait = this.activeCharacters.get(characterId); if (portrait) { portrait.exit(() => { this.activeCharacters.delete(characterId); }); } } /** * HOOK (has default): Handle player input during dialogue (advance or complete). * * DEFAULT: Delegates to DialogueBox.handleInput() which either completes * the typewriter or emits 'advance'. * Override if you use a custom dialogue display component. */ protected handleDialogueInput(): void { if (this.dialogueBox) { this.dialogueBox.handleInput(); } } /** * HOOK (has default): Returns configuration for the default DialogueBox. * Override to change the dialogue box appearance without replacing the component. */ protected getDialogueBoxConfig(): DialogueBoxConfig { const cam = this.cameras.main; return { x: cam.width / 2, y: cam.height - 100, width: Math.min(900, cam.width - 40), height: 160, backgroundColor: 0x0a0a1e, backgroundAlpha: 0.9, typeSpeed: 30, padding: 20, }; } // ============================================================================ // PURE HOOKS (no default, override as needed) // ============================================================================ /** HOOK: Create the scene background. */ protected createBackground(): void {} /** HOOK: Register characters via registerCharacter(). */ protected createCharacters(): void {} /** HOOK: Create scene-specific UI elements beyond the dialogue system. */ protected createUI(): void {} /** HOOK: Add custom key bindings beyond the default ones. */ protected setupInputs(): void {} /** HOOK: Called after all setup, before dialogue starts. */ protected initializeScene(): 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 scene-specific hints. Return empty array to hide panel. */ protected getGameplayHints(): string[] { return ['Click or Enter: advance', 'Choose wisely!']; } /** HOOK: Called when a special event is encountered in the dialogue. */ protected onDialogueEvent(action: string, data?: Record): void {} /** HOOK: Called when the player selects a choice option. */ protected onChoiceMade(choiceId: string, option: ChoiceOption): void {} /** * HOOK: Called AFTER a character enters the scene (after visual setup). * Use for additional logic (sound effects, state changes, etc.) */ protected onCharacterEnter(characterId: string, position?: string): void {} /** HOOK: Called AFTER a character exits the scene. */ protected onCharacterExit(characterId: string): void {} /** HOOK: Called when all dialogues in this chapter are complete. */ protected onChapterComplete(): void {} /** HOOK: Called every frame. */ protected onUpdate(time: number, delta: number): void {} // ============================================================================ // PROTECTED UTILITIES (available to subclasses) // ============================================================================ /** Register a character for use in dialogues. */ protected registerCharacter(config: ChapterCharacterConfig): void { this.characters.set(config.id, config); } /** Start playing the dialogue sequence from the beginning or a specific index. */ protected startDialogue(fromIndex: number = 0): void { if (this.dialogues.length === 0) { this.isDialoguePlaying = false; this.onChapterComplete(); return; } this.currentDialogueIndex = fromIndex; this.isDialoguePlaying = true; this.processCurrentEntry(); } /** Advance to the next dialogue entry. */ protected advanceDialogue(): void { if (!this.isDialoguePlaying || this.isChoiceActive || this.isAutoAdvancing) return; this.isWaitingForInput = false; this.currentDialogueIndex++; if (this.currentDialogueIndex >= this.dialogues.length) { this.isDialoguePlaying = false; if (this.dialogueBox) { this.dialogueBox.setBoxVisible(false); } this.onChapterComplete(); return; } this.processCurrentEntry(); } /** Change a character's expression. */ protected setCharacterExpression( characterId: string, expression: string, ): void { const portrait = this.activeCharacters.get(characterId); if (portrait) { portrait.setExpression(expression); } } /** * Resolve a player's choice selection. Call this from your custom choice UI. * Handles effects, notifies subclass via onChoiceMade, and advances dialogue. */ protected resolveChoice(optionIndex: number): void { if (!this.isChoiceActive) return; const entry = this.dialogues[this.currentDialogueIndex]; if (!entry || entry.type !== 'choice' || !entry.options) return; // Map filtered index back to original options const visibleOptions = (entry.options ?? []).filter( (opt) => !opt.condition || opt.condition(), ); const option = visibleOptions[optionIndex]; if (!option) return; // Hide choice UI if (this.choicePanel) { this.choicePanel.hide(); } this.isChoiceActive = false; // Apply effects if (option.effects) { for (const [key, value] of Object.entries(option.effects)) { const current = this.registry.get(key) ?? 0; this.registry.set(key, current + value); } } // Notify subclass this.onChoiceMade(entry.id ?? '', option); // Re-show dialogue box for continuing if (this.dialogueBox) { this.dialogueBox.setBoxVisible(true); } // Advance dialogue this.advanceDialogue(); } /** Show floating text (e.g., "+10 Points"). */ protected showFloatingText( text: string, x: number, y: number, style?: any, ): void { const defaultStyle = { fontSize: '24px', color: '#FFD700', fontFamily: 'Arial', stroke: '#000000', strokeThickness: 3, ...style, }; const floatText = this.add.text(x, y, text, defaultStyle).setOrigin(0.5); floatText.setDepth(999); this.tweens.add({ targets: floatText, y: y - 60, alpha: 0, duration: 1200, ease: 'Cubic.easeOut', onComplete: () => floatText.destroy(), }); } // ============================================================================ // HELP PANEL // ============================================================================ /** * Create a semi-transparent gameplay hints panel in the top-right corner. * Uses getGameplayHints() hook for content. */ 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; const panelX = cam.width - panelWidth - 12; const panelY = 12; const bg = this.add.rectangle( panelX + panelWidth / 2, panelY + panelHeight / 2, panelWidth, panelHeight, 0x000000, 0.5, ); bg.setDepth(500); bg.setScrollFactor(0); 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); 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); }); } // ============================================================================ // STATE MACHINE (internal, drives the dialogue flow) // ============================================================================ /** Process the current dialogue entry based on its type. */ private processCurrentEntry(): void { if (this.currentDialogueIndex >= this.dialogues.length) return; const entry = this.dialogues[this.currentDialogueIndex]; switch (entry.type) { case 'text': this.isWaitingForInput = true; this.showDialogueText( entry.speaker ?? '', entry.text ?? '', entry.expression, ); break; case 'choice': { const options: ChoiceDisplayOption[] = (entry.options ?? []) .filter((opt) => !opt.condition || opt.condition()) .map((opt) => ({ text: opt.text, enabled: true })); // Guard: if all choices are filtered out, skip to prevent softlock if (options.length === 0) { this.advanceDialogue(); break; } this.isChoiceActive = true; this.showChoiceUI(entry.prompt ?? '', options); break; } case 'character': { const charId = entry.characterId ?? ''; const charConfig = this.characters.get(charId); if (entry.action === 'enter' && charConfig) { const position = entry.position ?? charConfig.defaultPosition ?? 'center'; this.handleCharacterEnter(charId, charConfig, position); this.onCharacterEnter(charId, position); } else if (entry.action === 'exit') { this.handleCharacterExit(charId); this.onCharacterExit(charId); } else if (entry.action === 'expression' && entry.expression) { this.setCharacterExpression(charId, entry.expression); } // Auto-advance past character actions. // Set guard flag to prevent manual advanceDialogue() from racing. this.isAutoAdvancing = true; this.time.delayedCall(entry.action === 'enter' ? 300 : 50, () => { this.isAutoAdvancing = false; this.advanceDialogue(); }); break; } case 'event': this.onDialogueEvent(entry.action ?? '', entry.data); this.advanceDialogue(); break; case 'wait': // Set guard flag to prevent manual advanceDialogue() from racing. this.isAutoAdvancing = true; this.time.delayedCall(entry.duration ?? 1000, () => { this.isAutoAdvancing = false; this.advanceDialogue(); }); break; case 'branch': { const result = entry.condition?.() ?? true; const branch = result ? entry.trueBranch : entry.falseBranch; if (branch && branch.length > 0) { this.dialogues.splice(this.currentDialogueIndex + 1, 0, ...branch); } this.advanceDialogue(); break; } default: this.advanceDialogue(); break; } } /** 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(`[BaseChapterScene] Could not play music: ${key}`, e); } } }