Spaces:
Running
Running
| // 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> | |
| ) | |
| } | |