Spaces:
Running
Running
File size: 6,019 Bytes
414dc55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | // 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>
)
}
|