case0 / web /src /screens /deduce.tsx
HusseinEid's picture
feat: multi-crime cases, scene+exhibit pixel art, background AI generation
80cd1f2 verified
Raw
History Blame Contribute Delete
8.26 kB
// Flashback (dual reconstruction) · Timeline · Notes.
import { useState } from 'preact/hooks'
import { useGame } from '../store'
import type { Evidence, FlashbackAccount, TimelineBeat } from '../types'
import { BottomNav, Btn, Chip, EvIcon, Hud, Panel, Scene, perpNoun } from '../ui/components'
function Side({ data, hot }: { data: FlashbackAccount; hot?: boolean }) {
return (
<Panel variant={hot ? 'ox' : undefined} className="col" style={{ gap: 10, padding: 12 }}>
<div class="between">
<span class="t-display" style={{ fontSize: 11, color: hot ? 'var(--ox-3)' : 'var(--amber-2)' }}>{data.who}</span>
{hot && <Chip variant="ox">CONTESTED</Chip>}
</div>
<div style={{ aspectRatio: '16/9', boxShadow: 'inset 0 0 0 3px var(--ink-0)', position: 'relative' }}>
<Scene name={data.scene} w={256} h={144} anim style={{ width: '100%', height: '100%' }} />
<div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: 0.35 }} />
</div>
<div class="col" style={{ gap: 6 }}>
{data.lines.map((l, i) => (
<div key={i} class="t-body" style={{ fontSize: 14, padding: '7px 9px', background: 'var(--ink-1)', color: data.flags.includes(i) ? 'var(--ox-3)' : 'var(--bone-2)', boxShadow: data.flags.includes(i) ? 'inset 0 0 0 2px var(--ox-2)' : 'inset 0 0 0 2px var(--ink-0)' }}>
{data.flags.includes(i) && '⚑ '}
{l}
</div>
))}
</div>
</Panel>
)
}
export function FlashbackScreen() {
const g = useGame()
const F = g.case.flashback
const [ab, setAB] = useState<'a' | 'b'>('a')
return (
<div class="app__view">
<Hud title="RECONSTRUCTION" sub={F.title} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} />
<div class="screen-pad">
<div class="maxw" style={{ maxWidth: 980 }}>
<p class="t-body" style={{ marginBottom: 14, color: 'var(--bone-2)' }}>
Two accounts of the same minutes. Where they diverge, the truth is hiding. <span class="ox">Oxblood marks a contradiction.</span>
</p>
{g.mode === 'mobile' && (
<div class="viewbar" style={{ position: 'static', justifyContent: 'center', marginBottom: 12 }}>
<div class="seg">
<button class={ab === 'a' ? 'on' : ''} onClick={() => setAB('a')}>{F.a.who}</button>
<button class={ab === 'b' ? 'on' : ''} onClick={() => setAB('b')}>{F.b.who}</button>
</div>
</div>
)}
{g.mode === 'mobile' ? (
ab === 'a' ? <Side data={F.a} /> : <Side data={F.b} hot />
) : (
<div class="flash-grid"><Side data={F.a} /><Side data={F.b} hot /></div>
)}
<div class="center" style={{ marginTop: 18 }}>
<Btn variant="ox" onClick={() => g.nav('accuse')}>This is enough. Name the {perpNoun(g.case.kind)} ▸</Btn>
</div>
</div>
</div>
<BottomNav />
</div>
)
}
export function TimelineScreen() {
const g = useGame()
const c = g.case
const [sel, setSel] = useState<TimelineBeat | null>(null)
const conflicts = c.timeline.filter((t) => t.conflict)
const evOf = (id?: string | null): Evidence | undefined => (id ? c.evidence.find((e) => e.id === id) : undefined)
return (
<div class="app__view">
<Hud title="TIMELINE" sub={`THE NIGHT · ${conflicts.length} CONFLICTS`} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} />
<div class="screen-pad">
<div class="maxw">
<p class="t-body" style={{ marginBottom: 18, color: 'var(--bone-2)' }}>
The reconstructed night. <span class="ox">Amber pins are fixed facts; red nodes mark where a statement collides with the evidence.</span>
</p>
<div class="tl-track" style={{ overflowX: 'auto' }}>
<div class="tl-line" />
<div class="row" style={{ gap: g.mode === 'mobile' ? 14 : 22, minWidth: g.mode === 'mobile' ? 880 : 'auto', position: 'relative', zIndex: 2 }}>
{c.timeline.map((t, i) => {
const ev = evOf(t.ev)
return (
<div key={i} class="tl-stop grow" style={{ minWidth: 110, cursor: ev ? 'pointer' : 'default' }} onClick={() => ev && setSel(t)}>
<div class="t-mono amber" style={{ fontSize: 'calc(16px*var(--mono-scale))' }}>{t.time}</div>
<div class={'tl-node' + (t.locked ? ' tl-node--lock' : t.conflict ? ' tl-node--conflict' : '')} />
<Panel className="tl-slot" variant={t.conflict ? 'ox' : undefined} style={{ padding: 8, width: '100%' }}>
{ev && <div style={{ background: 'var(--ink-1)', padding: 3, width: 'fit-content', marginBottom: 5 }}><EvIcon icon={ev.icon} px={2} /></div>}
<div class="t-body" style={{ fontSize: 12, lineHeight: 1.3, color: t.conflict ? 'var(--ox-3)' : 'var(--bone-2)' }}>{t.label}</div>
</Panel>
</div>
)
})}
</div>
</div>
{conflicts.length > 0 && (
<div class="grid-3" style={{ marginTop: 22 }}>
{conflicts.map((t, i) => (
<Panel key={i} tab="CONFLICT" variant="ox">
<div class="t-body" style={{ fontSize: 14 }}>
<b class="ox">{t.time}.</b> {t.label}
{evOf(t.ev) && <> The {evOf(t.ev)!.name.toLowerCase()} tells a different story.</>}
</div>
</Panel>
))}
</div>
)}
<div class="center" style={{ marginTop: 20 }}>
<Btn variant="ox" onClick={() => g.nav('accuse')}>Build the accusation ▸</Btn>
</div>
</div>
</div>
{sel && (
<div class="tray" onClick={() => setSel(null)}>
<div class="tray__sheet" onClick={(e) => e.stopPropagation()}>
<div class="between" style={{ marginBottom: 10 }}>
<span class="t-display amber" style={{ fontSize: 11 }}>{sel.time} · {evOf(sel.ev)?.name}</span>
<Btn sm variant="ghost" onClick={() => setSel(null)}>✕</Btn>
</div>
<p class="t-body">{evOf(sel.ev)?.summary}</p>
</div>
</div>
)}
<BottomNav />
</div>
)
}
export function NotesScreen() {
const g = useGame()
const c = g.case
const grilled = c.suspects.filter((s) => (g.state.interrogations[s.id] || []).length > 1)
const pinned = c.evidence.filter((e) => g.state.pinned.includes(e.id))
const conflicts = c.timeline.filter((t) => t.conflict)
const sections: [string, preact.ComponentChildren][] = [
['SUSPECTS GRILLED', grilled.length ? grilled.map((s) => s.name).join(', ') : 'None yet. Start on the board.'],
['EXHIBITS PINNED', pinned.length ? pinned.map((e) => e.name).join(', ') : 'Pin the exhibits that contradict a statement.'],
['OPEN CONTRADICTIONS', conflicts.length ? conflicts.map((t) => t.label).join(' · ') : 'Reconstruct the timeline to surface them.'],
['THE QUESTION', 'Who could be where the evidence says, when it says — and who had the most to lose?'],
]
return (
<div class="app__view">
<Hud title="DETECTIVE'S NOTEBOOK" sub={c.id} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} />
<div class="screen-center">
<Panel tab="WORKING THEORY" style={{ width: 'min(640px,100%)', background: 'var(--ink-1)' }}>
<div class="col" style={{ gap: 0 }}>
{sections.map(([k, v], i) => (
<div key={i} style={{ padding: '14px 4px', boxShadow: i ? 'inset 0 2px 0 rgba(45,74,82,.4)' : 'none' }}>
<div class="t-display amber" style={{ fontSize: 10, marginBottom: 6 }}>{i + 1}. {k}</div>
<div class="t-body" style={{ fontSize: 15 }}>{v}</div>
</div>
))}
</div>
<div class="center" style={{ marginTop: 14 }}>
<Btn variant="ox" onClick={() => g.nav('accuse')}>Name the {perpNoun(g.case.kind)} ▸</Btn>
</div>
</Panel>
</div>
<BottomNav />
</div>
)
}