case0 / web /src /app.tsx
HusseinEid's picture
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>
)
}