Spaces:
Running
Running
| // Det. Hale — your partner on the wire. Contextual, spoiler-safe hints from the server, | |
| // plus one-time local nudges that introduce each screen to a new player. | |
| import { useEffect, useState } from 'preact/hooks' | |
| import { getHint } from '../api' | |
| import { useTypewriter } from '../engine/pixel' | |
| import { type Screen, useGame } from '../store' | |
| import { hasSeen, markSeen } from '../tips' | |
| import { Portrait } from './components' | |
| const HIDDEN = new Set(['title', 'verdict', 'share', 'boot']) | |
| const FALLBACK = 'Work the evidence, detective. Find where a statement and a fact disagree, and press there.' | |
| // First-visit nudges (client-local, no server call). Shown once per browser. | |
| const TIPS: Partial<Record<Screen, string>> = { | |
| board: 'Pick a suspect and lean on them. Work the checklist on the rail — question everyone, show them the exhibits.', | |
| interro: 'Ask your questions, then hit them with evidence from the tray. Watch that bar climb.', | |
| accuse: 'Name them, give me the why, and attach what made them crack.', | |
| } | |
| export function Assistant() { | |
| const g = useGame() | |
| const [open, setOpen] = useState(false) | |
| const [hint, setHint] = useState('') | |
| const [localTip, setLocalTip] = useState<string | null>(null) | |
| const screen = g.state.screen | |
| useEffect(() => { | |
| const t = () => { | |
| setLocalTip(null) // HINT button always fetches the live server hint | |
| setOpen((o) => !o) | |
| } | |
| const c = () => setOpen(false) | |
| window.addEventListener('toggle-hint', t) | |
| window.addEventListener('close-hint', c) | |
| return () => { | |
| window.removeEventListener('toggle-hint', t) | |
| window.removeEventListener('close-hint', c) | |
| } | |
| }, []) | |
| // Screen change: close whatever was open, then fire the one-time nudge for this | |
| // screen kind. On the board it waits for the case-brief overlay to be dismissed | |
| // so the two never stack. | |
| useEffect(() => { | |
| setOpen(false) | |
| setLocalTip(null) | |
| const tip = TIPS[screen] | |
| if (!tip || hasSeen(`tip:${screen}`)) return | |
| let timer: ReturnType<typeof setTimeout> | null = null | |
| const show = () => { | |
| markSeen(`tip:${screen}`) | |
| setLocalTip(tip) | |
| setOpen(true) | |
| } | |
| const briefPending = screen === 'board' && !hasSeen(`brief:${g.case.id}`) | |
| const onBrief = () => { | |
| timer = setTimeout(show, 400) | |
| } | |
| if (briefPending) window.addEventListener('cz-brief-done', onBrief, { once: true }) | |
| else timer = setTimeout(show, 700) | |
| return () => { | |
| if (timer) clearTimeout(timer) | |
| window.removeEventListener('cz-brief-done', onBrief) | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [screen]) | |
| useEffect(() => { | |
| if (!open || localTip) return | |
| let alive = true | |
| setHint('') | |
| getHint(g.runId, screen) | |
| .then((r) => { | |
| if (alive) setHint(r.hint || FALLBACK) | |
| }) | |
| .catch(() => { | |
| if (alive) setHint(FALLBACK) | |
| }) | |
| return () => { | |
| alive = false | |
| } | |
| }, [open, localTip, screen, g.runId]) | |
| const [typed, done] = useTypewriter(open ? localTip ?? hint : '', g.state.tweaks.typeSpeed || 18, open) | |
| if (HIDDEN.has(screen) || !open) return null | |
| return ( | |
| <div class="assistant"> | |
| <div class="assistant__panel"> | |
| <div class="between" style={{ marginBottom: 8 }}> | |
| <div class="row" style={{ gap: 8, alignItems: 'center' }}> | |
| <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 2 }}> | |
| <Portrait id="detective" px={3} /> | |
| </div> | |
| <div class="col" style={{ gap: 1 }}> | |
| <span class="t-display amber" style={{ fontSize: 9 }}>DET. HALE</span> | |
| <span class="t-label" style={{ fontSize: 7 }}>YOUR PARTNER · ON THE WIRE</span> | |
| </div> | |
| </div> | |
| <button class="assistant__x" onClick={() => setOpen(false)}>✕</button> | |
| </div> | |
| <p class="t-body" style={{ fontSize: 14, lineHeight: 1.5, color: 'var(--bone-2)' }}> | |
| “{typed} | |
| {!done && <span class="cursor" />}” | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| } | |