// 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' 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 = { 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 let t2: ReturnType 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 } export function Body({ id, px = 6, playing = true, style, className, gender }: SpriteProps & { playing?: boolean; gender?: string }) { const p = bodyFor(id, gender) return } export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) { const m = EV_ICONS[icon] || EV_ICONS.photoEv return } 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 ( ) } interface ExhibitArtProps { e: Pick 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 ( ) } // ---- 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 (
{tab && {tab}} {children}
) } 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.ButtonHTMLAttributes 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 ( ) } export function Chip({ variant, children, style }: { variant?: 'amber' | 'ox'; children?: ComponentChildren; style?: JSX.CSSProperties }) { const v = variant ? ` chip--${variant}` : '' return {children} } export function Stamp({ children, slam, style }: { children?: ComponentChildren; slam?: boolean; style?: JSX.CSSProperties }) { return {children} } // ---- 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 (
{!compact && label && (
{label} {v}%
)}
) } // ---- 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 (
{s.name}
{s.tag}
{!mini &&
{s.role}
}
) } // ---- 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 (
{e.name}
{e.type} {e.time}
{develop && !revealed && } {develop && }
) } // ---- 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 (
{who} {tag && {tag}}
{out} {!done && }
) } // ---- 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 && } ) } // ---- HINT trigger button ---- export function HintButton() { return ( ) } // ---- top HUD bar ---- // Navbar controls: optional Main-menu and Music toggle. 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 icon = (label: string, title: string, on: boolean, onClick: () => void) => ( ) return (
{menu && (g.mode === 'mobile' ? icon('≡', 'Main menu', true, () => g.nav('title')) : g.nav('title')}>Menu)} {icon('♪', musicOn ? 'Music: on' : 'Music: off', musicOn, () => setMusicOn(toggleMusic()))}
) } export function Hud({ title, sub, right }: { title: string; sub?: string; right?: ComponentChildren }) { const g = useGame() return (
{title}
{sub &&
{sub}
}
{right}
) } // ---- 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) => return ( {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 })})} ) } export function BottomNav() { const g = useGame() const cur = g.state.screen return ( ) }