diff --git a/src/App.tsx b/src/App.tsx index 477d61c..4576988 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; import { BeginScreen } from './ui/begin'; import { SeedPicker } from './ui/garden'; import { FragmentRevealModal, JournalIcon } from './ui/journal'; +import { LuraDialogue } from './ui/dialogue'; function App() { // PhaserGame ref — Phase 2+ will use this to access the active scene from React. @@ -15,7 +16,7 @@ function App() { - {/* Plan 02-04 mounts: */} + {/* Plan 02-05 mounts: , , */} ); diff --git a/src/game/scenes/Garden.ts b/src/game/scenes/Garden.ts index 91cb39a..58364c5 100644 --- a/src/game/scenes/Garden.ts +++ b/src/game/scenes/Garden.ts @@ -14,8 +14,11 @@ import { destroyPlant, applyReadyPulse, tileCenterToDom, + drawGate, + updateGateIndicator, type TileGameObjects, type PlantGameObject, + type GateGameObjects, } from '../../render/garden'; import { appStore, simAdapter } from '../../store'; import { fragments as allFragments } from '../../content'; @@ -57,6 +60,7 @@ export class Garden extends Phaser.Scene { private readyTweens: Map = new Map(); private storeUnsubscribe: (() => void) | null = null; private simContext: SimContext = { fragments: [], currentSeason: 1 }; + private gate: GateGameObjects | null = null; constructor() { super('Garden'); @@ -85,14 +89,42 @@ export class Garden extends Phaser.Scene { t.hit.on('pointerdown', () => this.handleTilePointerDown(idx)); }); + // Plan 02-04 — draw the gate visual and wire its pointerdown. + // The gate's hit rectangle dispatches setDialogueOverlayOpen(true) + // ONLY when a Lura beat is pending; otherwise click is a soft no-op + // (the gate is always visible but only "alive" when there's a beat + // to deliver). + this.gate = drawGate(this); + this.gate.hit.on('pointerdown', () => { + const pending = appStore.getState().luraBeatProgress.pending; + if (pending) { + appStore.getState().setDialogueOverlayOpen(true); + } + }); + this.lastFrameMs = this.clock.now(); // Re-render plants when tiles change in the store (Pitfall 6 mitigation: - // subscribe rather than read once in create()). + // subscribe rather than read once in create()). Same subscription + // also updates the gate indicator on luraBeatProgress changes. this.storeUnsubscribe = appStore.subscribe((state) => { this.repaintPlants(state.tiles as Tile[]); + if (this.gate) { + updateGateIndicator( + this, + this.gate, + state.luraBeatProgress.pending !== null, + ); + } }); this.repaintPlants(appStore.getState().tiles as Tile[]); + if (this.gate) { + updateGateIndicator( + this, + this.gate, + appStore.getState().luraBeatProgress.pending !== null, + ); + } eventBus.emit('scene-ready', this); } @@ -154,6 +186,21 @@ export class Garden extends Phaser.Scene { appStore.getState().setFragmentRevealId(newId); } } + // Plan 02-04 — flow updated luraBeatProgress into the store so + // the gate indicator subscriber re-evaluates and the LuraDialogue + // overlay sees the new pending value when the player clicks the gate. + // simAdapter.applyLuraProgress is the canonical sim → store path + // for this field (already declared in Plan 02-01). + const prevLura = storeState.luraBeatProgress; + const nextLura = result.state.luraBeatProgress; + if ( + prevLura.pending !== nextLura.pending || + prevLura.arrived !== nextLura.arrived || + prevLura.mid !== nextLura.mid || + prevLura.farewell !== nextLura.farewell + ) { + simAdapter.applyLuraProgress(nextLura); + } } } @@ -220,6 +267,10 @@ export class Garden extends Phaser.Scene { this.readyTweens.forEach((t) => t.stop()); this.readyTweens.clear(); this.plantObjs.forEach((p) => destroyPlant(p)); + if (this.gate?.glowTween) { + this.gate.glowTween.stop(); + this.gate.glowTween = null; + } this.plantObjs.clear(); } } diff --git a/src/render/garden/gate-renderer.ts b/src/render/garden/gate-renderer.ts new file mode 100644 index 0000000..8d2905a --- /dev/null +++ b/src/render/garden/gate-renderer.ts @@ -0,0 +1,95 @@ +import * as Phaser from 'phaser'; + +/** + * Phaser primitive gate visual + indicator (D-15). + * + * The gate sits at the right edge of the 4×4 garden (canvas pixel + * coordinates). When a Lura beat is pending — luraBeatProgress.pending + * is non-null — the glow rectangle alpha-pulses to telegraph the visit. + * When the player clicks the gate's hit rectangle, the Garden scene + * dispatches setDialogueOverlayOpen(true), which mounts LuraDialogue. + * + * Phase 3 paints over with the watercolor gate. The hit + glow shapes + * stay in place; only the body's primitive draw is replaced. + */ + +// Canvas-space coordinates. The garden's 4×4 grid is centered at +// (296..728 px on x); the gate sits to the right at x=880, vertically +// centered on the canvas. Phaser.Scale.FIT translates these to the +// visible viewport at runtime. +const GATE_X = 880; +const GATE_Y = 384; +const GATE_COLOR = 0x6e6e75; +const GATE_GLOW_COLOR = 0xe8d8b6; +const GATE_HIT_W = 80; +const GATE_HIT_H = 120; + +export interface GateGameObjects { + hit: Phaser.GameObjects.Rectangle; + body: Phaser.GameObjects.Rectangle; + glow: Phaser.GameObjects.Rectangle; + glowTween: Phaser.Tweens.Tween | null; +} + +/** + * drawGate — adds the three rectangles (body / glow / hit) to the + * scene and returns handles. The glow is initially fully transparent + * (alpha=0); updateGateIndicator manages its visibility. + */ +export function drawGate(scene: Phaser.Scene): GateGameObjects { + const body = scene.add.rectangle( + GATE_X, + GATE_Y, + GATE_HIT_W * 0.7, + GATE_HIT_H, + GATE_COLOR, + ); + const glow = scene.add.rectangle( + GATE_X, + GATE_Y, + GATE_HIT_W * 0.9, + GATE_HIT_H * 1.05, + GATE_GLOW_COLOR, + 0, + ); + glow.setBlendMode(Phaser.BlendModes.ADD); + // Hit rectangle: invisible, sits on top of the visual rectangles to + // capture pointer input. + const hit = scene.add.rectangle( + GATE_X, + GATE_Y, + GATE_HIT_W, + GATE_HIT_H, + 0xffffff, + 0, + ); + hit.setInteractive({ useHandCursor: true }); + hit.setData('isGate', true); + return { hit, body, glow, glowTween: null }; +} + +/** + * updateGateIndicator — start/stop the soft alpha pulse based on + * whether a beat is pending. Idempotent: calling it twice with the + * same isPending value is a no-op. + */ +export function updateGateIndicator( + scene: Phaser.Scene, + gate: GateGameObjects, + isPending: boolean, +): void { + if (isPending && !gate.glowTween) { + gate.glowTween = scene.tweens.add({ + targets: gate.glow, + alpha: { from: 0.0, to: 0.4 }, + duration: 1200, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + } else if (!isPending && gate.glowTween) { + gate.glowTween.stop(); + gate.glowTween = null; + gate.glow.setAlpha(0); + } +} diff --git a/src/render/garden/index.ts b/src/render/garden/index.ts index a06828d..d78d11c 100644 --- a/src/render/garden/index.ts +++ b/src/render/garden/index.ts @@ -7,3 +7,5 @@ export { drawPlant, destroyPlant } from './plant-renderer'; export type { PlantGameObject } from './plant-renderer'; export { applyReadyPulse } from './ready-pulse'; export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords'; +export { drawGate, updateGateIndicator } from './gate-renderer'; +export type { GateGameObjects } from './gate-renderer'; diff --git a/src/ui/dialogue/LuraDialogue.test.tsx b/src/ui/dialogue/LuraDialogue.test.tsx new file mode 100644 index 0000000..4ad1bbd --- /dev/null +++ b/src/ui/dialogue/LuraDialogue.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { appStore } from '../../store'; + +/** + * Mocks must live ABOVE the imports of the system under test so Vitest's + * hoisting catches them. We mock the Ink runtime layer end-to-end so the + * test runs under happy-dom without actually loading compiled Ink JSON + * (which the runtime test path can't easily reach via Vite's lazy glob + * under happy-dom). + * + * Behavioral coverage of the actual inkjs runtime is in the Plan 02-05 + * Playwright e2e (PIPE-07). + */ + +// Hand-rolled story stub used by both the loadInkStory mock and the +// runtime mock. Fakes the methods the LuraDialogue captures. +const fakeStoryProto = { + variablesState: {} as Record, + ChoosePathString: vi.fn(), +}; + +vi.mock('../../content', () => ({ + loadInkStory: vi.fn(async () => fakeStoryProto), + bindGardenStateToInk: vi.fn(), +})); + +vi.mock('./ink-runtime', () => ({ + createInkRuntime: vi.fn(() => ({ + nextLine: vi.fn(async () => null), // immediate end-of-story + canContinue: () => false, + currentChoices: () => [], + chooseChoice: vi.fn(), + skipDelay: vi.fn(), + })), +})); + +// Real LuraDialogue — uses the mocked dependencies above. +import { LuraDialogue } from './LuraDialogue'; + +describe('LuraDialogue (D-15 — Lura DOM dialogue overlay)', () => { + beforeEach(() => { + // Reset store to a known clean state. + appStore.setState({ + dialogueOverlayOpen: false, + luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, + }); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('returns null when dialogueOverlayOpen=false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing visual until the runtime loads (open + pending=arrival)', () => { + // Open the overlay with a pending beat before the loadInkStory promise + // has resolved. We expect the overlay container to exist (aria-label + // visible) — useEffect will resolve the runtime asynchronously. + appStore.setState({ + dialogueOverlayOpen: true, + luraBeatProgress: { + arrived: false, + mid: false, + farewell: false, + pending: 'arrival', + }, + }); + render(); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.getByLabelText('Lura at the gate')).toBeTruthy(); + }); + + it('renders the Close button when open + pending', () => { + appStore.setState({ + dialogueOverlayOpen: true, + luraBeatProgress: { + arrived: false, + mid: false, + farewell: false, + pending: 'arrival', + }, + }); + render(); + const closeBtn = screen.getByRole('button', { name: 'Close' }); + expect(closeBtn).toBeTruthy(); + }); + + it('Close button click clears dialogueOverlayOpen AND advances arrived=true via resolvePendingLuraBeat', () => { + appStore.setState({ + dialogueOverlayOpen: true, + luraBeatProgress: { + arrived: false, + mid: false, + farewell: false, + pending: 'arrival', + }, + }); + render(); + const closeBtn = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeBtn); + const post = appStore.getState(); + expect(post.dialogueOverlayOpen).toBe(false); + expect(post.luraBeatProgress.arrived).toBe(true); + expect(post.luraBeatProgress.pending).toBeNull(); + }); + + it('Close on a mid beat marks mid=true (and similarly for farewell)', () => { + appStore.setState({ + dialogueOverlayOpen: true, + luraBeatProgress: { + arrived: true, + mid: false, + farewell: false, + pending: 'mid', + }, + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + const post = appStore.getState(); + expect(post.luraBeatProgress.mid).toBe(true); + expect(post.luraBeatProgress.pending).toBeNull(); + }); + + it('calls loadInkStory with the correct beat name + ChoosePathString with the knot', async () => { + const { loadInkStory } = await import('../../content'); + appStore.setState({ + dialogueOverlayOpen: true, + luraBeatProgress: { + arrived: false, + mid: false, + farewell: false, + pending: 'mid', + }, + }); + render(); + await waitFor(() => { + expect(loadInkStory).toHaveBeenCalledWith('lura-mid'); + }); + expect(fakeStoryProto.ChoosePathString).toHaveBeenCalledWith('mid'); + }); +}); diff --git a/src/ui/dialogue/LuraDialogue.tsx b/src/ui/dialogue/LuraDialogue.tsx new file mode 100644 index 0000000..176efe5 --- /dev/null +++ b/src/ui/dialogue/LuraDialogue.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; +import { appStore, useAppStore } from '../../store'; +import { loadInkStory, bindGardenStateToInk } from '../../content'; +import { createInkRuntime, type InkRuntime } from './ink-runtime'; +import { InkRenderer } from './ink-renderer'; +import { resolvePendingLuraBeat } from '../../sim/narrative'; + +/** + * LuraDialogue (D-15) — React DOM dialogue overlay. Opens when the + * player clicks the gate while a Lura beat is pending. + * + * Lifecycle: + * 1. dialogueOverlayOpen flips true (set by Garden scene's gate + * pointerdown when luraBeatProgress.pending is non-null). + * 2. useEffect loads the compiled Ink JSON for the pending beat, + * binds the snapshot variables, calls ChoosePathString(knot) so + * the story enters the named knot ('arrival' / 'mid' / 'farewell'), + * then constructs the InkRuntime that drives line-by-line drip. + * 3. InkRenderer pumps until story end. + * 4. Player clicks Close → resolvePendingLuraBeat marks the visited + * flag true and clears `pending`. + * + * Per RESEARCH Architectural Responsibility Map: Ink runtime lives in + * the UI tier. src/sim/ MUST NOT import inkjs (CORE-10 + sim-purity + * ESLint rule). + */ +export function LuraDialogue(): React.ReactElement | null { + const open = useAppStore((s) => s.dialogueOverlayOpen); + const pending = useAppStore((s) => s.luraBeatProgress.pending); + const setDialogueOverlayOpen = useAppStore((s) => s.setDialogueOverlayOpen); + const setLuraBeatProgress = useAppStore((s) => s.setLuraBeatProgress); + const [runtime, setRuntime] = useState(null); + + useEffect(() => { + if (!open || !pending) { + setRuntime(null); + return; + } + let cancelled = false; + (async () => { + try { + const beatName = `lura-${pending}` as + | 'lura-arrival' + | 'lura-mid' + | 'lura-farewell'; + const story = await loadInkStory(beatName); + if (cancelled) return; + bindGardenStateToInk(story, appStore.getState()); + // The Ink files use `== arrival ==`, `== mid ==`, `== farewell ==` + // as their entry knots. ChoosePathString navigates to the named + // knot before the first Continue() call. + story.ChoosePathString(pending); + setRuntime(createInkRuntime(story)); + } catch (err) { + // Fail-soft: log + close. The gate's pending flag stays set so + // the player can re-try (rather than losing the beat to a + // silent failure). + console.error('[LuraDialogue] failed to load beat', pending, err); + setDialogueOverlayOpen(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, pending, setDialogueOverlayOpen]); + + if (!open) return null; + + const onClose = (): void => { + setDialogueOverlayOpen(false); + // Resolve in the store so the visited flag updates and `pending` + // clears. Read fresh state (not the memoized hook value) so we + // don't carry a stale pending identifier. + setLuraBeatProgress( + resolvePendingLuraBeat(appStore.getState().luraBeatProgress), + ); + }; + + return ( +
e.stopPropagation()} + style={{ + position: 'fixed', + inset: 0, + zIndex: 85, + background: '#1a1a1aee', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#e8e0d0', + fontFamily: 'serif', + }} + > +
+ {runtime ? ( + {}} /> + ) : ( +

...

+ )} + +
+
+ ); +} diff --git a/src/ui/dialogue/index.ts b/src/ui/dialogue/index.ts new file mode 100644 index 0000000..24c688a --- /dev/null +++ b/src/ui/dialogue/index.ts @@ -0,0 +1,15 @@ +/** + * Public barrel for src/ui/dialogue/. + * + * Per CORE-10: src/sim/** must NOT import from this module. The Ink + * runtime stays UI-tier (Architectural Responsibility Map line 40). + */ +export { LuraDialogue } from './LuraDialogue'; +export { InkRenderer } from './ink-renderer'; +export { + createInkRuntime, + DEFAULT_DELAY_MS, + PER_CHAR_MS, + MAX_DELAY_MS, +} from './ink-runtime'; +export type { InkRuntime } from './ink-runtime'; diff --git a/src/ui/dialogue/ink-renderer.tsx b/src/ui/dialogue/ink-renderer.tsx new file mode 100644 index 0000000..3a8337d --- /dev/null +++ b/src/ui/dialogue/ink-renderer.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import type { InkRuntime } from './ink-runtime'; + +/** + * InkRenderer — drives an InkRuntime and drips lines into the DOM with + * text-message cadence. Used by LuraDialogue (full-screen overlay). + * + * Phase 8's UX-05 reduced-motion toggle will short-circuit the cadence; + * the cleanest hook-point is to pass `skipDelay=true` from a parent + * component reading from the settings slice. For now Phase 2 ships the + * fixed cadence with a click-anywhere-to-skip affordance (calls + * runtime.skipDelay()). + * + * Selectable text — userSelect: 'text' on every

+ body — supports + * MEMR-05-style copy-paste from day one. Same posture as Plan 02-03's + * Memory Journal. + */ +export function InkRenderer({ + runtime, + onComplete, +}: { + runtime: InkRuntime; + onComplete?: () => void; +}): React.ReactElement { + const [lines, setLines] = useState([]); + const [choices, setChoices] = useState<{ index: number; text: string }[]>([]); + const cancelled = useRef(false); + const runRef = useRef(0); + + useEffect(() => { + cancelled.current = false; + runRef.current += 1; + const myRun = runRef.current; + setLines([]); + setChoices([]); + (async () => { + while (!cancelled.current && runRef.current === myRun) { + const line = await runtime.nextLine(); + if (cancelled.current || runRef.current !== myRun) return; + if (line === null) break; + if (line.trim().length > 0) { + setLines((prev) => [...prev, line.trim()]); + } + } + const cs = runtime.currentChoices(); + if (cs.length > 0) { + setChoices(cs); + return; + } + onComplete?.(); + })(); + return () => { + cancelled.current = true; + }; + }, [runtime, onComplete]); + + const onChoice = (index: number): void => { + runtime.chooseChoice(index); + setChoices([]); + // Re-run the loop to drain post-choice lines. + runRef.current += 1; + }; + + return ( +

runtime.skipDelay()} + style={{ cursor: 'pointer', userSelect: 'text' }} + > + {lines.map((line, i) => ( +

+ {line} +

+ ))} + {choices.length > 0 && ( +
+ {choices.map((c) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/ui/dialogue/ink-runtime.test.ts b/src/ui/dialogue/ink-runtime.test.ts new file mode 100644 index 0000000..d4c72ab --- /dev/null +++ b/src/ui/dialogue/ink-runtime.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Story } from 'inkjs'; +import { + createInkRuntime, + DEFAULT_DELAY_MS, + PER_CHAR_MS, + MAX_DELAY_MS, +} from './ink-runtime'; + +/** + * Ink runtime unit tests using a hand-rolled fake Story (no inkjs + * required). Vitest fake timers let us exercise the cadence delay + * without actually waiting wall-clock seconds. + */ + +interface FakeStory { + lines: string[]; + cursor: number; + choices: { index: number; text: string }[]; + chosen: number | null; +} + +interface FakeStoryHandle { + state: FakeStory; + canContinue: boolean; + currentChoices: { text: string; index: number }[]; + Continue: () => string | null; + ChooseChoiceIndex: (i: number) => void; + chosen: number | null; +} + +function makeStory( + lines: string[], + choices: { index: number; text: string }[] = [], +): FakeStoryHandle { + const state: FakeStory = { + lines: [...lines], + cursor: 0, + choices, + chosen: null, + }; + return { + state, + get canContinue() { + return state.cursor < state.lines.length; + }, + get currentChoices() { + // inkjs's `currentChoices` is exposed only when story has paused + // on a choice; our fake exposes it once content is exhausted. + return state.cursor >= state.lines.length ? state.choices : []; + }, + Continue() { + if (state.cursor >= state.lines.length) return null; + return state.lines[state.cursor++] ?? null; + }, + ChooseChoiceIndex(i: number) { + state.chosen = i; + }, + // chosen is read-through from the inner state mutation by + // ChooseChoiceIndex above. Tests assert against the live state. + get chosen() { + return state.chosen; + }, + }; +} + +describe('createInkRuntime', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns each line in order, then null', async () => { + const story = makeStory(['First line.', 'Second line.']); + const runtime = createInkRuntime(story as unknown as Story); + + const p1 = runtime.nextLine(); + await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100); + expect(await p1).toBe('First line.'); + + const p2 = runtime.nextLine(); + await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100); + expect(await p2).toBe('Second line.'); + + const p3 = runtime.nextLine(); + expect(await p3).toBeNull(); + }); + + it('canContinue reflects story state', async () => { + const story = makeStory(['only one']); + const runtime = createInkRuntime(story as unknown as Story); + expect(runtime.canContinue()).toBe(true); + const p = runtime.nextLine(); + await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100); + await p; + expect(runtime.canContinue()).toBe(false); + }); + + it('cadence delay scales with line length up to MAX_DELAY_MS', async () => { + const longLine = 'x'.repeat(500); // would be 1500 + 500*20 = 11500, capped at MAX + const story = makeStory([longLine]); + const runtime = createInkRuntime(story as unknown as Story); + const p = runtime.nextLine(); + + // Advance just-under MAX_DELAY_MS — promise must NOT resolve yet. + await vi.advanceTimersByTimeAsync(MAX_DELAY_MS - 1); + let resolved = false; + void p.then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); + + // Advance the rest — promise resolves. + await vi.advanceTimersByTimeAsync(2); + expect(await p).toBe(longLine); + }); + + it('skipDelay() makes the NEXT line resolve nearly immediately', async () => { + const story = makeStory(['short']); + const runtime = createInkRuntime(story as unknown as Story); + runtime.skipDelay(); + const p = runtime.nextLine(); + // No timer advance needed — skipDelay zeroes the delay. + await vi.advanceTimersByTimeAsync(0); + expect(await p).toBe('short'); + }); + + it('skipDelay() only affects the next line, not subsequent lines', async () => { + const story = makeStory(['a', 'b']); + const runtime = createInkRuntime(story as unknown as Story); + + runtime.skipDelay(); + const p1 = runtime.nextLine(); + await vi.advanceTimersByTimeAsync(0); + expect(await p1).toBe('a'); + + // Second line should respect the standard cadence. + const p2 = runtime.nextLine(); + let resolved = false; + void p2.then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(MAX_DELAY_MS + 100); + expect(await p2).toBe('b'); + }); + + it('chooseChoice forwards to the underlying story', async () => { + const story = makeStory( + [], + [ + { index: 0, text: 'first' }, + { index: 1, text: 'second' }, + ], + ); + const runtime = createInkRuntime(story as unknown as Story); + expect(runtime.currentChoices()).toEqual([ + { index: 0, text: 'first' }, + { index: 1, text: 'second' }, + ]); + runtime.chooseChoice(1); + expect(story.chosen).toBe(1); + }); + + it('cadence constants are stable + sensible', () => { + expect(DEFAULT_DELAY_MS).toBeGreaterThanOrEqual(500); + expect(PER_CHAR_MS).toBeGreaterThan(0); + expect(MAX_DELAY_MS).toBeGreaterThanOrEqual(DEFAULT_DELAY_MS); + }); +}); diff --git a/src/ui/dialogue/ink-runtime.ts b/src/ui/dialogue/ink-runtime.ts new file mode 100644 index 0000000..52b2337 --- /dev/null +++ b/src/ui/dialogue/ink-runtime.ts @@ -0,0 +1,70 @@ +import type { Story } from 'inkjs'; + +/** + * InkRuntime — thin wrapper around an inkjs Story that yields lines one + * at a time with a tunable text-message-cadence delay. + * + * Used by InkRenderer (which drives the loop). Phase 2 ships fixed + * cadence values; Phase 8's UX-05 reduced-motion toggle short-circuits + * the delay (skipDelay-on-every-line equivalent). The cadence values + * are tuned in playtest — see Plan 02-04 SUMMARY.md "Cadence values" + * for the chosen baseline. + */ +export interface InkRuntime { + /** Pull the next available line; resolves after the cadence delay. Returns null at story end. */ + nextLine(): Promise; + /** Are there more lines or choices available? */ + canContinue(): boolean; + /** Current choices, if the story has paused on a choice point. */ + currentChoices(): { index: number; text: string }[]; + /** Pick a choice and resume. */ + chooseChoice(index: number): void; + /** Skip the cadence delay on the next line (e.g., player tap-to-advance). */ + skipDelay(): void; +} + +/** + * Cadence baseline. Phase 2 values; tunable in playtest. The actual + * delay for a line is `min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS)`. + * + * For a typical 80-char line: 1500 + 80*20 = 3100ms (capped at 4000ms). + * For a short 10-char line: 1500 + 10*20 = 1700ms. + * For a one-word "Oh.": 1500 + 3*20 = 1560ms. + * + * Documented here so playtesters can adjust without spelunking through + * the renderer. + */ +export const DEFAULT_DELAY_MS = 1500; +export const PER_CHAR_MS = 20; +export const MAX_DELAY_MS = 4000; + +export function createInkRuntime(story: Story): InkRuntime { + let skipNext = false; + return { + async nextLine(): Promise { + if (!story.canContinue) return null; + const line = story.Continue(); + if (line === null) return null; + const delay = skipNext + ? 0 + : Math.min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS); + skipNext = false; + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + return line; + }, + canContinue(): boolean { + return story.canContinue; + }, + currentChoices(): { index: number; text: string }[] { + return story.currentChoices.map((c, i) => ({ index: i, text: c.text })); + }, + chooseChoice(index: number): void { + story.ChooseChoiceIndex(index); + }, + skipDelay(): void { + skipNext = true; + }, + }; +} diff --git a/src/ui/index.ts b/src/ui/index.ts index ccb50c2..e27c0b0 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -8,3 +8,4 @@ export * from './begin'; export * from './garden'; export * from './journal'; +export * from './dialogue';