Add case adjudication cutscene
This commit is contained in:
+60
-5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user