import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { audio } from './audio' export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null } export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null } export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } function usePrefersReducedMotion() { const [reduced, setReduced] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) useEffect(() => { const query = window.matchMedia?.('(prefers-reduced-motion: reduce)') if (!query) return const listener = (event: MediaQueryListEvent) => setReduced(event.matches) query.addEventListener('change', listener) return () => query.removeEventListener('change', listener) }, []) return reduced } export function SplashScreen({ hasResume, busy, status, onNewGame, onResume }: { hasResume: boolean; busy: boolean; status?: string; onNewGame: () => void; onResume: () => void }) { return
GU

PRINCIPAL INVESTIGATOR

Glitch University

{hasResume && }
{busy ? 'OPENING CASE FILE…' : status || 'GLITCH UNIVERSITY NETWORK TERMINAL'}
} // Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry). const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
Greyhaven file 87-10

The Glass Harbour Diversion

) const CUTSCENE_REGISTRY: Record void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion } export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY) export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) { const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined if (Component) return return

{label}

{componentKey ? `component "${componentKey}" not registered` : 'no component set'}
} // 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, 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(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 const fullText = current?.text ?? '' const done = charCount >= fullText.length const children = current ? current.childIds.map(id => byId.get(id)).filter((c): c is RuntimeUtterance => Boolean(c)) : [] const options = children.filter(c => c.utterer === 'player') const showChoices = done && options.length > 0 useEffect(() => { if (!current) { onExit(); return } if (reduced) { setCharCount(fullText.length); return } setCharCount(0) const id = window.setInterval(() => setCharCount(count => (count >= fullText.length ? count : count + 1)), 18) return () => window.clearInterval(id) }, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps const pick = (choice: RuntimeUtterance) => { if (!inline) audio.sfx('choice') if (choice.childIds.length > 0) setCurrentId(choice.childIds[0]) else onExit(choice.terminalKey ?? undefined) } const proceedRef = useRef(() => {}) proceedRef.current = () => { if (!current) { onExit(); return } if (!done) { setCharCount(fullText.length); return } if (children.length === 0) { onExit(current.terminalKey ?? undefined); return } if (options.length > 0) return // a branch — wait for a choice if (!inline) audio.sfx('advance') 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, inline]) if (!current) return null return
{ if (!showChoices) proceedRef.current() }}>
{current.poseUrl && {current.speaker.name}}
{current.speaker.name}{current.speaker.role && {current.speaker.role}}

{fullText.slice(0, charCount)}{done ? '' : '▍'}

{showChoices ?
{options.map(option => )}
:
{done ? 'CONTINUE ▸' : ''}
}
} // 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
}
}