// Root shell: loads the case from the server (or ?case=ID), then mounts the game. import { useEffect, useState } from 'preact/hooks' import { getCase, newCase } from './api' import { RainFX, prefersReducedMotion } from './engine/pixel' import { GameProvider, type Screen, useGame, useMode, useTweaks } from './store' import type { PublicCase } from './types' import { SCREENS } from './screens' import { TitleScreen } from './screens/cold' import { Assistant } from './ui/assistant' import { unlockAudioOnce } from './ui/audio' import { Btn } from './ui/components' const RAIN_SCREENS = new Set(['title', 'interro', 'briefing', 'verdict', 'flashback', 'story']) function ScreenHost() { const g = useGame() const Screen = SCREENS[g.state.screen] || TitleScreen const t = g.state.tweaks const showRain = t.rain && t.fx !== 'low' && !prefersReducedMotion() && RAIN_SCREENS.has(g.state.screen) return (
{showRain && (
)}
) } function Loading() { const showRain = !prefersReducedMotion() return (
{showRain && (
)}
CASE ZERO
FORMING A CASE
The city, the body, the lies — coming together from the night wire.
) } function ErrorView({ msg, retry }: { msg: string; retry: () => void }) { return (
THE WIRE WENT DEAD
{msg}
Try again
) } export function Root() { const [data, setData] = useState<{ case: PublicCase; runId: string } | null>(null) const [error, setError] = useState(null) const [startScreen, setStartScreen] = useState('title') const mode = useMode('auto') // always responsive to the real viewport const [tweaks, setTweak] = useTweaks() useEffect(() => { const r = document.documentElement r.setAttribute('data-palette', tweaks.palette) r.setAttribute('data-fonts', tweaks.fonts) r.setAttribute('data-fx', tweaks.fx) r.setAttribute('data-mood', tweaks.mood) window.__pxScale = tweaks.pixelScale }, [tweaks]) useEffect(unlockAudioOnce, []) // grant audio playback on first tap (mobile autoplay policy) const load = () => { setError(null) setData(null) setStartScreen('title') const params = new URLSearchParams(window.location.search) const cid = params.get('case') const req = cid ? getCase(cid) : newCase() req.then((r) => setData({ case: r.case, runId: r.runId })).catch((e) => setError(e instanceof Error ? e.message : String(e))) } useEffect(load, []) // "Begin New Case" - always fetch a FRESH case from the server (never replay the loaded one) // and start playing it. A shared ?case= is cleared so a refresh won't snap back to it. const beginNewCase = () => { setError(null) setData(null) setStartScreen('story') const u = new URL(window.location.href) if (u.searchParams.has('case')) { u.searchParams.delete('case') window.history.replaceState({}, '', u.pathname + u.search) } newCase() .then((r) => setData({ case: r.case, runId: r.runId })) .catch((e) => setError(e instanceof Error ? e.message : String(e))) } // "Enter Case ID" - load that exact case (fresh run) and jump straight into playing it. const loadCaseById = (id: string) => { const cid = id.trim() if (!cid) return setError(null) setData(null) setStartScreen('story') const u = new URL(window.location.href) u.searchParams.set('case', cid) window.history.replaceState({}, '', u.pathname + u.search) getCase(cid) .then((r) => setData({ case: r.case, runId: r.runId })) .catch((e) => setError(e instanceof Error ? e.message : String(e))) } if (error) { return (
) } if (!data) { return (
) } return (
) }