Files
gupi-osint-board/src/narrative.tsx
T
gitprovandClaude Opus 4.8 1b3ff1e78c Add per-node scene music volume control
Author a music track and volume (0-100%) per story node; the runtime
scales it to 0-1 and applies it without restarting the track when only
the level changes. Adds migration 024, threads music_volume through the
repository/runtime, and gives the node inspector a volume slider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 21:02:16 +02:00

153 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; musicVolume?: number; 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 <div className="splash">
<div className="splash-plate">
<div className="seal">GU</div>
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
<p className="splash-sub">Glitch University</p>
<div className="splash-actions">
{hasResume && <button className="splash-button" disabled={busy} onClick={onResume}>RESUME</button>}
<button className="splash-button primary" disabled={busy} onClick={onNewGame}>NEW GAME</button>
</div>
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status || 'GLITCH UNIVERSITY NETWORK TERMINAL'}</small>
</div>
</div>
}
// Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry).
const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
<div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<small>Greyhaven file 87-10</small>
<h1>The Glass Harbour Diversion</h1>
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Begin </button>
</div>
</div>
)
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => 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 <Component onComplete={onComplete} />
return <div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<h1>{label}</h1>
<small className="cutscene-missing">{componentKey ? `component "${componentKey}" not registered` : 'no component set'}</small>
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Continue </button>
</div>
</div>
}
// 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<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 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
// Typewriter clatter as characters are revealed (every other non-space char).
useEffect(() => {
if (inline || charCount === 0 || charCount > fullText.length) return
const ch = fullText[charCount - 1]
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
}, [charCount]) // 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 <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-scrim" aria-hidden />
<div className="dialogue-box">
<div className="dialogue-panel">
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
{showChoices
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
: <div className="dialogue-advance">{done ? 'CONTINUE ▸' : ''}</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>
}