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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:24:03 +02:00
co-authored by Claude Opus 4.8
parent f10d7584d2
commit 0b1e5e7fc7
6 changed files with 59 additions and 6 deletions
+4
View File
@@ -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) => { 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) } 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) => { app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
try { try {
if (!requireEditing(res)) return if (!requireEditing(res)) return
+3
View File
@@ -43,6 +43,7 @@ export function resolvePoseAssetId(
export interface NarrativeRepository { export interface NarrativeRepository {
authorMystery(input: MysteryAuthoring): Promise<{ slug: string }> authorMystery(input: MysteryAuthoring): Promise<{ slug: string }>
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }>
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null> createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null> getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> 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 } return { slug: input.slug }
}, },
resolveDialogue(nodeId) { return resolveDialogueGraph(nodeId) },
async createPlaythrough(userId, mysterySlug) { async createPlaythrough(userId, mysterySlug) {
const client = await pool.connect() const client = await pool.connect()
let playthroughId: string let playthroughId: string
+5 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { UtteranceCanvas } from './utteranceCanvas' 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 StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } 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
</div>)} </div>)}
</div> </div>
</div>)} </div>)}
{selected && (selected.nodeType === 'dialogue' || selected.hasUtterances) &&
<div className="graph-node-preview" style={{ left: selected.xpos + NODE_W + 28, top: selected.ypos }} onClick={event => event.stopPropagation()}>
<DialoguePreview nodeId={selected.id} />
</div>}
</div> </div>
{graph.nodes.length === 0 && <div className="graph-empty">Empty graph add a node to begin.</div>} {graph.nodes.length === 0 && <div className="graph-empty">Empty graph add a node to begin.</div>}
</div> </div>
+28 -4
View File
@@ -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 // 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. // 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 byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
const [currentId, setCurrentId] = useState<string | null>(node.rootId) const [currentId, setCurrentId] = useState<string | null>(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 [charCount, setCharCount] = useState(0)
const reduced = usePrefersReducedMotion() const reduced = usePrefersReducedMotion()
const current = currentId ? byId.get(currentId) ?? null : null 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 setCurrentId(children[0].id) // linear next line
} }
useEffect(() => { useEffect(() => {
if (inline) return // preview advances by click only, so it never steals the editor's keys
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() } if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [showChoices]) }, [showChoices, inline])
if (!current) return null if (!current) return null
return <div className="dialogue" role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}> return <div className={`dialogue${inline ? ' inline' : ''}`} role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}>
<div className="dialogue-portrait">{current.poseUrl && <img src={current.poseUrl} alt={current.speaker.name} />}</div> <div className="dialogue-portrait">{current.poseUrl && <img src={current.poseUrl} alt={current.speaker.name} />}</div>
<div className="dialogue-scrim" aria-hidden /> <div className="dialogue-scrim" aria-hidden />
<div className="dialogue-box"> <div className="dialogue-box">
@@ -116,3 +119,24 @@ export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUt
</div> </div>
</div> </div>
} }
// 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 <div className="dialogue-preview" onPointerDown={event => event.stopPropagation()}>
<div className="dialogue-preview-bar"><span>preview</span>
<button title="Restart" onClick={event => { event.stopPropagation(); setPlayKey(key => key + 1) }}></button>
{onClose && <button title="Close" onClick={event => { event.stopPropagation(); onClose() }}>×</button>}
</div>
<div className="dialogue-preview-stage">
{tree?.rootId
? <div className="dialogue-preview-scale"><DialoguePlayer key={playKey} inline node={tree} startId={startId} onExit={() => setPlayKey(key => key + 1)} /></div>
: <div className="dialogue-preview-empty">no utterances yet</div>}
</div>
</div>
}
+13
View File
@@ -574,3 +574,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.asset-actions button:hover { background: #1c463a; color: #e4e9e4; } .asset-actions button:hover { background: #1c463a; color: #e4e9e4; }
.asset-actions .asset-del { flex: 0 0 26px; } .asset-actions .asset-del { flex: 0 0 26px; }
.asset-actions .asset-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; } .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; }
+6 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react' import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { DialoguePreview } from './narrative'
type Utterer = 'npc' | 'player' type Utterer = 'npc' | 'player'
type Utterance = { type Utterance = {
@@ -34,8 +35,9 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
const cardRefs = useRef(new Map<string, HTMLDivElement>()) const cardRefs = useRef(new Map<string, HTMLDivElement>())
const [heights, setHeights] = useState<Record<string, number>>({}) const [heights, setHeights] = useState<Record<string, number>>({})
const [revision, setRevision] = useState(0)
const reload = useCallback(async () => { const reload = useCallback(async () => {
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) } try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')); setRevision(r => r + 1) }
catch (error) { setStatus(String((error as Error).message || error)) } catch (error) { setStatus(String((error as Error).message || error)) }
}, [nodeId, setStatus]) }, [nodeId, setStatus])
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload]) useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
@@ -205,6 +207,9 @@ export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStat
</div>)} </div>)}
</div> </div>
{utterances.length === 0 && <div className="graph-empty">No utterances yet add an NPC line or player choice.</div>} {utterances.length === 0 && <div className="graph-empty">No utterances yet add an NPC line or player choice.</div>}
<div className="utterance-preview-dock" onPointerDown={event => event.stopPropagation()}>
<DialoguePreview nodeId={nodeId} revision={revision} startId={selected ? (selected.utterer === 'player' ? selected.parentUtteranceId : selected.id) : null} />
</div>
</div> </div>
{selected && <aside className="graph-inspector"> {selected && <aside className="graph-inspector">