import type { JourneySnapshot } from '@/journey/types'; import type { QualityTier } from '@/journey/motion-policy'; import { SceneResourceBag } from '@/scene/resources'; import type { LiveQualityTier, PageLifecycleOverlay, SceneBuildContext, SceneController, SceneControllerOptions, SceneControllerSnapshot, SceneGeneration, SceneLifecycleMachine, SceneLifecycleState, ScenePageEnvironment, SceneScheduler, } from '@/scene/types'; const RESTORE_CUTOFF_MS = 5_000; const legalTransitions: Readonly> = { idle: ['loading', 'paused', 'static', 'disposed'], loading: ['running', 'paused', 'lost', 'static', 'disposed'], running: ['paused', 'lost', 'static', 'disposed'], paused: ['running', 'loading', 'lost', 'static', 'disposed'], lost: ['restoring', 'paused', 'static', 'disposed'], restoring: ['running', 'paused', 'lost', 'static', 'disposed'], static: ['loading', 'disposed'], disposed: [], }; export function createSceneLifecycleMachine(initial: SceneLifecycleState = { kind: 'idle' }): SceneLifecycleMachine { let current = initial; return { transition(next) { if (!legalTransitions[current.kind].includes(next.kind)) { throw new Error(`Illegal transition: ${current.kind} -> ${next.kind}`); } if ( current.kind === 'paused' && next.kind === 'lost' && (current.generation === null || current.resumeNeedsLoad) ) { throw new Error('Illegal transition: paused -> lost requires a retained context'); } current = next; return current; }, replace(next) { if (next.kind !== current.kind) throw new Error('Lifecycle metadata replacement must preserve state kind'); current = next; return current; }, snapshot: () => current, }; } export function createSceneController(options: SceneControllerOptions): SceneController { const scheduler = options.scheduler ?? browserScheduler(); const page = options.page ?? browserPageEnvironment(); const machine = createSceneLifecycleMachine(); const rootResources = new SceneResourceBag(); let overlay: PageLifecycleOverlay = 'active'; let quality: LiveQualityTier = options.quality; let generationCounter = 0; let current: ActiveGeneration | null = null; let loading: LoadingGeneration | null = null; let activeOperation: Promise | null = null; let latestJourney = initialJourneySnapshot(); let pauseReason: 'visitor' | 'visibility' | 'freeze' | null = null; let contextLossCount = 0; let restorationSucceeded = false; let lostAtForRestore: number | null = null; const transition = (next: SceneLifecycleState): void => { machine.transition(next); }; const stopRaf = (): void => rootResources.cancelAnimationFrame(); const disposeGeneration = (): void => { stopRaf(); if (current) { current.resources.dispose(); current = null; } }; const invalidateLoading = (): void => { if (!loading) return; const invalidated = loading; loading = null; activeOperation = null; invalidated.abort.abort(); invalidated.resources.dispose(); }; const invalidateAll = (): void => { invalidateLoading(); disposeGeneration(); }; const enterStatic = (reason: string): void => { if (machine.snapshot().kind === 'disposed') return; invalidateAll(); clearRestoreWatchdog(); pauseReason = null; lostAtForRestore = null; transition({ kind: 'static', reason }); }; const scheduleFrame = (): void => { if ( rootResources.snapshot().rafCount > 0 || overlay !== 'active' || page.visibilityState !== 'visible' || machine.snapshot().kind !== 'running' || !current ) return; let frameId = 0; frameId = scheduler.requestAnimationFrame((now) => { rootResources.releaseAnimationFrame(frameId); if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return; try { const frameStartedAt = scheduler.now(); current.runtime.render(latestJourney, now); const frameEndedAt = scheduler.now(); options.onFrameRendered?.(frameStartedAt, frameEndedAt); } catch { enterStatic('render-failure'); return; } scheduleFrame(); }); rootResources.animationFrame(frameId, scheduler.cancelAnimationFrame); }; const beginLoad = (mode: 'loading' | 'restoring', restoreLostAt?: number): Promise => { if (overlay !== 'active' || machine.snapshot().kind === 'disposed') return Promise.resolve(); invalidateLoading(); disposeGeneration(); const generation = ++generationCounter; const abort = new AbortController(); const resources = new SceneResourceBag(); loading = restoreLostAt === undefined ? { generation, abort, resources, mode } : { generation, abort, resources, mode, restoreLostAt }; transition(mode === 'restoring' ? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() } : { kind: 'loading', generation }); const context: SceneBuildContext = { canvas: options.canvas, quality, generation, signal: abort.signal, resources, }; let buildResult: Promise; try { buildResult = Promise.resolve(options.build(context)); } catch (error) { buildResult = Promise.reject(error); } const operation = buildResult .then((runtime) => finishLoad(generation, runtime)) .catch(() => failLoad(generation)); activeOperation = operation; void operation.finally(() => { if (activeOperation === operation) activeOperation = null; }); return operation; }; const finishLoad = (generation: number, runtime: SceneGeneration): void => { const candidate = loading; if (!candidate || candidate.generation !== generation) { runtime.dispose(); return; } candidate.resources.own('other', runtime); const lifecycle = machine.snapshot(); const expectedKind = candidate.mode === 'restoring' ? 'restoring' : 'loading'; const pastCutoff = candidate.mode === 'restoring' && candidate.restoreLostAt !== undefined && scheduler.now() > candidate.restoreLostAt + RESTORE_CUTOFF_MS; if ( candidate.abort.signal.aborted || overlay !== 'active' || lifecycle.kind !== expectedKind || lifecycle.generation !== generation || pastCutoff ) { loading = null; candidate.resources.dispose(); if (pastCutoff && machine.snapshot().kind === 'restoring') enterStatic('context-restore-timeout'); return; } loading = null; current = { generation, resources: candidate.resources, runtime }; if (candidate.mode === 'restoring') { restorationSucceeded = true; lostAtForRestore = null; clearRestoreWatchdog(); } if (page.visibilityState === 'visible') { transition({ kind: 'running', generation }); pauseReason = null; scheduleFrame(); } else { transition({ kind: 'paused', generation, resumeNeedsLoad: false }); pauseReason = 'visibility'; } }; const failLoad = (generation: number): void => { if (!loading || loading.generation !== generation) return; const failed = loading; loading = null; failed.resources.dispose(); if (failed.abort.signal.aborted || machine.snapshot().kind === 'disposed') return; enterStatic(failed.mode === 'restoring' ? 'context-restore-failure' : 'scene-load-failure'); }; let restoreTimer: number | null = null; const clearRestoreWatchdog = (): void => { if (restoreTimer === null) return; scheduler.clearTimeout(restoreTimer); rootResources.releaseTimer(restoreTimer); restoreTimer = null; }; const armRestoreWatchdog = (lostAt: number): void => { clearRestoreWatchdog(); const delay = Math.max(0, lostAt + RESTORE_CUTOFF_MS - scheduler.now()); const timer = scheduler.setTimeout(() => { rootResources.releaseTimer(timer); if (restoreTimer === timer) restoreTimer = null; evaluatePendingRestore(); }, delay); restoreTimer = rootResources.timer(timer, scheduler.clearTimeout); }; const evaluatePendingRestore = (): void => { const lifecycle = machine.snapshot(); if (lifecycle.kind !== 'lost' || !lifecycle.restorationPending) return; if (overlay !== 'active' || page.visibilityState !== 'visible') return; if (scheduler.now() > lifecycle.lostAt + RESTORE_CUTOFF_MS) { enterStatic('context-restore-timeout'); return; } void beginLoad('restoring', lifecycle.lostAt); }; const onContextLost = (rawEvent: Event): void => { const lifecycle = machine.snapshot(); const legal = lifecycle.kind === 'loading' || lifecycle.kind === 'running' || (lifecycle.kind === 'paused' && lifecycle.generation !== null && !lifecycle.resumeNeedsLoad); if (!legal || overlay === 'disposed') return; rawEvent.preventDefault(); contextLossCount += 1; if (restorationSucceeded || contextLossCount > 1) { enterStatic('repeated-context-loss'); return; } const lostAt = scheduler.now(); invalidateAll(); pauseReason = null; lostAtForRestore = lostAt; transition({ kind: 'lost', lostAt, restorationPending: false }); if (page.visibilityState === 'visible' && overlay === 'active') armRestoreWatchdog(lostAt); }; const onContextRestored = (): void => { const lifecycle = machine.snapshot(); if (lifecycle.kind !== 'lost' || overlay === 'disposed') return; machine.replace({ ...lifecycle, restorationPending: true }); if (overlay === 'active' && page.visibilityState === 'visible') evaluatePendingRestore(); }; const onVisibilityChange = (): void => { if (overlay !== 'active' || machine.snapshot().kind === 'disposed') return; const lifecycle = machine.snapshot(); if (page.visibilityState === 'hidden') { stopRaf(); if (lifecycle.kind === 'running' && current) { transition({ kind: 'paused', generation: current.generation, resumeNeedsLoad: false }); pauseReason = 'visibility'; } else if (lifecycle.kind === 'restoring') { const lostAt = lifecycle.lostAt; invalidateLoading(); transition({ kind: 'lost', lostAt, restorationPending: true }); lostAtForRestore = lostAt; } return; } if (lifecycle.kind === 'paused' && pauseReason === 'visibility') { if (current && !lifecycle.resumeNeedsLoad) { transition({ kind: 'running', generation: current.generation }); pauseReason = null; scheduleFrame(); } else { pauseReason = null; void beginLoad('loading'); } } else if (lifecycle.kind === 'lost') { evaluatePendingRestore(); } }; const onPageHide = (event: Event): void => { const persisted = Boolean((event as Event & { persisted?: boolean }).persisted); if (!persisted) { dispose(); return; } if (overlay === 'disposed') return; overlay = 'frozen'; const lifecycle = machine.snapshot(); switch (lifecycle.kind) { case 'idle': case 'static': case 'disposed': break; case 'loading': invalidateLoading(); transition({ kind: 'paused', generation: null, resumeNeedsLoad: true }); pauseReason = 'freeze'; break; case 'running': stopRaf(); transition({ kind: 'paused', generation: lifecycle.generation, resumeNeedsLoad: false }); pauseReason = 'freeze'; break; case 'paused': if (pauseReason !== 'visitor') pauseReason = 'freeze'; break; case 'lost': clearRestoreWatchdog(); break; case 'restoring': { const lostAt = lifecycle.lostAt; invalidateLoading(); transition({ kind: 'lost', lostAt, restorationPending: true }); lostAtForRestore = lostAt; break; } } }; const onPageShow = (event: Event): void => { const persisted = Boolean((event as Event & { persisted?: boolean }).persisted); if (!persisted || overlay !== 'frozen') return; overlay = 'active'; const lifecycle = machine.snapshot(); if (!(options.motionAllowed?.() ?? true)) { if ( lifecycle.kind === 'paused' && current !== null && !lifecycle.resumeNeedsLoad && pauseReason === 'visitor' ) return; enterStatic('motion-disallowed'); return; } if (page.visibilityState !== 'visible') return; if (lifecycle.kind === 'paused' && pauseReason === 'freeze') { pauseReason = null; if (current && !lifecycle.resumeNeedsLoad) { transition({ kind: 'running', generation: current.generation }); scheduleFrame(); } else { void beginLoad('loading'); } } else if (lifecycle.kind === 'lost') { evaluatePendingRestore(); } }; rootResources.listen(options.canvas, 'webglcontextlost', onContextLost); rootResources.listen(options.canvas, 'webglcontextrestored', onContextRestored); rootResources.listen(page.document, 'visibilitychange', onVisibilityChange); rootResources.listen(page.window, 'pagehide', onPageHide); rootResources.listen(page.window, 'pageshow', onPageShow); if (options.signal) { if (options.signal.aborted) dispose(); else rootResources.listen(options.signal, 'abort', dispose, { once: true }); } function start(): Promise { const lifecycle = machine.snapshot(); if (overlay !== 'active' || lifecycle.kind === 'disposed') return Promise.resolve(); if (lifecycle.kind === 'running' || lifecycle.kind === 'loading' || lifecycle.kind === 'restoring') { return activeOperation ?? Promise.resolve(); } if (lifecycle.kind !== 'idle') return Promise.resolve(); return beginLoad('loading'); } function pause(): void { const lifecycle = machine.snapshot(); if (lifecycle.kind === 'disposed' || lifecycle.kind === 'static' || lifecycle.kind === 'paused') return; pauseReason = 'visitor'; if (lifecycle.kind === 'running' && current) { stopRaf(); transition({ kind: 'paused', generation: current.generation, resumeNeedsLoad: false }); return; } invalidateAll(); clearRestoreWatchdog(); lostAtForRestore = null; transition({ kind: 'paused', generation: null, resumeNeedsLoad: true }); } function resume(): Promise { const lifecycle = machine.snapshot(); if (overlay !== 'active' || lifecycle.kind !== 'paused') return Promise.resolve(); pauseReason = null; if (current && !lifecycle.resumeNeedsLoad) { transition({ kind: 'running', generation: current.generation }); scheduleFrame(); return Promise.resolve(); } return beginLoad('loading'); } function setStatic(reason: string): void { if (machine.snapshot().kind === 'static' || machine.snapshot().kind === 'disposed') return; enterStatic(reason); } function enableMotion(): Promise { if (machine.snapshot().kind !== 'static' || !(options.motionAllowed?.() ?? true)) return Promise.resolve(); return beginLoad('loading'); } async function setQuality(tier: QualityTier): Promise { if (tier === 'static') { setStatic('quality-static'); return; } if (quality === tier) return; quality = tier; const lifecycle = machine.snapshot(); if (lifecycle.kind === 'running') { stopRaf(); transition({ kind: 'paused', generation: lifecycle.generation, resumeNeedsLoad: false }); disposeGeneration(); await beginLoad('loading'); } else if (lifecycle.kind === 'loading' || lifecycle.kind === 'restoring') { pause(); await resume(); } } function update(snapshot: JourneySnapshot): void { latestJourney = copyJourneySnapshot(snapshot); } function snapshot(): SceneControllerSnapshot { return { state: machine.snapshot(), overlay, quality, canvasCount: overlay === 'disposed' ? 0 : 1, rafCount: rootResources.snapshot().rafCount, currentGeneration: current?.generation ?? null, }; } function whenSettled(): Promise { return activeOperation ?? Promise.resolve(); } function dispose(): void { if (machine.snapshot().kind === 'disposed') return; overlay = 'disposed'; invalidateAll(); clearRestoreWatchdog(); transition({ kind: 'disposed' }); rootResources.dispose(); } return { start, pause, resume, setStatic, enableMotion, setQuality, update, snapshot, whenSettled, dispose, }; } interface ActiveGeneration { generation: number; resources: SceneResourceBag; runtime: SceneGeneration; } interface LoadingGeneration { generation: number; abort: AbortController; resources: SceneResourceBag; mode: 'loading' | 'restoring'; restoreLostAt?: number; } function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot { return { scrollY: finite(snapshot.scrollY), routeProgress: finite(snapshot.routeProgress), direction: snapshot.direction, acceleration: finite(snapshot.acceleration), driveSpeed: finite(snapshot.driveSpeed), activeSectionId: snapshot.activeSectionId, chapter: snapshot.chapter, sign: snapshot.sign ? { targetId: snapshot.sign.targetId, phase: snapshot.sign.phase, progress: finite(snapshot.sign.progress), } : null, arrived: Boolean(snapshot.arrived), }; } function finite(value: number): number { return Number.isFinite(value) ? value : 0; } function initialJourneySnapshot(): JourneySnapshot { return { scrollY: 0, routeProgress: 0, direction: 0, acceleration: 0, driveSpeed: 0.18, activeSectionId: 'start', chapter: 'deep-night', sign: null, arrived: false, }; } function browserScheduler(): SceneScheduler { return { now: () => performance.now(), requestAnimationFrame: (callback) => window.requestAnimationFrame(callback), cancelAnimationFrame: (id) => window.cancelAnimationFrame(id), setTimeout: (callback, delay) => window.setTimeout(callback, delay), clearTimeout: (id) => window.clearTimeout(id), }; } function browserPageEnvironment(): ScenePageEnvironment { return { document, window, get visibilityState() { return document.visibilityState; }, }; }