Spaces:
Running
Running
| // Title · Story · Briefing (dossier) · Boot — the "cold" pre-investigation screens. | |
| import { useEffect, useRef, useState } from 'preact/hooks' | |
| import type { ComponentChildren } from 'preact' | |
| import { useTypewriter } from '../engine/pixel' | |
| import { useGame } from '../store' | |
| import type { PublicCase } from '../types' | |
| import { playSfx } from '../ui/audio' | |
| import { BottomNav, Btn, Controls, ExhibitArt, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components' | |
| export function TitleScreen() { | |
| const g = useGame() | |
| const c = g.case | |
| const [enterId, setEnterId] = useState(false) | |
| const [idVal, setIdVal] = useState(c.id) | |
| const pull = () => { | |
| const id = idVal.trim() | |
| if (id) g.loadCase(id) // load that exact case (fresh run) and jump straight into it | |
| } | |
| return ( | |
| <div class="app__view" style={{ position: 'relative' }}> | |
| <Scene name="skyline" w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} /> | |
| <div style={{ position: 'absolute', top: 12, right: 14, zIndex: 5 }}><Controls /></div> | |
| <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} /> | |
| <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} /> | |
| <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}> | |
| <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL DETECTIVE MYSTERY</div> | |
| <h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1> | |
| <p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}> | |
| Every case is generated. The city, the body, the lies. Solve one no one has ever seen. | |
| </p> | |
| <div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>“{c.tagline}”</div> | |
| {!enterId ? ( | |
| <div class="col" style={{ gap: 10, width: 'min(320px,80vw)', marginTop: 6 }}> | |
| <Btn variant="amber" onClick={() => g.newCase()}>▸ Begin New Case</Btn> | |
| <Btn variant="ghost" onClick={() => setEnterId(true)}>Enter Case ID</Btn> | |
| <Btn variant="ghost" onClick={() => g.nav('board')}>Continue · {c.id}</Btn> | |
| </div> | |
| ) : ( | |
| <Panel className="col" style={{ gap: 10, width: 'min(340px,86vw)', padding: 16 }}> | |
| <span class="t-label">ENTER A CASE FILE NUMBER</span> | |
| <input | |
| class="pinput t-mono" | |
| value={idVal} | |
| onInput={(e) => setIdVal((e.target as HTMLInputElement).value)} | |
| style={{ textAlign: 'center', fontSize: 'calc(17px*var(--mono-scale))' }} | |
| /> | |
| <div class="row" style={{ gap: 8 }}> | |
| <Btn variant="ghost" sm onClick={() => setEnterId(false)}>Back</Btn> | |
| <Btn variant="amber" className="grow" onClick={pull}>Pull File ▸</Btn> | |
| </div> | |
| </Panel> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export function StoryScreen() { | |
| const g = useGame() | |
| const beats = g.case.storyBeats | |
| const [i, setI] = useState(0) | |
| const beat = beats[i] | |
| const last = i === beats.length - 1 | |
| const [body, done] = useTypewriter(beat.text, (g.state.tweaks.typeSpeed || 18) + 2, true) | |
| return ( | |
| <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}> | |
| <Scene | |
| name={beat.scene} | |
| establishing={i === 0} | |
| incident={beat.incident ? g.case.kind || 'homicide' : undefined} | |
| anim | |
| w={320} | |
| h={200} | |
| cover | |
| deps={[i]} | |
| style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: beat.incident ? 0.75 : 0.5 }} | |
| /> | |
| <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,11,16,.7) 0%, rgba(8,11,16,.35) 38%, rgba(8,11,16,.82) 100%)' }} /> | |
| <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column' }}> | |
| <div class="between" style={{ padding: '14px 18px' }}> | |
| <span class="t-label" style={{ letterSpacing: '.2em', color: 'var(--amber-2)' }}>{beat.kicker}</span> | |
| <div class="row" style={{ gap: 8 }}> | |
| <HintButton /> | |
| <Btn sm variant="ghost" onClick={() => g.nav('board')}>Skip ▸</Btn> | |
| </div> | |
| </div> | |
| <div class="grow center" style={{ padding: '12px 22px' }}> | |
| <div style={{ maxWidth: 680, width: '100%' }}> | |
| <h1 class="t-display" style={{ fontSize: 'clamp(22px,4vw,34px)', color: 'var(--bone-3)', marginBottom: 18, lineHeight: 1.1, textShadow: '3px 3px 0 var(--ink-0)' }}>{beat.title}</h1> | |
| <p class="t-body" style={{ fontSize: 'clamp(16px,2.2vw,20px)', lineHeight: 1.65, color: 'var(--bone-2)', minHeight: 130 }}> | |
| {body} | |
| {!done && <span class="cursor" />} | |
| </p> | |
| </div> | |
| </div> | |
| <div class="between" style={{ padding: '16px 22px 22px', gap: 14 }}> | |
| <div class="row" style={{ gap: 6 }}> | |
| {beats.map((_, k) => ( | |
| <span key={k} onClick={() => setI(k)} style={{ width: 26, height: 6, cursor: 'pointer', background: k === i ? 'var(--amber-2)' : k < i ? 'var(--slate-2)' : 'var(--ink-3)', boxShadow: 'inset 0 0 0 1px var(--ink-0)' }} /> | |
| ))} | |
| </div> | |
| <div class="row" style={{ gap: 10 }}> | |
| {i > 0 && <Btn variant="ghost" onClick={() => setI(i - 1)}>◂ Back</Btn>} | |
| {!last ? ( | |
| <Btn variant="amber" onClick={() => setI(i + 1)}>Next ▸</Btn> | |
| ) : ( | |
| <Btn variant="amber" style={{ padding: '13px 24px' }} onClick={() => g.nav('board')}>Start Investigating ▸▸</Btn> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export function BootScreen() { | |
| const g = useGame() | |
| const boot = g.case.bootLines | |
| const [lines, setLines] = useState<string[]>([]) | |
| const [cur, setCur] = useState('') | |
| const [stamped, setStamped] = useState(false) | |
| const [done, setDone] = useState(false) | |
| const idx = useRef(0) | |
| const ch = useRef(0) | |
| useEffect(() => { | |
| const speed = g.state.tweaks.typeSpeed || 16 | |
| let timer: ReturnType<typeof setTimeout> | |
| const tick = () => { | |
| const i = idx.current | |
| if (i >= boot.length) { | |
| setStamped(true) | |
| setTimeout(() => setDone(true), 900) | |
| return | |
| } | |
| const line = boot[i] | |
| ch.current++ | |
| setCur(line.slice(0, ch.current)) | |
| if (ch.current >= line.length) { | |
| setLines((l) => [...l, line]) | |
| setCur('') | |
| ch.current = 0 | |
| idx.current++ | |
| timer = setTimeout(tick, 240) | |
| } else { | |
| timer = setTimeout(tick, speed) | |
| } | |
| } | |
| timer = setTimeout(tick, 300) | |
| return () => clearTimeout(timer) | |
| }, []) | |
| return ( | |
| <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}> | |
| <Scene name={g.case.district} establishing w={320} h={200} cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.28 }} /> | |
| <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 80% at 30% 40%, transparent 40%, rgba(8,11,16,.7) 100%)' }} /> | |
| <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}> | |
| <div class="maxw" style={{ maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 22, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}> | |
| <div class="row" style={{ gap: 10, alignItems: 'center' }}> | |
| <span style={{ width: 9, height: 9, background: 'var(--ox-3)', boxShadow: '0 0 10px var(--ox-3)', animation: 'blink 1.4s steps(1) infinite' }} /> | |
| <span class="t-label" style={{ letterSpacing: '.22em', color: 'var(--amber-2)' }}>{g.case.city} · {g.case.district}</span> | |
| </div> | |
| <div class="t-mono" style={{ fontSize: 'calc(17px*var(--mono-scale))', lineHeight: 1.85, color: 'var(--bone-2)', minHeight: g.mode === 'mobile' ? 220 : 200 }}> | |
| {lines.map((l, i) => ( | |
| <div key={i} style={{ opacity: i === lines.length - 1 && !cur ? 1 : 0.82 }}>{l}</div> | |
| ))} | |
| {cur && <div class="bone">{cur}<span class="cursor" /></div>} | |
| {lines.length === 0 && !cur && <span class="cursor" />} | |
| </div> | |
| <div class="col" style={{ gap: 18, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}> | |
| <div style={{ position: 'relative', paddingTop: 16 }}> | |
| <span class="t-label" style={{ position: 'absolute', top: 0, left: 0 }}>CASE FILE No.</span> | |
| {stamped ? ( | |
| <Stamp slam style={{ fontSize: 'clamp(15px,3.5vw,22px)' }}>{g.case.id}</Stamp> | |
| ) : ( | |
| <span class="t-display dim" style={{ fontSize: 'clamp(15px,3.5vw,22px)', opacity: 0.25, letterSpacing: '.08em' }}>— — — — —</span> | |
| )} | |
| </div> | |
| {done && <Btn variant="amber" style={{ fontSize: 12, padding: '15px 24px' }} onClick={() => g.nav('briefing')}>Take the Case ▸</Btn>} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---- dossier ---- | |
| function PageHead({ tab, idx, c }: { tab: string; idx: string; c: PublicCase }) { | |
| return ( | |
| <div class="between" style={{ borderBottom: '2px solid #211d1533', paddingBottom: 10, marginBottom: 16 }}> | |
| <div class="col" style={{ gap: 4 }}> | |
| <span class="dlabel">{c.city} CO. · {c.division || 'HOMICIDE DIVISION'}</span> | |
| <span class="dh" style={{ fontSize: 13 }}>{tab}</span> | |
| </div> | |
| <span class="dtype" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: '#6a6150' }}>FILE {c.id} · pg.{idx}</span> | |
| </div> | |
| ) | |
| } | |
| function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChildren }[] { | |
| return [ | |
| { | |
| tab: 'COVER', | |
| render: () => ( | |
| <div class="center" style={{ flexDirection: 'column', height: '100%', gap: 18, textAlign: 'center', position: 'relative' }}> | |
| <span class="paperclip" /> | |
| <span class="dlabel" style={{ fontSize: 9, letterSpacing: '.3em' }}>{c.district}</span> | |
| <div class="dlabel" style={{ color: '#6a6150', letterSpacing: '.2em' }}>{c.kindLabel || 'HOMICIDE'} — CASE FILE</div> | |
| <h1 class="dh" style={{ fontSize: 'clamp(26px,5vw,44px)', lineHeight: 1.05 }}>{c.title}</h1> | |
| <div style={{ margin: '6px 0' }}><span class="dstamp" style={{ fontSize: 'clamp(14px,3vw,20px)' }}>{c.id}</span></div> | |
| <div class="dtype" style={{ color: '#4a4234' }}>{c.district}</div> | |
| <div style={{ position: 'absolute', bottom: 6, right: 8 }}><span class="dstamp" style={{ fontSize: 11, borderColor: '#1a1610', color: '#1a1610', transform: 'rotate(4deg)' }}>CONFIDENTIAL</span></div> | |
| <div class="dtype" style={{ color: '#6a6150', fontSize: 'calc(14px*var(--mono-scale))', marginTop: 8 }}>▸ flip the page →</div> | |
| </div> | |
| ), | |
| }, | |
| { | |
| tab: 'THE VICTIM', | |
| render: () => ( | |
| <div> | |
| <PageHead tab="THE VICTIM" idx="01" c={c} /> | |
| <div class="row" style={{ gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}> | |
| <div class="photo-paper" style={{ flexShrink: 0 }}> | |
| <div style={{ background: '#0d1117', padding: 4 }}><Portrait id={c.victim.sprite} px={6} /></div> | |
| <div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.victim.name.split(' ').slice(-1)[0]}</div> | |
| </div> | |
| <div class="col" style={{ gap: 8, flex: 1, minWidth: 200 }}> | |
| <h2 class="dh" style={{ fontSize: 20 }}>{c.victim.name}</h2> | |
| <div class="dtype" style={{ color: '#4a4234' }}>{c.victim.role}</div> | |
| <div class="row" style={{ gap: 10, flexWrap: 'wrap' }}> | |
| <span class="dstamp" style={{ fontSize: 11 }}>{c.victimStatus || 'DECEASED'}</span> | |
| <span class="dtype" style={{ alignSelf: 'center' }}>{c.todLabel || 'T.O.D.'} {c.tod} · AGE {c.victim.age}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <hr style={{ border: 0, borderTop: '2px dashed #211d1533', margin: '16px 0' }} /> | |
| <p class="dtype">{c.victim.bio}</p> | |
| </div> | |
| ), | |
| }, | |
| { | |
| tab: 'THE SCENE', | |
| render: () => ( | |
| <div> | |
| <PageHead tab="THE SCENE" idx="02" c={c} /> | |
| <div class="photo-paper" style={{ marginBottom: 16, transform: 'rotate(-1deg)' }}> | |
| <div style={{ aspectRatio: '16/9', background: '#0d1117' }}> | |
| <Scene name={c.scene} w={288} h={162} style={{ width: '100%', height: '100%' }} /> | |
| </div> | |
| <div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', marginTop: 4 }}>EXHIBIT A — {c.scene}</div> | |
| </div> | |
| <p class="dtype" style={{ marginBottom: 10 }}><b style={{ color: '#8a3a2c' }}>FOUND — </b>{c.found}</p> | |
| <p class="dtype"><b style={{ color: '#8a3a2c' }}>CAUSE — </b>{c.cause}</p> | |
| </div> | |
| ), | |
| }, | |
| { | |
| tab: 'THE EXHIBITS', | |
| render: () => ( | |
| <div> | |
| <PageHead tab="THE EXHIBITS" idx="03" c={c} /> | |
| <p class="dtype" style={{ marginBottom: 12, color: '#4a4234' }}>Photographed and logged at the scene. Examine each on the wall.</p> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> | |
| {c.evidence.slice(0, 6).map((e, i) => ( | |
| <div key={e.id} class="photo-paper" style={{ transform: `rotate(${(i % 3) - 1}deg)` }}> | |
| <div style={{ background: '#0d1117' }}> | |
| <ExhibitArt e={e} style={{ width: '100%', height: 'auto' }} /> | |
| </div> | |
| <div class="between" style={{ marginTop: 4, gap: 6 }}> | |
| <span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span> | |
| <span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#8a3a2c', flexShrink: 0 }}>{e.time}</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ), | |
| }, | |
| { | |
| tab: 'KEY FACTS', | |
| render: () => ( | |
| <div style={{ position: 'relative' }}> | |
| <PageHead tab="KEY FACTS" idx="04" c={c} /> | |
| <div style={{ position: 'absolute', top: 60, right: 0 }}><span class="dstamp" style={{ fontSize: 13 }}>{c.kindLabel || 'HOMICIDE'}</span></div> | |
| <div class="col" style={{ gap: 0 }}> | |
| {c.facts.map(([k, v], i) => ( | |
| <div key={i} class="row" style={{ gap: 14, padding: '11px 0', borderBottom: '1px dotted #211d1540', alignItems: 'baseline' }}> | |
| <span class="dlabel" style={{ color: '#6a6150', width: 116, flexShrink: 0 }}>{k}</span> | |
| <span class="dtype" style={{ color: k === 'VERDICT' ? '#8a2a2a' : '#211d15' }}>{v}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ), | |
| }, | |
| { | |
| tab: 'PERSONS OF INTEREST', | |
| render: () => ( | |
| <div> | |
| <PageHead tab="PERSONS OF INTEREST" idx="05" c={c} /> | |
| <p class="dtype" style={{ marginBottom: 14, color: '#4a4234' }}>Those who stayed when the others fled the sirens. Each had reason to be near.</p> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> | |
| {c.suspects.map((s) => ( | |
| <div key={s.id} class="row" style={{ gap: 10, alignItems: 'center', background: '#00000010', padding: 8, boxShadow: 'inset 0 0 0 1px #211d1522' }}> | |
| <div style={{ background: '#0d1117', padding: 3, flexShrink: 0 }}><Portrait id={s.sprite} px={3} gender={s.gender} accent={s.accentColor} /></div> | |
| <div class="col" style={{ gap: 2, minWidth: 0 }}> | |
| <span class="dh" style={{ fontSize: 10 }}>{s.name}</span> | |
| <span class="dlabel" style={{ color: '#8a3a2c' }}>{s.tag}</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <p class="dtype" style={{ marginTop: 16, color: '#8a3a2c', textAlign: 'center' }}>One of them is lying. Find the crack.</p> | |
| </div> | |
| ), | |
| }, | |
| ] | |
| } | |
| export function BriefingScreen() { | |
| const g = useGame() | |
| const c = g.case | |
| const PAGES = dossierPages(c) | |
| const [page, setPage] = useState(0) | |
| const [flip, setFlip] = useState<string | null>(null) | |
| const go = (dir: number) => { | |
| const next = page + dir | |
| if (next < 0 || next >= PAGES.length) return | |
| playSfx('page') | |
| setFlip(dir > 0 ? 'fwd' : 'back') | |
| setPage(next) | |
| setTimeout(() => setFlip(null), 470) | |
| } | |
| const P = PAGES[page] | |
| return ( | |
| <div class="app__view"> | |
| <Hud title={c.title} sub="CASE DOSSIER" /> | |
| <div class="dossier-stage"> | |
| <div class="dossier"> | |
| <div class="dossier__folder"> | |
| <div class="page-wrap"> | |
| <div class={'page ' + (page % 2 ? 'page--paper2 ' : '') + (flip ? 'page--flip-' + flip : '')} key={page}> | |
| {P.render()} | |
| <div class="page__curl" /> | |
| {page > 0 && <div class="page__edge page__edge--l" onClick={() => go(-1)} title="Previous page" />} | |
| {page < PAGES.length - 1 && <div class="page__edge page__edge--r" onClick={() => go(1)} title="Next page" />} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="dossier__nav"> | |
| <Btn sm variant="ghost" sfx={null} disabled={page === 0} onClick={() => go(-1)}>◂ Prev</Btn> | |
| <div class="page-dots"> | |
| {PAGES.map((pg, i) => ( | |
| <span key={i} class={'page-dot' + (i === page ? ' page-dot--on' : '')} title={pg.tab} | |
| onClick={() => { setFlip(i > page ? 'fwd' : 'back'); setPage(i); setTimeout(() => setFlip(null), 470) }} /> | |
| ))} | |
| </div> | |
| {page < PAGES.length - 1 ? ( | |
| <Btn sm variant="amber" sfx={null} onClick={() => go(1)}>Next page ▸</Btn> | |
| ) : ( | |
| <Btn variant="amber" onClick={() => g.nav('board')}>Open the Wall ▸▸</Btn> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <BottomNav /> | |
| </div> | |
| ) | |
| } | |