case0 / web /src /ui /assistant.tsx
HusseinEid's picture
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds (#4)
16ff49b
Raw
History Blame
4.1 kB
// 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>
)
}