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:
+2
-1
@@ -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() {
|
||||
<SeedPicker />
|
||||
<FragmentRevealModal />
|
||||
<JournalIcon />
|
||||
{/* Plan 02-04 mounts: <LuraDialogue /> */}
|
||||
<LuraDialogue />
|
||||
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<number, Phaser.Tweens.Tween> = 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,3 +8,4 @@
|
||||
export * from './begin';
|
||||
export * from './garden';
|
||||
export * from './journal';
|
||||
export * from './dialogue';
|
||||
|
||||
Reference in New Issue
Block a user