From 0b1e5e7fc7fd14b8e8f7d7510a497a7e07c11885 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Tue, 18 Aug 2026 19:24:03 +0200 Subject: [PATCH] Live dialogue preview in the editors (reuses the real DialoguePlayer) - DialoguePlayer gains an `inline` mode (fills its container, no window key capture) and a `startId` to jump the walk to a given utterance. - New DialoguePreview: a scaled-down mini player that fetches the resolved tree from GET /api/admin/story-nodes/:id/dialogue (same resolver as playback). - Mystery graph: preview appears next to a selected dialogue node. - Utterance editor: docked preview that jumps to the clicked/selected utterance and refreshes after each edit. Co-Authored-By: Claude Opus 4.8 --- server/index.ts | 4 ++++ server/narrativeRepository.ts | 3 +++ src/mysteryGraph.tsx | 6 +++++- src/narrative.tsx | 32 ++++++++++++++++++++++++++++---- src/styles.css | 13 +++++++++++++ src/utteranceCanvas.tsx | 7 ++++++- 6 files changed, 59 insertions(+), 6 deletions(-) diff --git a/server/index.ts b/server/index.ts index 8892472..cadcced 100644 --- a/server/index.ts +++ b/server/index.ts @@ -289,6 +289,10 @@ app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => { try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) } }) +// Resolved runtime dialogue tree for the editor's live preview (same resolver as play). +app.get('/api/admin/story-nodes/:id/dialogue', requireAdmin, async (req, res, next) => { + try { res.json(await narrative.resolveDialogue(String(req.params.id))) } catch (error) { next(error) } +}) app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => { try { if (!requireEditing(res)) return diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index 8b995fb..99b8d7b 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -43,6 +43,7 @@ export function resolvePoseAssetId( export interface NarrativeRepository { authorMystery(input: MysteryAuthoring): Promise<{ slug: string }> + resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> createPlaythrough(userId: string, mysterySlug?: string): Promise getCurrentPlaythrough(userId: string): Promise advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> @@ -197,6 +198,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora return { slug: input.slug } }, + resolveDialogue(nodeId) { return resolveDialogueGraph(nodeId) }, + async createPlaythrough(userId, mysterySlug) { const client = await pool.connect() let playthroughId: string diff --git a/src/mysteryGraph.tsx b/src/mysteryGraph.tsx index 1ae2919..eddff29 100644 --- a/src/mysteryGraph.tsx +++ b/src/mysteryGraph.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { UtteranceCanvas } from './utteranceCanvas' -import { CUTSCENE_COMPONENT_KEYS } from './narrative' +import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative' type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } @@ -140,6 +140,10 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m )} )} + {selected && (selected.nodeType === 'dialogue' || selected.hasUtterances) && +
event.stopPropagation()}> + +
} {graph.nodes.length === 0 &&
Empty graph — add a node to begin.
} diff --git a/src/narrative.tsx b/src/narrative.tsx index 54dd63c..83acc81 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -61,9 +61,11 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey // Walk a dialogue node's utterance tree: play NPC lines, present player options at a // branch, follow a chosen option to the next line or out through its exit terminal. -export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void }) { +export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) { const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances]) - const [currentId, setCurrentId] = useState(node.rootId) + const [currentId, setCurrentId] = useState(startId ?? node.rootId) + // In preview, clicking an utterance card jumps the walk to that line. + useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId]) const [charCount, setCharCount] = useState(0) const reduced = usePrefersReducedMotion() const current = currentId ? byId.get(currentId) ?? null : null @@ -94,15 +96,16 @@ export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUt setCurrentId(children[0].id) // linear next line } useEffect(() => { + if (inline) return // preview advances by click only, so it never steals the editor's keys const onKey = (event: KeyboardEvent) => { if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [showChoices]) + }, [showChoices, inline]) if (!current) return null - return
{ if (!showChoices) proceedRef.current() }}> + return
{ if (!showChoices) proceedRef.current() }}>
{current.poseUrl && {current.speaker.name}}
@@ -116,3 +119,24 @@ export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUt
} + +// A live, scaled-down mini player for the editor — runs the real DialoguePlayer +// against the same server resolver, so it reflects the current authored dialogue. +export function DialoguePreview({ nodeId, startId, revision, onClose }: { nodeId: string; startId?: string | null; revision?: number; onClose?: () => void }) { + const [tree, setTree] = useState<{ utterances: RuntimeUtterance[]; rootId: string | null } | null>(null) + const [playKey, setPlayKey] = useState(0) + useEffect(() => { + fetch(`/api/admin/story-nodes/${nodeId}/dialogue`).then(response => response.ok ? response.json() : null).then(setTree).catch(() => setTree(null)) + }, [nodeId, revision]) + return
event.stopPropagation()}> +
preview + + {onClose && } +
+
+ {tree?.rootId + ?
setPlayKey(key => key + 1)} />
+ :
no utterances yet
} +
+
+} diff --git a/src/styles.css b/src/styles.css index ddd50b0..b263b24 100644 --- a/src/styles.css +++ b/src/styles.css @@ -574,3 +574,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .asset-actions button:hover { background: #1c463a; color: #e4e9e4; } .asset-actions .asset-del { flex: 0 0 26px; } .asset-actions .asset-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; } + +/* Live dialogue preview (mini player, real DialoguePlayer scaled down) */ +.dialogue.inline { position: absolute; inset: 0; width: 100%; height: 100%; animation: none; } +.dialogue-preview { width: 340px; background: #0a211d; border: 1px solid #40655b; box-shadow: 4px 6px 0 #04110e88; } +.dialogue-preview-bar { display: flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; background: #0e2a24; border-bottom: 1px solid #24413a; font: 8px IBM Plex Mono; letter-spacing: .16em; color: #86a199; text-transform: uppercase; } +.dialogue-preview-bar span { margin-right: auto; } +.dialogue-preview-bar button { background: none; border: 0; color: #9bb0a9; cursor: pointer; font-size: 12px; line-height: 1; padding: 0 2px; } +.dialogue-preview-bar button:hover { color: #e7b57e; } +.dialogue-preview-stage { position: relative; width: 340px; height: 191px; overflow: hidden; cursor: pointer; background: #06140f; } +.dialogue-preview-scale { position: absolute; top: 0; left: 0; width: 1020px; height: 573px; transform: scale(0.33333); transform-origin: top left; } +.dialogue-preview-empty { display: grid; place-items: center; height: 100%; color: #5f7b73; font: 9px IBM Plex Mono; } +.graph-node-preview { position: absolute; z-index: 6; } +.utterance-preview-dock { position: absolute; left: 16px; bottom: 16px; z-index: 6; } diff --git a/src/utteranceCanvas.tsx b/src/utteranceCanvas.tsx index dc683b8..514d7be 100644 --- a/src/utteranceCanvas.tsx +++ b/src/utteranceCanvas.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react' +import { DialoguePreview } from './narrative' type Utterer = 'npc' | 'player' type Utterance = { @@ -34,8 +35,9 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat const cardRefs = useRef(new Map()) const [heights, setHeights] = useState>({}) + const [revision, setRevision] = useState(0) const reload = useCallback(async () => { - try { setUtterances(await api(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) } + try { setUtterances(await api(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')); setRevision(r => r + 1) } catch (error) { setStatus(String((error as Error).message || error)) } }, [nodeId, setStatus]) useEffect(() => { void reload(); api('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload]) @@ -205,6 +207,9 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
)}
{utterances.length === 0 &&
No utterances yet — add an NPC line or player choice.
} +
event.stopPropagation()}> + +
{selected &&