diff --git a/content/seasons/00-demo/fragments.yaml b/content/seasons/00-demo/fragments.yaml deleted file mode 100644 index c4432ed..0000000 --- a/content/seasons/00-demo/fragments.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# /content/seasons/00-demo/fragments.yaml -# -# Phase 1 demo fragment — proves the loader round-trips end-to-end. -# Removed in Phase 2 when real Season 1 content lands under /content/seasons/01-soil/. -# -# Fragment ID convention is `season.` per CLAUDE.md "Code Style" -# and content/README.md. Never numeric. Renames forbidden once shipped. -fragments: - - id: season0.demo.first-light - season: 0 - body: | - The garden remembers the first time it was tended, - though it cannot say in whose voice. diff --git a/content/seasons/01-soil/fragments.yaml b/content/seasons/01-soil/fragments.yaml new file mode 100644 index 0000000..a6ec368 --- /dev/null +++ b/content/seasons/01-soil/fragments.yaml @@ -0,0 +1,10 @@ +# /content/seasons/01-soil/fragments.yaml +# +# Phase 2 placeholder. Plan 02-03 replaces with the authored Season-1 +# fragments (≥10 in voice, MEMR-* coverage). The single placeholder +# fragment here keeps the eager fragment loader green during Plan 02-02 +# (Plan 02-03 expands the file). +fragments: + - id: season1.soil.placeholder + season: 1 + body: "(placeholder — Plan 02-03 ships authored fragments)" diff --git a/content/seasons/01-soil/ui-strings.yaml b/content/seasons/01-soil/ui-strings.yaml new file mode 100644 index 0000000..846d10d --- /dev/null +++ b/content/seasons/01-soil/ui-strings.yaml @@ -0,0 +1,43 @@ +# Player-visible Phase 2 UI copy. Externalized per CLAUDE.md +# Code Style ("anything player-facing... should match the bible's voice") +# and reviewed against anti-fomo-doctrine.md (no FOMO, no nag, no streaks). +# +# Tone: warm, specific, intermittent, sometimes funny, sometimes devastating. +# Lura's warmth is the contrast (Plan 02-04); Phase 2 Wave 1 ships only the +# outermost shell — Begin screen, the seed picker chrome, and the post-harvest +# beat that Plan 02-03 will surface. +season: 1 + +begin: + title: "The Last Garden" + subtitle: "tend" + cta: "Begin" + +seed_picker: + title: "Sow" + cancel: "Not yet" + +# Three short beats, surfaced one at a time after a harvest (Plan 02-03). +# Authored to be quiet — the player is meant to almost miss them. +post_harvest_beat: + - "The earth remembers." + - "Something stayed." + - "It rests where it grew." + +journal: + empty_state: "Nothing yet. Plant something." + back: "Close" + +settings: + title: "Settings" + export: "Save to a copy" + import: "Restore from a copy" + restore_snapshot: "Earlier garden" + persistence_denied_toast: "The garden may forget, if your browser asks it to." + +# Plant display names — sourced here so the writer can adjust without +# touching src/sim/garden/plants.ts (which carries fallbackName for tests). +plants: + rosemary: "Rosemary" + yarrow: "Yarrow" + winter-rose: "Winter-rose" diff --git a/src/App.tsx b/src/App.tsx index daeb73e..8b48836 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useRef } from 'react'; import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; +import { BeginScreen } from './ui/begin'; +import { SeedPicker } from './ui/garden'; function App() { // PhaserGame ref — Phase 2+ will use this to access the active scene from React. @@ -8,6 +10,11 @@ function App() { return (
+ + + {/* Plan 02-03 mounts: , */} + {/* Plan 02-04 mounts: */} + {/* Plan 02-05 mounts: , , */}
); } diff --git a/src/PhaserGame.tsx b/src/PhaserGame.tsx index 12e062c..42f100b 100644 --- a/src/PhaserGame.tsx +++ b/src/PhaserGame.tsx @@ -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(function PhaserGame(_props, ref) { +export const PhaserGame = forwardRef(function PhaserGame(props, ref) { const game = useRef(null); + const sceneRef = useRef(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(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
; diff --git a/src/content/index.ts b/src/content/index.ts index dbbf959..ffe501c 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,7 +1,14 @@ -export { fragments, loadFragmentsFromGlob } from './loader.ts'; +export { + fragments, + loadFragmentsFromGlob, + loadSeasonFragments, + uiStrings, +} from './loader.ts'; export { FragmentSchema, SeasonContentSchema, + UiStringsSchema, type Fragment, type SeasonContent, + type UiStrings, } from './schemas/index.ts'; diff --git a/src/content/loader.ts b/src/content/loader.ts index 86f7601..75c7950 100644 --- a/src/content/loader.ts +++ b/src/content/loader.ts @@ -1,6 +1,12 @@ import grayMatter from 'gray-matter'; import { parse as parseYAML } from 'yaml'; -import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas/index.ts'; +import { + SeasonContentSchema, + FragmentSchema, + UiStringsSchema, + type Fragment, + type UiStrings, +} from './schemas/index.ts'; /** * Vite-native content pipeline (PIPE-01). The glob patterns MUST be @@ -12,9 +18,9 @@ import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas/in * through Vite into the build process — `npm run build` exits non-zero, * which is the PIPE-01 contract. * - * Phase 1 ships one demo fragment under /content/seasons/00-demo/fragments.yaml; - * Phase 2 fills /content/seasons/01-soil/ and may also begin authoring - * one-per-file Markdown fragments under /content/seasons//fragments/*.md. + * Phase 2 (Plan 02-02) replaces the 00-demo placeholder with /content/ + * seasons/01-soil/. Plan 02-03 will populate the real Season-1 fragment + * pool (currently a single placeholder fragment). */ const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, @@ -52,12 +58,94 @@ function loadMdFragments(): Fragment[] { } /** - * All fragments discovered at build time. Phase 1 ships one demo fragment - * under /content/seasons/00-demo/fragments.yaml; Phase 2 fills - * /content/seasons/01-soil/. + * All fragments discovered at build time. Phase 2 (Plan 02-02) ships a + * single Season-1 placeholder; Plan 02-03 expands to ≥10 authored + * fragments. */ export const fragments: Fragment[] = [...loadYamlFragments(), ...loadMdFragments()]; +// --------------------------------------------------------------------- +// PIPE-02 — per-Season lazy fragment chunks (Plan 02-02 wires the +// surface; Plan 02-03 + 02-04 + Phase-4+ exploit it as Seasons grow). +// +// The eager `fragments` export above stays for now; Plan 02-03 may +// switch the consuming code to `await loadSeasonFragments(seasonId)` +// once the Phase-2 fragment count makes eager loading expensive. +// --------------------------------------------------------------------- + +const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', { + query: '?raw', + import: 'default', +}); + +const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', { + query: '?raw', + import: 'default', +}); + +function pad2(n: number): string { + return n.toString().padStart(2, '0'); +} + +export async function loadSeasonFragments(seasonId: number): Promise { + const yamlMatch = Object.entries(lazyYamlFragments).filter(([p]) => + p.includes(`/${pad2(seasonId)}-`), + ); + const mdMatch = Object.entries(lazyMdFragments).filter(([p]) => + p.includes(`/${pad2(seasonId)}-`), + ); + + const yamlOut: Fragment[] = []; + for (const [path, loader] of yamlMatch) { + const raw = (await loader()) as string; + const parsed = SeasonContentSchema.safeParse(parseYAML(raw)); + if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + } + yamlOut.push(...parsed.data.fragments); + } + + const mdOut: Fragment[] = []; + for (const [path, loader] of mdMatch) { + const raw = (await loader()) as string; + const { data, content } = grayMatter(raw); + const merged = { ...data, body: content.trim() }; + const parsed = FragmentSchema.safeParse(merged); + if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + } + mdOut.push(parsed.data); + } + + return [...yamlOut, ...mdOut]; +} + +// --------------------------------------------------------------------- +// UI strings — loaded eagerly so first paint can reference any string +// without await. Per CLAUDE.md externalized-strings rule. +// --------------------------------------------------------------------- + +const uiStringFiles = import.meta.glob('/content/seasons/*/ui-strings.yaml', { + eager: true, + query: '?raw', + import: 'default', +}) as Record; + +function loadUiStrings(): Record { + const result: Record = {}; + for (const [path, raw] of Object.entries(uiStringFiles)) { + const data = parseYAML(raw); + const parsed = UiStringsSchema.safeParse(data); + if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + } + result[parsed.data.season] = parsed.data; + } + return result; +} + +export const uiStrings: Record = loadUiStrings(); + /** * Test-only helper that lets loader.test.ts validate mocked SeasonContent * shapes against the schema without touching the filesystem. PIPE-01 is diff --git a/src/content/schemas/index.ts b/src/content/schemas/index.ts index 1ef69e6..1996b30 100644 --- a/src/content/schemas/index.ts +++ b/src/content/schemas/index.ts @@ -1,2 +1,3 @@ export { FragmentSchema, type Fragment } from './fragment.ts'; export { SeasonContentSchema, type SeasonContent } from './season.ts'; +export { UiStringsSchema, type UiStrings } from './ui-strings.ts'; diff --git a/src/content/schemas/ui-strings.ts b/src/content/schemas/ui-strings.ts new file mode 100644 index 0000000..1ea1721 --- /dev/null +++ b/src/content/schemas/ui-strings.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +/** + * Player-visible UI strings, externalized per CLAUDE.md "Code Style": + * "Player-visible strings are externalized in /content/, never hardcoded." + * + * One file per season under /content/seasons//ui-strings.yaml. The + * loader (src/content/loader.ts) keys them by `season` so the runtime can + * resolve `uiStrings[1].begin.title` etc. + */ +export const UiStringsSchema = z.object({ + season: z.number().int().min(0).max(7), + begin: z.object({ + title: z.string().min(1), + subtitle: z.string().min(1), + cta: z.string().min(1), + }), + seed_picker: z.object({ + title: z.string().min(1), + cancel: z.string().min(1), + }), + post_harvest_beat: z.array(z.string().min(1)).min(1), + journal: z.object({ + empty_state: z.string().min(1), + back: z.string().min(1), + }), + settings: z.object({ + title: z.string().min(1), + export: z.string().min(1), + import: z.string().min(1), + restore_snapshot: z.string().min(1), + persistence_denied_toast: z.string().min(1), + }), + plants: z.record(z.string(), z.string().min(1)), +}); + +export type UiStrings = z.infer; diff --git a/src/ui/begin/BeginScreen.test.tsx b/src/ui/begin/BeginScreen.test.tsx new file mode 100644 index 0000000..26cc174 --- /dev/null +++ b/src/ui/begin/BeginScreen.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { BeginScreen } from './BeginScreen'; +import { appStore } from '../../store'; +import { __resetAudioBootstrapForTest } from './use-audio-bootstrap'; + +// Mock the audio bootstrap module so happy-dom (which lacks AudioContext) +// doesn't blow up; also gives us a spy to assert the click invokes it. +vi.mock('./use-audio-bootstrap', async () => { + const actual = + await vi.importActual('./use-audio-bootstrap'); + return { + ...actual, + bootstrapAudioContext: vi.fn().mockResolvedValue(null), + }; +}); + +import { bootstrapAudioContext } from './use-audio-bootstrap'; + +describe('BeginScreen (AEST-07, D-21, D-22)', () => { + beforeEach(() => { + cleanup(); + appStore.setState({ beginGateDismissed: false }); + __resetAudioBootstrapForTest(); + vi.mocked(bootstrapAudioContext).mockClear(); + }); + + it('renders the title and Begin CTA when not dismissed', () => { + render(); + expect(screen.getByText('The Last Garden')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy(); + }); + + it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => { + appStore.setState({ beginGateDismissed: true }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('dismisses the gate and triggers audio bootstrap on click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Begin' })); + expect(bootstrapAudioContext).toHaveBeenCalledTimes(1); + expect(appStore.getState().beginGateDismissed).toBe(true); + }); + + it('subtitle is the externalized "tend" string (CLAUDE.md tone)', () => { + render(); + expect(screen.getByText('tend')).toBeTruthy(); + }); +}); diff --git a/src/ui/begin/BeginScreen.tsx b/src/ui/begin/BeginScreen.tsx new file mode 100644 index 0000000..b087d35 --- /dev/null +++ b/src/ui/begin/BeginScreen.tsx @@ -0,0 +1,69 @@ +import { type JSX } from 'react'; +import { useAppStore } from '../../store'; +import { uiStrings } from '../../content'; +import { bootstrapAudioContext } from './use-audio-bootstrap'; + +/** + * D-21 + AEST-07: tasteful typographic Begin screen. Phase 3 swaps in + * the painted gesture-gate without changing this file's behavior. + * + * D-22: shown on first run only — gated by session.beginGateDismissed. + * Once dismissed, the gate stays down for the session; persistence + * across sessions is owned by the save lifecycle (Plan 02-05). + * + * CLAUDE.md banner concern #7 (Web Audio user-gesture): the click + * handler calls `bootstrapAudioContext()` SYNCHRONOUSLY (not inside a + * useEffect), so iOS Safari sees the AudioContext construction inside + * the gesture (Pitfall 5). + */ +export function BeginScreen(): JSX.Element | null { + const dismissed = useAppStore((s) => s.beginGateDismissed); + const dismissBeginGate = useAppStore((s) => s.dismissBeginGate); + + if (dismissed) return null; + + const strings = uiStrings[1]?.begin; + if (!strings) return null; + + const onBegin = (): void => { + void bootstrapAudioContext(); // synchronous-inside-click; do NOT move into useEffect (Pitfall 5) + dismissBeginGate(); + }; + + return ( +
+

{strings.title}

+

{strings.subtitle}

+ +
+ ); +} diff --git a/src/ui/begin/index.ts b/src/ui/begin/index.ts new file mode 100644 index 0000000..12a33cb --- /dev/null +++ b/src/ui/begin/index.ts @@ -0,0 +1,5 @@ +export { BeginScreen } from './BeginScreen'; +export { + bootstrapAudioContext, + installFirstInteractionGestureHandler, +} from './use-audio-bootstrap'; diff --git a/src/ui/begin/use-audio-bootstrap.ts b/src/ui/begin/use-audio-bootstrap.ts new file mode 100644 index 0000000..ca94668 --- /dev/null +++ b/src/ui/begin/use-audio-bootstrap.ts @@ -0,0 +1,71 @@ +/** + * Audio bootstrap (AEST-07 + RESEARCH Pattern 9 + CLAUDE.md banner concern #7). + * + * Web Audio cannot start until the user has interacted with the page; iOS + * Safari further requires the AudioContext to be CONSTRUCTED inside the + * gesture, not merely resumed (Pitfall 5). This module is the single + * owner of that contract. + * + * Usage: + * - BeginScreen onClick → call `bootstrapAudioContext()` synchronously + * inside the click handler (NOT inside a useEffect). + * - Returning players (D-22) skip the Begin screen → PhaserGame.tsx + * installs `installFirstInteractionGestureHandler()` so the next + * click / touch / keypress bootstraps audio. + * + * This module is in src/ui/ — it is allowed to touch the DOM and + * AudioContext freely (the CORE-10 firewall only forbids src/sim/ from + * importing src/ui/, not the other way). + */ + +let _ctx: AudioContext | null = null; +let _resumed = false; + +export async function bootstrapAudioContext(): Promise { + if (_resumed && _ctx) return _ctx; + if (!_ctx) { + try { + const Ctor = + typeof AudioContext !== 'undefined' + ? AudioContext + : (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + _ctx = new Ctor(); + } catch { + return null; + } + } + try { + await _ctx.resume(); + _resumed = true; + return _ctx; + } catch { + return null; + } +} + +/** + * For returning players (D-22): no Begin screen, but the next click, + * touch, or keypress must bootstrap audio. One-shot — removes itself + * from all three event types after the first fire. + */ +export function installFirstInteractionGestureHandler(): void { + const handler = (): void => { + void bootstrapAudioContext(); + document.removeEventListener('click', handler); + document.removeEventListener('touchstart', handler); + document.removeEventListener('keydown', handler); + }; + document.addEventListener('click', handler); + document.addEventListener('touchstart', handler); + document.addEventListener('keydown', handler); +} + +/** + * Test-only: reset the module-level cache between tests. + * NOT part of the public surface; internal to the test harness. + */ +export function __resetAudioBootstrapForTest(): void { + _ctx = null; + _resumed = false; +} diff --git a/src/ui/garden/SeedPicker.test.tsx b/src/ui/garden/SeedPicker.test.tsx new file mode 100644 index 0000000..ff759ef --- /dev/null +++ b/src/ui/garden/SeedPicker.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'; + +// Phaser does not initialize under happy-dom (canvas.getContext('2d') +// returns null, which trips its checkInverseAlpha boot probe). The +// SeedPicker only needs an event-emitter surface — swap in a lightweight +// EventTarget-like shim instead of pulling Phaser into the test runtime. +vi.mock('../../game/event-bus', () => { + type Listener = (...args: unknown[]) => void; + const listeners = new Map>(); + return { + eventBus: { + on(event: string, fn: Listener): void { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(fn); + }, + off(event: string, fn: Listener): void { + listeners.get(event)?.delete(fn); + }, + emit(event: string, ...args: unknown[]): void { + listeners.get(event)?.forEach((fn) => fn(...args)); + }, + removeAllListeners(): void { + listeners.clear(); + }, + }, + }; +}); + +import { SeedPicker } from './SeedPicker'; +import { eventBus } from '../../game/event-bus'; +import { appStore } from '../../store'; + +function resetStore(unlocked: string[] = ['rosemary']): void { + appStore.setState({ + tiles: new Array(16).fill(null), + unlockedPlantTypes: unlocked, + pendingCommands: [], + tickCount: 0, + lastTickAt: 0, + }); +} + +describe('SeedPicker (D-02 inline DOM popover)', () => { + beforeEach(() => { + cleanup(); + resetStore(); + eventBus.removeAllListeners(); + }); + afterEach(() => { + eventBus.removeAllListeners(); + }); + + it('returns null on initial render (not visible until tile-clicked-coords fires)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('appears positioned at the emitted screen coords when tile-clicked-coords fires', () => { + render(); + act(() => { + eventBus.emit('tile-clicked-coords', { tileIdx: 5, screenX: 200, screenY: 300 }); + }); + const popover = screen.getByTestId('seed-picker'); + expect(popover).toBeTruthy(); + // left = x - 80, top = y - 120 + expect(popover.style.left).toBe('120px'); + expect(popover.style.top).toBe('180px'); + }); + + it('renders one button per unlocked plant type', () => { + resetStore(['rosemary']); + render(); + act(() => { + eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 }); + }); + expect(screen.getByRole('button', { name: 'Rosemary' })).toBeTruthy(); + // Yarrow is locked — no button. + expect(screen.queryByRole('button', { name: 'Yarrow' })).toBeNull(); + }); + + it('clicking a plant button enqueues a plantSeed command into the store', () => { + resetStore(['rosemary']); + render(); + act(() => { + eventBus.emit('tile-clicked-coords', { tileIdx: 7, screenX: 0, screenY: 0 }); + }); + fireEvent.click(screen.getByRole('button', { name: 'Rosemary' })); + const cmds = appStore.getState().pendingCommands; + expect(cmds).toEqual([ + { kind: 'plantSeed', tileIdx: 7, plantTypeId: 'rosemary' }, + ]); + }); + + it('dismisses (returns null) after a plant is selected', () => { + resetStore(['rosemary']); + const { container } = render(); + act(() => { + eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 }); + }); + fireEvent.click(screen.getByRole('button', { name: 'Rosemary' })); + expect(container.firstChild).toBeNull(); + }); + + it('shows multiple plants when unlocked', () => { + resetStore(['rosemary', 'yarrow', 'winter-rose']); + render(); + act(() => { + eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 }); + }); + expect(screen.getByRole('button', { name: 'Rosemary' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Yarrow' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Winter-rose' })).toBeTruthy(); + }); +}); diff --git a/src/ui/garden/SeedPicker.tsx b/src/ui/garden/SeedPicker.tsx new file mode 100644 index 0000000..4eb110b --- /dev/null +++ b/src/ui/garden/SeedPicker.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState, type JSX } from 'react'; +import { eventBus } from '../../game/event-bus'; +import { useAppStore } from '../../store'; +import { uiStrings } from '../../content'; +import { PLANT_TYPES } from '../../sim/garden'; +import type { PlantTypeId } from '../../sim/garden/types'; + +interface PickerState { + visible: boolean; + tileIdx: number; + x: number; + y: number; +} + +interface TileClickPayload { + tileIdx: number; + screenX: number; + screenY: number; +} + +/** + * D-02 — inline DOM popover positioned over the Phaser canvas. + * + * Listens for 'tile-clicked-coords' from the Garden scene; mounts itself + * absolutely-positioned at those screen coords. Click outside dismisses. + * + * Architectural note: the popover is a DOM overlay rather than a Phaser + * UI scene because (a) it needs HTML form-element accessibility (button + * focus / tab order / screen-reader semantics) and (b) RESEARCH Pattern + * 4 — the inline-DOM-popover-over-canvas pattern — keeps the SeedPicker + * decoupled from the watercolor render pipeline that lands in Phase 3. + */ +export function SeedPicker(): JSX.Element | null { + const [picker, setPicker] = useState({ + visible: false, + tileIdx: -1, + x: 0, + y: 0, + }); + const unlocked = useAppStore((s) => s.unlockedPlantTypes); + const enqueueCommand = useAppStore((s) => s.enqueueCommand); + const strings = uiStrings[1]?.seed_picker; + const plantStrings = uiStrings[1]?.plants ?? {}; + + useEffect(() => { + const onCoords = (payload: TileClickPayload): void => { + setPicker({ + visible: true, + tileIdx: payload.tileIdx, + x: payload.screenX, + y: payload.screenY, + }); + }; + eventBus.on('tile-clicked-coords', onCoords); + return () => { + eventBus.off('tile-clicked-coords', onCoords); + }; + }, []); + + // Click-outside dismiss. Defer one tick so the click that opened the + // picker isn't itself interpreted as the "outside" click. + useEffect(() => { + if (!picker.visible) return; + const t = setTimeout(() => { + const onClick = (): void => setPicker((p) => ({ ...p, visible: false })); + document.addEventListener('click', onClick, { once: true }); + }, 0); + return () => clearTimeout(t); + }, [picker.visible]); + + if (!picker.visible || !strings) return null; + + const onSelect = (plantTypeId: PlantTypeId): void => { + enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId }); + setPicker((p) => ({ ...p, visible: false })); + }; + + // Translate screen coords → picker top-left (centered above tile). + const left = picker.x - 80; + const top = picker.y - 120; + + return ( +
e.stopPropagation()} + style={{ + position: 'fixed', + left, + top, + zIndex: 50, + background: '#2a2a2e', + color: '#e8e0d0', + padding: '0.6rem 0.8rem', + borderRadius: 4, + boxShadow: '0 6px 18px rgba(0,0,0,0.4)', + fontFamily: 'serif', + minWidth: 160, + }} + > +
+ {strings.title} +
+ {unlocked.length === 0 && ( +
+ )} + {unlocked.map((id) => { + const type = PLANT_TYPES[id as PlantTypeId]; + if (!type) return null; + const display = plantStrings[id] ?? type.fallbackName; + return ( + + ); + })} +
+ ); +} diff --git a/src/ui/garden/index.ts b/src/ui/garden/index.ts new file mode 100644 index 0000000..1171d74 --- /dev/null +++ b/src/ui/garden/index.ts @@ -0,0 +1 @@ +export { SeedPicker } from './SeedPicker'; diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..e0d373c --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,9 @@ +/** + * Top-level barrel for src/ui/. App code imports from here. + * + * Per CORE-10: src/sim/** must NOT import from this module. The sim + * stays UI-agnostic; only src/App.tsx + src/PhaserGame.tsx consume the + * UI layer. + */ +export * from './begin'; +export * from './garden';