118 lines
6.5 KiB
TypeScript
118 lines
6.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
|||
|
|
|
||
|
|
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; 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 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 }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void }) {
|
||
|
|
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||
|
|
const [currentId, setCurrentId] = useState<string | null>(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 (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
|
||
|
|
setCurrentId(children[0].id) // linear next line
|
||
|
|
}
|
||
|
|
useEffect(() => {
|
||
|
|
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])
|
||
|
|
|
||
|
|
if (!current) return null
|
||
|
|
return <div className="dialogue" 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>
|
||
|
|
}
|