Add case adjudication cutscene

This commit is contained in:
2026-08-23 23:21:24 +02:00
parent 0c4e0db118
commit 31a4b52832
13 changed files with 330 additions and 33 deletions
+60 -5
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio'
import { BarricelliLuggageMerit } from './merits'
import type { CutscenePresentation } from './narrativeContract'
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 }
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 }
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 }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -37,7 +38,9 @@ export function SplashScreen({ hasResume, busy, status, onNewGame, onResume }: {
}
// Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry).
const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
type CutsceneComponentProps = { label: string; presentation?: CutscenePresentation | null; onComplete: () => void }
const GlassHarbourDiversion: FC<CutsceneComponentProps> = ({ onComplete }) => (
<div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<small>Greyhaven file 87-10</small>
@@ -46,7 +49,59 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
</div>
</div>
)
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
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,
}
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
@@ -68,9 +123,9 @@ export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { com
</div>
}
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
export function CutsceneHost({ componentKey, label, presentation, onComplete }: { componentKey: string | null | undefined; label: string; presentation?: CutscenePresentation | null; onComplete: () => void }) {
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
if (Component) return <Component onComplete={onComplete} />
if (Component) return <Component label={label} presentation={presentation} onComplete={onComplete} />
return <div className="cutscene-card title-card" onClick={onComplete}>
<div className="title-card-inner">
<h1>{label}</h1>