Spaces:
Running
Running
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55 | // 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 ( | |
| <div class="app__stage"> | |
| <div class="app__frame"> | |
| <Screen key={g.state.screen} /> | |
| <div class="fx-layer fx-scanlines" /> | |
| <div class="fx-layer fx-vignette" /> | |
| <div class="fx-layer fx-flicker" /> | |
| {showRain && ( | |
| <div class="fx-layer"> | |
| <RainFX density={t.fx === 'high' ? 130 : 80} /> | |
| </div> | |
| )} | |
| </div> | |
| <Assistant /> | |
| </div> | |
| ) | |
| } | |
| function Loading() { | |
| const showRain = !prefersReducedMotion() | |
| return ( | |
| <div class="app__stage"> | |
| <div class="app__frame" style={{ position: 'relative', background: 'var(--ink-0)' }}> | |
| {showRain && ( | |
| <div class="fx-layer"> | |
| <RainFX density={80} /> | |
| </div> | |
| )} | |
| <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(80% 70% at 50% 45%, transparent 35%, rgba(8,11,16,.85) 100%)' }} /> | |
| <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}> | |
| <div class="col center" style={{ gap: 14, textAlign: 'center' }}> | |
| <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>CASE ZERO</div> | |
| <div class="t-display" style={{ fontSize: 'clamp(18px,4vw,28px)', color: 'var(--bone-3)' }}>FORMING A CASE<span class="cursor" /></div> | |
| <div class="t-mono dim" style={{ fontSize: 'calc(15px*var(--mono-scale))', maxWidth: 320 }}>The city, the body, the lies — coming together from the night wire.</div> | |
| </div> | |
| </div> | |
| <div class="fx-layer fx-scanlines" /> | |
| <div class="fx-layer fx-vignette" /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function ErrorView({ msg, retry }: { msg: string; retry: () => void }) { | |
| return ( | |
| <div class="app__stage"> | |
| <div class="app__frame"> | |
| <div class="screen-center"> | |
| <div class="panel panel--ox col center" style={{ gap: 14, maxWidth: 420, textAlign: 'center', padding: 24 }}> | |
| <div class="t-display ox" style={{ fontSize: 14 }}>THE WIRE WENT DEAD</div> | |
| <div class="t-body" style={{ color: 'var(--bone-2)' }}>{msg}</div> | |
| <Btn variant="amber" onClick={retry}>Try again</Btn> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export function Root() { | |
| const [data, setData] = useState<{ case: PublicCase; runId: string } | null>(null) | |
| const [error, setError] = useState<string | null>(null) | |
| const [startScreen, setStartScreen] = useState<Screen>('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 ( | |
| <div class="app" data-mode={mode}> | |
| <ErrorView msg={error} retry={load} /> | |
| </div> | |
| ) | |
| } | |
| if (!data) { | |
| return ( | |
| <div class="app" data-mode={mode}> | |
| <Loading /> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div class="app" data-mode={mode}> | |
| <GameProvider key={data.runId} case={data.case} runId={data.runId} mode={mode} tweaks={tweaks} setTweak={setTweak} initialScreen={startScreen} newCase={beginNewCase} loadCase={loadCaseById}> | |
| <ScreenHost /> | |
| </GameProvider> | |
| </div> | |
| ) | |
| } | |