import { IStorySceneConfig, IStoryPageConfig } from '../data/Interfaces'; import { StorageMgr, globalStorageMgr } from '../common/StorageMgr'; import { STORAGE_KEY } from '../common/Constants'; /** * Story-intro cutscene controller (task 9.1, req 19.1 — 19.9). * * Responsibilities: * 1. Decide whether the intro must play (first-time gate, req 19.5). * 2. Drive the 3-page typewriter sequence (req 19.2-19.3). * 3. Honour taps (speed up printing) and "Skip" (immediate dismiss, req 19.4). * 4. Persist the "seen" flag so it plays only once (req 19.5). * 5. Provide a `reset()` API the Settings menu calls (req 19.6). * * The view layer binds `onTextChanged` / `onFinished` to render text and * trigger the next scene load. */ export type StoryPhase = 'idle' | 'typing' | 'waiting_next' | 'finished'; export interface IStorySceneCallbacks { onTextChanged?: (text: string, page: IStoryPageConfig) => void; onPageEntered?: (page: IStoryPageConfig) => void; onFinished?: (skipped: boolean) => void; } /** * How many characters per real-time second a page types out. Boosts to * `FAST_MULTIPLIER` while the user is tapping (req 19.3). */ export const BASE_TYPING_CPS = 30; export const FAST_MULTIPLIER = 4; export class StorySceneCtrl { private phase: StoryPhase = 'idle'; private pageIndex = 0; private cursor = 0; private elapsedSecOnPage = 0; private typingFast = false; constructor( private readonly scene: IStorySceneConfig, private readonly storage: StorageMgr = globalStorageMgr, private readonly callbacks: IStorySceneCallbacks = {} ) {} /** Returns true if the user has already seen / skipped the intro. */ public hasBeenSeen(): boolean { return this.storage.get(STORAGE_KEY.StoryIntroSeen, false); } /** * Called by the boot flow. If the intro was already consumed, the view * should skip straight to the next scene; otherwise this begins playback. */ public start(): 'playing' | 'already_seen' { if (this.hasBeenSeen()) { return 'already_seen'; } this.phase = 'typing'; this.pageIndex = 0; this.cursor = 0; this.elapsedSecOnPage = 0; this.typingFast = false; this.callbacks.onPageEntered?.(this.currentPage()); this.emitText(); return 'playing'; } /** Call every frame with real-time delta. */ public tick(dtSec: number): void { if (this.phase !== 'typing') return; this.elapsedSecOnPage += dtSec; const page = this.currentPage(); const cps = BASE_TYPING_CPS * (this.typingFast ? FAST_MULTIPLIER : 1); const targetCursor = Math.floor(this.elapsedSecOnPage * cps); if (targetCursor !== this.cursor) { this.cursor = Math.min(targetCursor, page.text.length); this.emitText(); } if (this.cursor >= page.text.length) { this.phase = 'waiting_next'; } } /** Tap anywhere — speed up typewriter or advance to next page (req 19.3). */ public onTap(): void { if (this.phase === 'typing') { // First tap: reveal full page immediately (req 19.3 "accelerate"). const page = this.currentPage(); this.cursor = page.text.length; this.emitText(); this.phase = 'waiting_next'; return; } if (this.phase === 'waiting_next') { this.advancePage(); } } /** Skip button pressed — immediate dismissal (req 19.4). */ public onSkip(): void { if (this.phase === 'finished') return; this.markSeen(); this.phase = 'finished'; this.callbacks.onFinished?.(true); } /** Called by the Settings screen to re-enable the intro (req 19.6). */ public reset(): void { this.storage.remove(STORAGE_KEY.StoryIntroSeen); } /** Expose current page for HUD rendering. */ public get currentPageNumber(): number { return this.pageIndex + 1; } /** Current visible text on the active page. */ public get visibleText(): string { return this.currentPage().text.slice(0, this.cursor); } public get status(): StoryPhase { return this.phase; } public get totalPages(): number { return this.scene.pages.length; } // ----------------------------------------------------------------- private currentPage(): IStoryPageConfig { return this.scene.pages[this.pageIndex]; } private emitText(): void { this.callbacks.onTextChanged?.(this.visibleText, this.currentPage()); } private advancePage(): void { if (this.pageIndex < this.scene.pages.length - 1) { this.pageIndex++; this.cursor = 0; this.elapsedSecOnPage = 0; this.phase = 'typing'; this.typingFast = false; this.callbacks.onPageEntered?.(this.currentPage()); this.emitText(); } else { // Last page complete → finish naturally. this.markSeen(); this.phase = 'finished'; this.callbacks.onFinished?.(false); } } private markSeen(): void { this.storage.set(STORAGE_KEY.StoryIntroSeen, true); } }