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
+23 -2
View File
@@ -5,7 +5,7 @@ import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLeadIn, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
import { CutsceneHost, type PlaythroughState, type RuntimeNode } from './narrative'
// three.js stays out of the board bundle until the player opens the inventory.
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
@@ -89,6 +89,7 @@ export function App() {
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
const [narrativeOverlay, setNarrativeOverlay] = useState<RuntimeNode | null>(null)
const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
@@ -527,7 +528,25 @@ export function App() {
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
else if (next.node?.kind === 'cutscene') {
setReportOpen(false)
setCompletedGoal(null)
setNarrativeOverlay(next.node)
} else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
} finally { setAdvancing(false) }
}
const continueAfterNarrativeOverlay = async () => {
if (!activePlaythroughId) return
setAdvancing(true)
try {
const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
})
if (!response.ok) { setStatus('STORY ADVANCE UNAVAILABLE'); return }
const next = await response.json() as PlaythroughState
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
else window.location.assign('/?resume=1')
} finally { setAdvancing(false) }
}
@@ -811,6 +830,8 @@ export function App() {
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
{narrativeOverlay?.kind === 'cutscene' && <CutsceneHost componentKey={narrativeOverlay.componentKey} label={narrativeOverlay.label}
presentation={narrativeOverlay.presentation} onComplete={() => { if (!advancing) void continueAfterNarrativeOverlay() }}/>}
</main>
}