Spaces:
Running on Zero
Running on Zero
File size: 27,143 Bytes
c670567 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 | /**
* ============================================================================
* 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);
}
}
}
|