first commmit

This commit is contained in:
jakciehan
2026-05-06 08:17:32 +08:00
commit 427a33c55b
269 changed files with 20906 additions and 0 deletions
+162
View File
@@ -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);
}
}