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-23 22:40:43 +02:00
import { BarricelliLuggageMerit } from './merits'
2026-08-23 23:20:40 +02:00
import type { CutscenePresentation } from './narrativeContract'
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-23 23:20:40 +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 ; presentation? : CutscenePresentation | 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).
2026-08-23 23:20:40 +02:00
type CutsceneComponentProps = { label : string ; presentation? : CutscenePresentation | null ; onComplete : () => void }
const GlassHarbourDiversion : FC < CutsceneComponentProps > = ({ onComplete }) => (
2026-08-18 15:37:46 +02:00
< 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 >
)
2026-08-23 23:20:40 +02:00
const CaseAdjudication : FC < CutsceneComponentProps > = ({ label , presentation , onComplete }) => {
const reducedMotion = usePrefersReducedMotion ()
const [ phase , setPhase ] = useState < 'reviewing' | 'finding' | 'stamped' > ( reducedMotion ? 'stamped' : 'reviewing' )
const finding = presentation ? . kind === 'case-adjudication' ? presentation . finding : 'SUPPORTED BY THE SUBMITTED EVIDENCE'
const [ findingCharacters , setFindingCharacters ] = useState ( reducedMotion ? finding.length : 0 )
const claims = presentation ? . kind === 'case-adjudication' ? presentation . claims : [{ statement : label , evidence : [] }]
const stampText = presentation ? . kind === 'case-adjudication' ? presentation . stampText : 'CASE VERIFIED'
useEffect (() => {
if ( reducedMotion ) return
const findingTimer = window . setTimeout (() => setPhase ( 'finding' ), 650 )
const stampTimer = window . setTimeout (() => { setPhase ( 'stamped' ); audio . sfx ( 'sting' ) }, 2850 )
return () => { window . clearTimeout ( findingTimer ); window . clearTimeout ( stampTimer ) }
}, [ reducedMotion ])
useEffect (() => {
if ( phase === 'reviewing' || findingCharacters >= finding . length ) return
if ( reducedMotion ) { setFindingCharacters ( finding . length ); return }
const timer = window . setTimeout (() => {
setFindingCharacters ( count => Math . min ( finding . length , count + 1 ))
if ( findingCharacters % 3 === 0 ) audio . type ()
}, 22 )
return () => window . clearTimeout ( timer )
}, [ phase , finding , findingCharacters , reducedMotion ])
return < div className = { `case-adjudication phase- ${ phase } ` } role = "dialog" aria-label = "Case adjudication" >
< div className = "adjudication-veil" aria-hidden = "true" />
< article className = "adjudication-paper" >
< header >< span > GLITCH UNIVERSITY </ span >< b > GUPI EVIDENCE REVIEW </ b >< small >{ presentation ? . kind === 'case-adjudication' ? presentation.reportTitle : label }</ small ></ header >
< div className = "adjudication-rule" />
{ claims . map (( claim , claimIndex ) => < section className = "adjudication-claim" key = { ` ${ claimIndex } : ${ claim . statement } ` }>
< h2 > CLAIM { String ( claimIndex + 1 ). padStart ( 2 , '0' )}</ h2 >
< blockquote >{ claim . statement }</ blockquote >
< h3 > EVIDENCE REVIEWED </ h3 >
{ claim . evidence . length ? claim . evidence . map ( item => < div className = "adjudication-evidence" key = { ` ${ item . displayNumber } : ${ item . title } ` }>
< b > EXHIBIT { item . displayNumber } — ACCEPTED </ b >
< span >{ item . title }{ item . publishedAt ? ` · ${ item . publishedAt . slice ( 0 , 10 ) } ` : '' }</ span >
{ item . sourceCitation && < small >{ item . sourceCitation }</ small >}
</ div >) : < div className = "adjudication-evidence" >< b > ACCEPTED REPORT ON FILE </ b ></ div >}
</ section >)}
< div className = "adjudication-finding" >< span > FINDING </ span >< strong >{ finding . slice ( 0 , findingCharacters )}{ phase === 'finding' && findingCharacters < finding . length ? '▍' : '' }</ strong ></ div >
{ presentation ? . kind === 'case-adjudication' && < footer >< span > INVESTIGATOR : { presentation . investigatorName }</ span >< span > FILED : { presentation . submittedAt . slice ( 0 , 10 )}</ span ></ footer >}
< div className = "adjudication-stamp" aria-hidden = { phase !== 'stamped' }>{ stampText }</ div >
{ phase === 'stamped' && < button type = "button" onClick = { onComplete }> RECEIVE MERIT < span > ▸ </ span ></ button >}
</ article >
</ div >
}
const CUTSCENE_REGISTRY : Record < string , FC < CutsceneComponentProps >> = {
'glass-harbour-diversion' : GlassHarbourDiversion ,
'case-adjudication' : CaseAdjudication ,
}
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.
2026-08-23 22:40:43 +02:00
const MERIT_REGISTRY : Record < string , FC < { label : string ; onComplete : () = > void } >> = { 'barricelli-luggage' : BarricelliLuggageMerit }
2026-08-22 16:11:53 +02:00
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-23 23:20:40 +02:00
export function CutsceneHost ({ componentKey , label , presentation , onComplete } : { componentKey : string | null | undefined ; label : string ; presentation? : CutscenePresentation | null ; onComplete : () => void }) {
2026-08-18 15:37:46 +02:00
const Component = componentKey ? CUTSCENE_REGISTRY [ componentKey ] : undefined
2026-08-23 23:20:40 +02:00
if ( Component ) return < Component label = { label } presentation = { presentation } onComplete = { onComplete } />
2026-08-18 15:37:46 +02:00
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 >
}