first commmit
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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<boolean>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user