feat(02-04): Lura dialogue overlay + Ink runtime + gate visual + Garden scene wiring

- src/ui/dialogue/ink-runtime.ts: thin wrapper around inkjs Story —
  nextLine() with text-message cadence (1500ms base + 20ms/char, capped
  at 4000ms), skipDelay() for tap-to-advance, choice surface forwarded
  to ChooseChoiceIndex. Constants exported for Plan 02-05's UX-05
  reduced-motion hook + playtest tuning.
- src/ui/dialogue/ink-runtime.test.ts: 7 cases pinning the cadence
  bounds, skipDelay one-shot semantics, choice forwarding (uses
  vi.useFakeTimers() to validate timing without wall-clock waits).
- src/ui/dialogue/ink-renderer.tsx: drips lines into the DOM as the
  runtime yields them; userSelect: 'text' for MEMR-05 copy-paste;
  click-anywhere skip; choice buttons stop event propagation.
- src/ui/dialogue/LuraDialogue.tsx: D-15 — full-screen DOM overlay
  driven by dialogueOverlayOpen + luraBeatProgress.pending. Loads the
  compiled Ink JSON via loadInkStory, binds variables from store
  snapshot, ChoosePathString into the named knot ('arrival'/'mid'/
  'farewell'), then runs InkRenderer. Close button calls
  resolvePendingLuraBeat to mark visited and clear pending.
- src/ui/dialogue/LuraDialogue.test.tsx: 6 cases — closed-state null,
  dialog renders on open+pending, Close fires resolvePendingLuraBeat
  for all three beats, loadInkStory called with correct beat name +
  knot. Mocks the loadInkStory + ink-runtime layer to keep happy-dom
  out of inkjs internals (Plan 02-05 e2e exercises the live path).
- src/render/garden/gate-renderer.ts: drawGate() + updateGateIndicator()
  — Phaser primitive gate (body / glow / hit) at canvas (880, 384).
  Glow alpha-pulses via Sine-yoyo tween when isPending=true; idempotent.
- src/game/scenes/Garden.ts: gate added in create(); pointerdown
  dispatches setDialogueOverlayOpen(true) only when a beat is pending.
  storeUnsubscribe also drives updateGateIndicator on luraBeatProgress
  changes. update() loop now calls simAdapter.applyLuraProgress when
  the sim's luraBeatProgress differs from the store's so harvests
  trigger the gate indicator. destroy() cleans up the gate's tween.
- src/App.tsx: <LuraDialogue /> mounted as DOM sibling of PhaserGame.
- src/ui/index.ts + src/render/garden/index.ts: re-exports.

