mafia / frontend /src /scenes /BaseChapterScene.ts
Alfaxad's picture
Migrate Mafia game to ZeroGPU Gradio Space
c670567 verified
Raw
History Blame Contribute Delete
27.1 kB
/**
* ============================================================================
* 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<string, any>;
// -- 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<string, number>;
/** 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<string, string>;
/** 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<string, ChapterCharacterConfig> = new Map();
protected activeCharacters: Map<string, CharacterPortrait> = 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<string, any>): 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);
}
}
}