case0 / web /src /ui /components.tsx
HusseinEid's picture
feat: multi-crime cases, scene+exhibit pixel art, background AI generation
80cd1f2 verified
// UI kit — reusable pixel components. Ported from prototype/js/ui.jsx, wired to the
// store + procedural art modules instead of globals.
import type { ComponentChildren, JSX } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { EV_ICONS, IPAL, bodyFor, exhibitPainter, portraitFor, sceneFor } from '../engine/art'
import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
import { useGame } from '../store'
import type { Evidence, Suspect } from '../types'
import { type Sfx, musicIsPlaying, playSfx, toggleMusic } from './audio'
import { TweaksSheet } from './tweaks'
const pxScaled = (px: number, floor = 2) =>
Math.max(floor, Math.round(px * (window.__pxScale || 1)))
// What the culprit is called in this kind of case ("name the killer / thief / arsonist").
const PERP: Record<string, string> = {
homicide: 'killer', theft: 'thief', fraud: 'fraudster',
blackmail: 'blackmailer', arson: 'arsonist', missing_person: 'abductor',
}
export const perpNoun = (kind?: string): string => PERP[kind || 'homicide'] || 'culprit'
// ---- sprite wrappers ----
export function useBlink(): boolean {
const [b, setB] = useState(false)
useEffect(() => {
let t1: ReturnType<typeof setTimeout>
let t2: ReturnType<typeof setTimeout>
let alive = true
const loop = () => {
const wait = 2400 + Math.random() * 3200
t1 = setTimeout(() => {
if (!alive) return
setB(true)
t2 = setTimeout(() => {
if (alive) {
setB(false)
loop()
}
}, 120)
}, wait)
}
loop()
return () => {
alive = false
clearTimeout(t1)
clearTimeout(t2)
}
}, [])
return b
}
interface SpriteProps {
id: string
px?: number
style?: JSX.CSSProperties
className?: string
}
// Toggle the mouth open/closed at a speech-like cadence while the suspect's voice plays.
function useMouth(active: boolean): boolean {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!active) {
setOpen(false)
return
}
const id = setInterval(() => setOpen((o) => !o), 135)
return () => clearInterval(id)
}, [active])
return open
}
export function Portrait({ id, px = 6, blink = true, talking = false, style, className, gender }: SpriteProps & { blink?: boolean; talking?: boolean; gender?: string }) {
const p = portraitFor(id, gender)
const b = useBlink()
const mouth = useMouth(talking)
let frame = p.frames[0]
if (talking && p.frames[2]) frame = mouth ? p.frames[2] : p.frames[0]
else if (blink && b) frame = p.frames[1]
return <PixelCanvas map={frame} pal={p.pal} px={pxScaled(px)} style={style} className={className} />
}
export function Body({ id, px = 6, playing = true, style, className, gender }: SpriteProps & { playing?: boolean; gender?: string }) {
const p = bodyFor(id, gender)
return <PixelCanvas frames={p.frames} pal={p.pal} px={pxScaled(px)} fps={1.1} playing={playing} style={style} className={className} />
}
export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) {
const m = EV_ICONS[icon] || EV_ICONS.photoEv
return <PixelCanvas map={m} pal={IPAL} px={pxScaled(px, 1)} style={style} />
}
interface SceneProps {
name: string
w?: number
h?: number
anim?: boolean
full?: boolean
cover?: boolean
style?: JSX.CSSProperties
className?: string
deps?: unknown[]
}
export function Scene({ name, w = 240, h = 135, anim = false, full = false, cover = false, style, className, deps = [] }: SceneProps) {
const st = cover ? { objectFit: 'cover' as const, ...style } : style
return (
<SceneCanvas
paint={sceneFor(name)}
w={w}
h={h}
anim={anim}
full={full}
style={st}
className={className}
deps={[name, anim, full, ...deps]}
/>
)
}
interface ExhibitArtProps {
e: Pick<Evidence, 'id' | 'name' | 'summary'>
w?: number
h?: number
style?: JSX.CSSProperties
className?: string
}
/** Procedural "evidence photo" of one exhibit - the object itself, on the forensic
* table, classified from the exhibit's own words and seeded by its id. */
export function ExhibitArt({ e, w = 96, h = 72, style, className }: ExhibitArtProps) {
return (
<SceneCanvas
paint={exhibitPainter(e.name, e.summary || '', e.id)}
w={w}
h={h}
style={style}
className={className}
deps={[e.id, e.name]}
/>
)
}
// ---- primitives ----
interface PanelProps {
tab?: string
variant?: 'amber' | 'ox' | 'raised' | 'inset'
className?: string
style?: JSX.CSSProperties
children?: ComponentChildren
onClick?: (e: MouseEvent) => void
}
export function Panel({ tab, variant, className = '', style, children, onClick }: PanelProps) {
const v = variant ? ` panel--${variant}` : ''
return (
<div class={`panel${v} ${className}`} style={style} onClick={onClick}>
{tab && <span class="panel__tab">{tab}</span>}
{children}
</div>
)
}
type BtnProps = {
variant?: 'amber' | 'ox' | 'ghost'
sm?: boolean
className?: string
children?: ComponentChildren
/** SFX on click: a specific cue, or null to silence. Defaults to "click". */
sfx?: Sfx | null
} & JSX.HTMLAttributes<HTMLButtonElement>
export function Btn({ variant, sm, className = '', children, sfx, onClick, ...rest }: BtnProps) {
const v = variant ? ` pbtn--${variant}` : ''
const handle = (e: MouseEvent) => {
if (sfx !== null) playSfx(sfx || 'click')
;(onClick as ((ev: MouseEvent) => void) | undefined)?.(e)
}
return (
<button class={`pbtn${v}${sm ? ' pbtn--sm' : ''} ${className}`} onClick={handle} {...rest}>
{children}
</button>
)
}
export function Chip({ variant, children, style }: { variant?: 'amber' | 'ox'; children?: ComponentChildren; style?: JSX.CSSProperties }) {
const v = variant ? ` chip--${variant}` : ''
return <span class={`chip${v}`} style={style}>{children}</span>
}
export function Stamp({ children, slam, style }: { children?: ComponentChildren; slam?: boolean; style?: JSX.CSSProperties }) {
return <span class={`stamp${slam ? ' stamp--slam' : ''}`} style={style}>{children}</span>
}
// ---- suspicion / composure bar ----
export function SuspicionBar({ value, label = 'SUSPICION', compact }: { value: number; label?: string | null; compact?: boolean }) {
const v = Math.max(0, Math.min(100, Math.round(value)))
const calm = v < 42
return (
<div class="col" style={{ gap: compact ? 3 : 5 }}>
{!compact && label && (
<div class="between">
<span class="t-label">{label}</span>
<span class="t-mono" style={{ color: calm ? 'var(--slate-3)' : 'var(--ox-3)', fontSize: 'calc(14px*var(--mono-scale))' }}>{v}%</span>
</div>
)}
<div class="bar" style={compact ? { height: 10 } : undefined}>
<div class={'bar__fill' + (calm ? ' bar__fill--calm' : '')} style={{ width: v + '%' }} />
</div>
</div>
)
}
// ---- suspect card ----
export function SuspectCard({ s, onClick, active, mini }: { s: Suspect; onClick?: (e: MouseEvent) => void; active?: boolean; mini?: boolean }) {
const g = useGame()
const susp = g.state.suspicion[s.id]
return (
<Panel
className="suspect-card"
variant={active ? 'amber' : undefined}
style={{ padding: mini ? 8 : 12, cursor: onClick ? 'pointer' : 'default', minWidth: mini ? 0 : 168 }}
onClick={onClick}
>
<div class="row" style={{ gap: 10, alignItems: 'flex-start' }}>
<div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0 }}>
<Portrait id={s.sprite} px={mini ? 3 : 4} gender={s.gender} />
</div>
<div class="col grow" style={{ gap: 5, minWidth: 0 }}>
<div>
<div class="t-display" style={{ fontSize: mini ? 9 : 11, color: 'var(--bone-3)' }}>{s.name}</div>
<div class="t-label" style={{ marginTop: 3 }}>{s.tag}</div>
</div>
{!mini && <div class="t-body dim" style={{ fontSize: 12, lineHeight: 1.35 }}>{s.role}</div>}
<SuspicionBar value={susp} compact />
</div>
</div>
</Panel>
)
}
// ---- evidence card with "develops in" reveal ----
export function EvidenceCard({ e, onClick, active, develop, small }: { e: Evidence; onClick?: (ev: MouseEvent) => void; active?: boolean; develop?: boolean; small?: boolean }) {
const [revealed, setRevealed] = useState(!develop)
useEffect(() => {
if (develop) {
const t = setTimeout(() => setRevealed(true), 60)
return () => clearTimeout(t)
}
}, [develop])
return (
<Panel
variant={active ? 'amber' : undefined}
className="ev-card"
style={{ padding: small ? 8 : 11, cursor: onClick ? 'pointer' : 'default', position: 'relative', overflow: 'hidden' }}
onClick={onClick}
>
<div class="row" style={{ gap: 10, alignItems: 'center' }}>
<div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 4, flexShrink: 0 }}>
<EvIcon icon={e.icon} px={small ? 2 : 3} />
</div>
<div class="col grow" style={{ gap: 4, minWidth: 0 }}>
<div class="t-display" style={{ fontSize: small ? 8 : 10, color: 'var(--bone-3)' }}>{e.name}</div>
<div class="row" style={{ gap: 6, alignItems: 'center' }}>
<Chip variant="amber" style={{ fontSize: 'calc(12px*var(--mono-scale))', padding: '2px 5px' }}>{e.type}</Chip>
<span class="t-mono dim nowrap" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{e.time}</span>
</div>
</div>
</div>
{develop && !revealed && <span class="develop-veil" />}
{develop && <span class="develop-veil develop-veil--anim" />}
</Panel>
)
}
// ---- dialogue / speech panel with typewriter ----
export function DialoguePanel({ who, text, speed = 26, onDone, instant, tag }: { who: string; text: string; speed?: number; onDone?: () => void; instant?: boolean; tag?: string | null }) {
const [out, done] = useTypewriter(text, speed, !instant)
useEffect(() => {
if (done && onDone) onDone()
}, [done])
return (
<Panel className="dialogue" style={{ padding: 16 }}>
<div class="between" style={{ marginBottom: 8 }}>
<span class="t-display amber" style={{ fontSize: 11 }}>{who}</span>
{tag && <Chip variant="ox">{tag}</Chip>}
</div>
<div class="t-body" style={{ minHeight: 56, color: 'var(--bone-2)' }}>
{out}
{!done && <span class="cursor" />}
</div>
</Panel>
)
}
// ---- type a string once, fire onDone ----
export function TypeOnce({ text, speed, onDone }: { text: string; speed: number; onDone?: () => void }) {
const [out, done] = useTypewriter(text, speed, true)
const [fired, setFired] = useState(false)
useEffect(() => {
if (done && !fired) {
setFired(true)
onDone?.()
}
}, [done])
return (
<>
{out}
{!done && <span class="cursor" />}
</>
)
}
// ---- HINT trigger button ----
export function HintButton() {
return (
<button class="hint-btn" onClick={() => window.dispatchEvent(new Event('toggle-hint'))} title="Ask your partner">
<span class="hint-btn__dot" /> HINT
</button>
)
}
// ---- top HUD bar ----
// Navbar controls: optional Main-menu, Music toggle, Settings sheet. Compact icon buttons so
// they sit gracefully in the HUD bar (and on mobile) instead of floating mid-screen.
export function Controls({ menu = false }: { menu?: boolean }) {
const g = useGame()
const [musicOn, setMusicOn] = useState(musicIsPlaying())
const [settings, setSettings] = useState(false)
const icon = (label: string, title: string, on: boolean, onClick: () => void) => (
<button
class="hint-btn"
title={title}
aria-label={title}
onClick={onClick}
style={{ width: 30, height: 28, padding: 0, justifyContent: 'center', fontSize: '0.95rem', opacity: on ? 1 : 0.5 }}
>
{label}
</button>
)
return (
<div class="row" style={{ gap: 5, alignItems: 'center' }}>
{menu && <Btn sm variant="ghost" onClick={() => g.nav('title')}>Menu</Btn>}
{icon('♪', musicOn ? 'Music: on' : 'Music: off', musicOn, () => setMusicOn(toggleMusic()))}
{icon('⚙', 'Settings', true, () => setSettings(true))}
{settings && <TweaksSheet onClose={() => setSettings(false)} />}
</div>
)
}
export function Hud({ title, sub, right }: { title: string; sub?: string; right?: ComponentChildren }) {
const g = useGame()
return (
<div class="hud">
<div class="row" style={{ gap: 12, alignItems: 'center', minWidth: 0 }}>
<button class="hud-badge" onClick={() => g.nav('board')} title="Investigation Board">
<span class="t-display" style={{ fontSize: 9, color: 'var(--ink-0)' }}>CASE</span>
<span class="t-mono" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: 'var(--ink-0)' }}>{g.case.id}</span>
</button>
<div class="col" style={{ gap: 2, minWidth: 0 }}>
<div class="t-display hud__title">{title}</div>
{sub && <div class="t-label nowrap">{sub}</div>}
</div>
</div>
<div class="row" style={{ gap: 6, alignItems: 'center' }}>
{right}
<Controls menu />
<HintButton />
</div>
</div>
)
}
// ---- bottom nav (mobile) ----
const NAV_ITEMS = [
{ id: 'briefing', label: 'CASE', icon: 'file' },
{ id: 'board', label: 'BOARD', icon: 'board' },
{ id: 'suspects', label: 'SUSPECTS', icon: 'people' },
{ id: 'evidence', label: 'EVIDENCE', icon: 'box' },
{ id: 'timeline', label: 'TIME', icon: 'clock' },
] as const
function NavGlyph({ icon, on }: { icon: string; on: boolean }) {
const c = on ? 'var(--ink-0)' : 'var(--bone-1)'
const P = (d: JSX.CSSProperties) => <span style={{ position: 'absolute', ...d }} />
return (
<span style={{ position: 'relative', width: 18, height: 18, display: 'inline-block' }}>
{icon === 'file' && (<>{P({ left: 4, top: 2, width: 10, height: 14, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 6, width: 6, height: 2, background: c })}{P({ left: 6, top: 10, width: 6, height: 2, background: c })}</>)}
{icon === 'board' && (<>{P({ left: 2, top: 3, width: 14, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 7, width: 3, height: 3, background: c })}{P({ left: 11, top: 9, width: 3, height: 3, background: c })}</>)}
{icon === 'people' && (<>{P({ left: 3, top: 3, width: 5, height: 5, background: c })}{P({ left: 3, top: 9, width: 7, height: 6, background: c })}{P({ left: 11, top: 4, width: 4, height: 4, background: c })}{P({ left: 10, top: 9, width: 6, height: 6, background: c })}</>)}
{icon === 'box' && (<>{P({ left: 3, top: 4, width: 12, height: 11, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 3, top: 4, width: 12, height: 2, background: c })}{P({ left: 8, top: 8, width: 2, height: 4, background: c })}</>)}
{icon === 'clock' && (<>{P({ left: 3, top: 3, width: 12, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 8, top: 6, width: 2, height: 4, background: c })}{P({ left: 8, top: 9, width: 4, height: 2, background: c })}</>)}
</span>
)
}
export function BottomNav() {
const g = useGame()
const cur = g.state.screen
return (
<nav class="bottom-nav">
{NAV_ITEMS.map((it) => {
const on = cur === it.id || (it.id === 'suspects' && cur === 'interro')
return (
<button
key={it.id}
class={'nav-btn' + (on ? ' nav-btn--on' : '')}
onClick={() => g.nav((it.id === 'suspects' ? 'board' : it.id) as Parameters<typeof g.nav>[0])}
>
<NavGlyph icon={it.icon} on={on} />
<span class="t-display" style={{ fontSize: 7, letterSpacing: '.06em' }}>{it.label}</span>
</button>
)
})}
</nav>
)
}