2026-08-18 15:37:46 +02:00
import { useEffect , useMemo , useRef , useState , type FC } from 'react'
2026-08-18 19:51:44 +02:00
import { audio } from './audio'
2026-08-18 15:37:46 +02:00
2026-08-22 18:34:52 +02:00
export type RuntimeUtterance = { id : string ; utterer : 'npc' | 'player' ; speaker : { name : string ; role : string }; poseUrl : string | null ; text : string ; childIds : string []; terminalKey : string | null ; awardsFlag? : string | null }
2026-08-22 16:11:53 +02:00
export type RuntimeNode = { id : string ; kind : 'cutscene' | 'dialogue' | 'level' | 'merit' ; label : string ; componentKey? : string | null ; levelSlug? : string | null ; musicUrl? : string | null ; musicVolume? : number ; awardsFlag? : string | null ; utterances? : RuntimeUtterance []; rootId? : string | null }
2026-08-18 15:37:46 +02:00
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 }
2026-08-18 19:05:29 +02:00
export const CUTSCENE_COMPONENT_KEYS = Object . keys ( CUTSCENE_REGISTRY )
2026-08-18 15:37:46 +02:00
2026-08-22 16:11:53 +02:00
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
// award model). The achievement itself is granted server-side on arrival; this is
// purely the presentation of receiving it.
const MERIT_REGISTRY : Record < string , FC < { label : string ; onComplete : () = > void } >> = {}
export const MERIT_COMPONENT_KEYS = Object . keys ( MERIT_REGISTRY )
export function MeritHost ({ componentKey , label , awardsFlag , onComplete } : { componentKey : string | null | undefined ; label : string ; awardsFlag? : string | null ; onComplete : () => void }) {
const Component = componentKey ? MERIT_REGISTRY [ componentKey ] : undefined
if ( Component ) return < Component label = { label } onComplete = { onComplete } />
return < div className = "cutscene-card merit-card" onClick = { onComplete }>
< div className = "title-card-inner" >
< small className = "merit-eyebrow" > ◆ MERIT AWARDED ◆ </ small >
< h1 >{ label }</ h1 >
{ awardsFlag && < p className = "merit-flag" > 🏅 { awardsFlag }</ p >}
< button className = "cutscene-begin" onClick = { event => { event . stopPropagation (); onComplete () }}> Accept ▸ </ button >
</ div >
</ div >
}
2026-08-18 15:37:46 +02:00
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.
2026-08-22 20:11:15 +02:00
export function DialoguePlayer ({ node , onExit , onAward , onCapture , inline , startId } : { node : { utterances : RuntimeUtterance []; rootId : string | null }; onExit : ( terminalKey? : string ) => void ; onAward ?: ( utteranceId : string ) => void ; onCapture ?: ( text : string , utteranceId : string ) => void ; inline? : boolean ; startId? : string | null }) {
2026-08-18 15:37:46 +02:00
const byId = useMemo (() => new Map ( node . utterances . map ( u => [ u . id , u ])), [ node . utterances ])
2026-08-18 19:24:03 +02:00
const [ currentId , setCurrentId ] = useState < string | null >( startId ?? node . rootId )
2026-08-22 18:34:52 +02:00
const onAwardRef = useRef ( onAward )
onAwardRef . current = onAward
2026-08-18 19:24:03 +02:00
// In preview, clicking an utterance card jumps the walk to that line.
useEffect (() => { if ( startId !== undefined ) setCurrentId ( startId ?? node . rootId ) }, [ startId , node . rootId ])
2026-08-18 15:37:46 +02:00
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
2026-08-18 20:24:06 +02:00
// 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
2026-08-22 18:34:52 +02:00
// Grant a line's authored achievement when it becomes current (play mode only).
useEffect (() => {
if ( inline || ! currentId ) return
const utterance = byId . get ( currentId )
if ( utterance ? . awardsFlag ) onAwardRef . current ? .( utterance . id )
}, [ currentId , inline , byId ])
2026-08-18 15:37:46 +02:00
const pick = ( choice : RuntimeUtterance ) => {
2026-08-18 19:51:44 +02:00
if ( ! inline ) audio . sfx ( 'choice' )
2026-08-22 18:34:52 +02:00
if ( ! inline && choice . awardsFlag ) onAwardRef . current ? .( choice . id )
2026-08-18 15:37:46 +02:00
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
2026-08-18 19:51:44 +02:00
if ( ! inline ) audio . sfx ( 'advance' )
2026-08-18 15:37:46 +02:00
setCurrentId ( children [ 0 ]. id ) // linear next line
}
useEffect (() => {
2026-08-18 19:24:03 +02:00
if ( inline ) return // preview advances by click only, so it never steals the editor's keys
2026-08-18 15:37:46 +02:00
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 )
2026-08-18 19:24:03 +02:00
}, [ showChoices , inline ])
2026-08-18 15:37:46 +02:00
if ( ! current ) return null
2026-08-18 19:24:03 +02:00
return < div className = { `dialogue ${ inline ? ' inline' : '' } ` } role = "dialog" aria-label = "Dialogue" onClick = {() => { if ( ! showChoices ) proceedRef . current () }}>
2026-08-18 15:37:46 +02:00
< 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" >
2026-08-22 20:11:15 +02:00
< div className = "dialogue-speaker" >< strong >{ current . speaker . name }</ strong >{ current . speaker . role && < em >{ current . speaker . role }</ em >}
{ onCapture && ! inline && current . utterer === 'npc' && done && < button className = "dialogue-capture" title = "Copy to notebook" onClick = { event => { event . stopPropagation (); onCapture ( current . text , current . id ) }}> ✎ Note this </ button >}
</ div >
2026-08-18 15:37:46 +02:00
< 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 >
}
2026-08-18 19:24:03 +02:00
// 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 >
}