13 new tests across dialogue layer; 264/264 total green; npm run ci
exits 0; Vite emits 4 lazy ink-*.js chunks (compiled JSON code-split
per file); ESLint sim-purity rule still green (sim/narrative imports
no inkjs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:33:22 -04:00
parent 7b79d11584
commit 661f990e9a
11 changed files with 792 additions and 2 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
import { BeginScreen } from './ui/begin'; import { BeginScreen } from './ui/begin';
import { SeedPicker } from './ui/garden'; import { SeedPicker } from './ui/garden';
import { FragmentRevealModal, JournalIcon } from './ui/journal'; import { FragmentRevealModal, JournalIcon } from './ui/journal';
import { LuraDialogue } from './ui/dialogue';
function App() { function App() {
// PhaserGame ref — Phase 2+ will use this to access the active scene from React. // PhaserGame ref — Phase 2+ will use this to access the active scene from React.
@@ -15,7 +16,7 @@ function App() {
<SeedPicker /> <SeedPicker />
<FragmentRevealModal /> <FragmentRevealModal />
<JournalIcon /> <JournalIcon />
{/* Plan 02-04 mounts: <LuraDialogue /> */} <LuraDialogue />
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */} {/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
</div> </div>
); );
+52 -1
View File
@@ -14,8 +14,11 @@ import {
destroyPlant, destroyPlant,
applyReadyPulse, applyReadyPulse,
tileCenterToDom, tileCenterToDom,
drawGate,
updateGateIndicator,
type TileGameObjects, type TileGameObjects,
type PlantGameObject, type PlantGameObject,
type GateGameObjects,
} from '../../render/garden'; } from '../../render/garden';
import { appStore, simAdapter } from '../../store'; import { appStore, simAdapter } from '../../store';
import { fragments as allFragments } from '../../content'; import { fragments as allFragments } from '../../content';
@@ -57,6 +60,7 @@ export class Garden extends Phaser.Scene {
private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map(); private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map();
private storeUnsubscribe: (() => void) | null = null; private storeUnsubscribe: (() => void) | null = null;
private simContext: SimContext = { fragments: [], currentSeason: 1 }; private simContext: SimContext = { fragments: [], currentSeason: 1 };
private gate: GateGameObjects | null = null;
constructor() { constructor() {
super('Garden'); super('Garden');
@@ -85,14 +89,42 @@ export class Garden extends Phaser.Scene {
t.hit.on('pointerdown', () => this.handleTilePointerDown(idx)); 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(); this.lastFrameMs = this.clock.now();
// Re-render plants when tiles change in the store (Pitfall 6 mitigation: // 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.storeUnsubscribe = appStore.subscribe((state) => {
this.repaintPlants(state.tiles as Tile[]); this.repaintPlants(state.tiles as Tile[]);
if (this.gate) {
updateGateIndicator(
this,
this.gate,
state.luraBeatProgress.pending !== null,
);
}
}); });
this.repaintPlants(appStore.getState().tiles as Tile[]); this.repaintPlants(appStore.getState().tiles as Tile[]);
if (this.gate) {
updateGateIndicator(
this,
this.gate,
appStore.getState().luraBeatProgress.pending !== null,
);
}
eventBus.emit('scene-ready', this); eventBus.emit('scene-ready', this);
} }
@@ -154,6 +186,21 @@ export class Garden extends Phaser.Scene {
appStore.getState().setFragmentRevealId(newId); 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.forEach((t) => t.stop());
this.readyTweens.clear(); this.readyTweens.clear();
this.plantObjs.forEach((p) => destroyPlant(p)); this.plantObjs.forEach((p) => destroyPlant(p));
if (this.gate?.glowTween) {
this.gate.glowTween.stop();
this.gate.glowTween = null;
}
this.plantObjs.clear(); this.plantObjs.clear();
} }
} }
+95
View File
@@ -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);
}
}
+2
View File
@@ -7,3 +7,5 @@ export { drawPlant, destroyPlant } from './plant-renderer';
export type { PlantGameObject } from './plant-renderer'; export type { PlantGameObject } from './plant-renderer';
export { applyReadyPulse } from './ready-pulse'; export { applyReadyPulse } from './ready-pulse';
export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords'; export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords';
export { drawGate, updateGateIndicator } from './gate-renderer';
export type { GateGameObjects } from './gate-renderer';
+145
View File
@@ -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<string, unknown>,
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(<LuraDialogue />);
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(<LuraDialogue />);
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(<LuraDialogue />);
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(<LuraDialogue />);
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(<LuraDialogue />);
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(<LuraDialogue />);
await waitFor(() => {
expect(loadInkStory).toHaveBeenCalledWith('lura-mid');
});
expect(fakeStoryProto.ChoosePathString).toHaveBeenCalledWith('mid');
});
});
+126
View File
@@ -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<InkRuntime | null>(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 (
<div
role="dialog"
aria-label="Lura at the gate"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed',
inset: 0,
zIndex: 85,
background: '#1a1a1aee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<div
style={{
maxWidth: 600,
padding: '2rem',
maxHeight: '80vh',
overflowY: 'auto',
}}
>
{runtime ? (
<InkRenderer runtime={runtime} onComplete={() => {}} />
) : (
<p style={{ opacity: 0.5 }}>...</p>
)}
<button
onClick={onClose}
style={{
marginTop: '2rem',
padding: '0.5rem 1.4rem',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #e8e0d0',
cursor: 'pointer',
fontFamily: 'serif',
}}
>
Close
</button>
</div>
</div>
);
}
+15
View File
@@ -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';
+110
View File
@@ -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 <p> + 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<string[]>([]);
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 (
<div
onClick={() => runtime.skipDelay()}
style={{ cursor: 'pointer', userSelect: 'text' }}
>
{lines.map((line, i) => (
<p
key={i}
style={{
margin: '0.6rem 0',
fontSize: '1.05rem',
lineHeight: 1.6,
userSelect: 'text',
}}
>
{line}
</p>
))}
{choices.length > 0 && (
<div style={{ marginTop: '1rem' }}>
{choices.map((c) => (
<button
key={c.index}
onClick={(e) => {
e.stopPropagation();
onChoice(c.index);
}}
style={{
display: 'block',
margin: '0.4rem 0',
background: 'transparent',
color: '#e8e0d0',
border: '1px solid #4d4d52',
padding: '0.4rem 0.8rem',
cursor: 'pointer',
fontFamily: 'serif',
textAlign: 'left',
}}
>
{c.text}
</button>
))}
</div>
)}
</div>
);
}
+174
View File
@@ -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);
});
});
+70
View File
@@ -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<string | null>;
/** 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<string | null> {
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<void>((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;
},
};
}
+1
View File
@@ -8,3 +8,4 @@
export * from './begin'; export * from './begin';
export * from './garden'; export * from './garden';
export * from './journal'; export * from './journal';
export * from './dialogue';