feat(02-02): begin screen + seed picker + ui-strings + lazy content split
- content/seasons/01-soil/ui-strings.yaml: player-visible Phase-2 copy externalized per CLAUDE.md (Begin / seed picker / post-harvest beat / journal / settings / plant display names); voice reviewed against bible + anti-fomo-doctrine.md
- content/seasons/01-soil/fragments.yaml: placeholder Season-1 fragment file (Plan 02-03 expands to ≥10 authored)
- content/seasons/00-demo/: deleted (Phase-1 demo replaced)
- src/content/schemas/ui-strings.ts: UiStringsSchema (Zod) — validates structure of every season's ui-strings.yaml at load time
- src/content/schemas/index.ts + src/content/index.ts: re-export UiStringsSchema/UiStrings
- src/content/loader.ts: eager `uiStrings` glob + PIPE-02 lazy `loadSeasonFragments(seasonId)` (Plan 02-03+ exploit)
- src/ui/begin/use-audio-bootstrap.ts: bootstrapAudioContext() lazy-creates + resumes (RESEARCH Pattern 9; Pitfall 5 mitigation — context construction inside the gesture for iOS Safari) + installFirstInteractionGestureHandler() one-shot for D-22 returning players + __resetAudioBootstrapForTest()
- src/ui/begin/BeginScreen.tsx: D-21 typographic Begin screen — title + subtitle + CTA from uiStrings[1].begin; onClick calls bootstrapAudioContext synchronously inside the click event then dismisses the session gate (D-22)
- src/ui/begin/BeginScreen.test.tsx: 4 tests — render / D-22 skip / click bootstraps + dismisses / subtitle string
- src/ui/garden/SeedPicker.tsx: D-02 inline DOM popover; subscribes to 'tile-clicked-coords'; renders one button per unlocked plant type from uiStrings[1].plants; click enqueues plantSeed command via store.enqueueCommand
- src/ui/garden/SeedPicker.test.tsx: 6 tests — initial-null / coords-positioned / unlocked-only / enqueue / dismiss / multi-plant; mocks game/event-bus to avoid Phaser canvas init under happy-dom (deviation Rule 3)
- src/ui/{begin,garden,index}.ts: barrels
- src/App.tsx: mount BeginScreen + SeedPicker as overlay siblings to PhaserGame
- src/PhaserGame.tsx: bootstrap unlockedPlantTypes=['rosemary'] for first-run; install gesture handler + scene-ready listener
- npm run ci exits 0; 163/163 tests pass (10 new this commit + 25 from Task 1 + 128 baseline)
This commit is contained in:
+30
-5
@@ -1,6 +1,9 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
||||
import StartGame from './game/main.ts';
|
||||
import type * as Phaser from 'phaser';
|
||||
import { eventBus } from './game/event-bus';
|
||||
import { appStore } from './store';
|
||||
import { installFirstInteractionGestureHandler } from './ui/begin';
|
||||
|
||||
export interface IRefPhaserGame {
|
||||
game: Phaser.Game | null;
|
||||
@@ -11,11 +14,20 @@ interface IProps {
|
||||
currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
|
||||
}
|
||||
|
||||
export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(_props, ref) {
|
||||
export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
|
||||
const game = useRef<Phaser.Game | null>(null);
|
||||
const sceneRef = useRef<Phaser.Scene | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (game.current === null) {
|
||||
// Bootstrap initial state. Plan 02-05 will replace this with the
|
||||
// real save-load path; for now, first-run players get rosemary
|
||||
// unlocked (D-05 — the warm starter plant).
|
||||
const initial = appStore.getState();
|
||||
if (initial.unlockedPlantTypes.length === 0) {
|
||||
appStore.setState({ unlockedPlantTypes: ['rosemary'] });
|
||||
}
|
||||
|
||||
game.current = StartGame('game-container');
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
@@ -34,13 +46,26 @@ export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
// Phase 2+: subscribe to scene-ready events here and surface the active scene
|
||||
// through `currentActiveScene` so React can talk to Phaser.
|
||||
}, []);
|
||||
const onSceneReady = (scene: Phaser.Scene): void => {
|
||||
sceneRef.current = scene;
|
||||
props.currentActiveScene?.(scene);
|
||||
};
|
||||
eventBus.on('scene-ready', onSceneReady);
|
||||
|
||||
// Install the first-interaction gesture handler unconditionally —
|
||||
// it is a one-shot that bootstraps audio on the first click /
|
||||
// touch / keypress whether the Begin screen handled it or not (D-22
|
||||
// fallback for returning players who skip the Begin gate).
|
||||
installFirstInteractionGestureHandler();
|
||||
|
||||
return () => {
|
||||
eventBus.off('scene-ready', onSceneReady);
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
game: game.current,
|
||||
scene: null,
|
||||
scene: sceneRef.current,
|
||||
}));
|
||||
|
||||
return <div id="game-container" />;
|
||||
|
||||
Reference in New Issue
Block a user