case0 / web /src /screens /cold.tsx
HusseinEid's picture
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds (#4)
16ff49b
Raw
History Blame Contribute Delete
19.1 kB
// 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>
)
}