Files
2026-05-06 08:17:32 +08:00

126 lines
3.8 KiB
TypeScript

import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
import { STORAGE_KEY } from '../common/Constants';
/**
* UIFlowMgr — scene-flow state machine (task 9.2, req 12.7-12.8, 13.1).
*
* It does **not** perform `director.loadScene()` itself; the Cocos view layer
* subscribes to `onSceneEnter` and performs the actual scene swap. Keeping
* the flow engine-agnostic makes it trivial to Jest-test every boot path,
* every settlement path, and the new story-intro gate (req 19.x).
*
* Decision D-4 / req 13.1 guardrail:
* The `showDifficultyPicker()` action simply does not exist; and
* `availableSettingsEntries()` purposefully omits it. A future contributor
* who tries to add a "difficulty" key will hit a TypeScript compile-error
* because the union `SettingsKey` is exhaustive.
*/
export type SceneId =
| 'boot'
| 'story_intro'
| 'main_menu'
| 'level_select'
| 'gameplay'
| 'settlement'
| 'settings';
export type SettingsKey =
| 'audio_volume'
| 'layout_customisation'
| 'replay_tutorial'
| 'replay_story_intro';
export interface ISceneEnter {
scene: SceneId;
/** Optional payload (e.g. `{ levelId: '1-1' }` for gameplay). */
payload?: Record<string, unknown>;
}
export interface IUIFlowCallbacks {
onSceneEnter?: (ev: ISceneEnter) => void;
}
export class UIFlowMgr {
private current: SceneId = 'boot';
constructor(
private readonly storage: StorageMgr = globalStorageMgr,
private readonly callbacks: IUIFlowCallbacks = {}
) {}
public get currentScene(): SceneId {
return this.current;
}
/** Invoked by `GameBoot.start()` once engine is ready. */
public onBoot(): void {
if (this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false)) {
this.enter('main_menu');
} else {
this.enter('story_intro');
}
}
/** Called by StorySceneCtrl.onFinished(). */
public onStoryFinished(): void {
this.enter('gameplay', { levelId: '1-1' });
}
/** Main menu → level select. */
public onPressStartGame(): void {
// First-time "Start Game" may jump through the story again if we
// ever reset; otherwise go to level select.
if (!this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false)) {
this.enter('story_intro');
} else {
this.enter('level_select');
}
}
public onPickLevel(levelId: string): void {
this.enter('gameplay', { levelId });
}
public onOpenSettings(): void {
this.enter('settings');
}
public onCloseSettings(): void {
this.enter('main_menu');
}
public onLevelCleared(nextLevelId: string | null): void {
if (nextLevelId) {
this.enter('settlement', { nextLevelId });
} else {
// After final boss settlement, back to the main menu.
this.enter('settlement', { isChapterEnd: true });
}
}
public onSettlementContinue(nextLevelId?: string): void {
if (nextLevelId) this.enter('gameplay', { levelId: nextLevelId });
else this.enter('main_menu');
}
public onPlayerDied(currentLevelId: string): void {
this.enter('settlement', { levelId: currentLevelId, dead: true });
}
/**
* Exhaustive list of settings entries available in the Settings scene.
* Purposefully omits any difficulty-selection entry (req 13.1).
*/
public availableSettingsEntries(): ReadonlyArray<SettingsKey> {
return ['audio_volume', 'layout_customisation', 'replay_tutorial', 'replay_story_intro'];
}
// -----------------------------------------------------------------
private enter(scene: SceneId, payload?: Record<string, unknown>): void {
this.current = scene;
this.callbacks.onSceneEnter?.({ scene, payload });
}
}