case0 / web /src /screens /interro.tsx
HusseinEid's picture
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds (#4)
16ff49b
Raw
History Blame Contribute Delete
9.79 kB
// Interrogation room — questions, free-text, and present-evidence all go to the server,
// which returns the suspect's reply + the authoritative suspicion. The client only displays.
import { useEffect, useRef, useState } from 'preact/hooks'
import { interrogate } from '../api'
import { HOT_SUSPICION, useGame } from '../store'
import type { Evidence, SuggestedQuestion } from '../types'
import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
import { CrackBanner, DeltaFloater } from '../ui/juice'
interface Pending {
text: string
tag?: string | null
suspicion: number
speed?: number // typewriter ms/char, paced to the voice when one is available
}
export function Interrogation() {
const g = useGame()
const c = g.case
const sid = (g.state.payload.suspect as string) || c.suspects[0].id
const s = c.suspects.find((x) => x.id === sid)!
const transcript = g.state.interrogations[sid] || []
const susp = g.state.suspicion[sid]
const usedQ = g.state.usedQ[sid] || []
const usedEv = g.state.usedEv[sid] || []
const [pending, setPending] = useState<Pending | null>(null)
const [thinking, setThinking] = useState(false)
const [talking, setTalking] = useState(false)
const [tray, setTray] = useState(false)
const [input, setInput] = useState('')
const [floater, setFloater] = useState<{ delta: number; id: number } | null>(null)
const [crack, setCrack] = useState(false)
const scroller = useRef<HTMLDivElement>(null)
const busy = thinking || !!pending
useEffect(() => () => stopSpeak(), []) // stop any voice when leaving the room
useEffect(() => {
if (!transcript.length) setPending({ text: s.greet, suspicion: susp })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sid])
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [transcript, pending, thinking])
const commit = () => {
if (!pending) return
g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'sus', text: pending.text } })
g.dispatch({ type: 'SUSP_SET', sid, value: pending.suspicion })
setPending(null)
}
const run = async (body: Parameters<typeof interrogate>[2], detLine: { text: string; ev?: string }) => {
if (busy) return
stopSpeak()
setTalking(false)
g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'det', text: detLine.text, ev: detLine.ev } })
setThinking(true)
const fallback = g.state.tweaks.typeSpeed || 22
try {
const r = await interrogate(g.runId, sid, body)
const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
// Feedback moments: record what rattled whom, float the suspicion delta, and
// slam the banner the FIRST time this suspect's lie cracks.
const flags = r.flags || {}
const crackedNow = !!(flags.cornered || flags.contradictionExposed)
const firstCrack = crackedNow && !g.state.cracked[sid]
if (crackedNow || (flags.rattled && body.presentEvidenceId)) {
g.dispatch({
type: 'FLAG',
sid,
evId: body.presentEvidenceId,
kind: flags.contradictionExposed ? 'contradiction' : flags.cornered ? 'cornered' : 'rattled',
})
}
if (typeof r.suspicionDelta === 'number' && r.suspicionDelta !== 0) setFloater({ delta: r.suspicionDelta, id: Date.now() })
if (firstCrack) {
setCrack(true)
playSfx('success')
setTimeout(() => setCrack(false), 1700)
}
// Synthesize the voice during the "thinking" beat so text + speech start together.
const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
// Pace the typewriter to the audio so words land in step with the spoken line.
const speed = voice ? Math.max(14, Math.min(90, Math.round(voice.durationMs / Math.max(1, r.reply.length)))) : fallback
setThinking(false)
setPending({ text: r.reply, suspicion: r.suspicion, tag, speed })
if (voice) {
setTalking(true)
voice.play()
}
} catch {
setThinking(false)
setPending({ text: '…I have nothing more to say to that.', suspicion: susp, speed: fallback })
}
}
const ask = (q: SuggestedQuestion) => {
if (busy) return
playSfx('select')
g.dispatch({ type: 'USEQ', sid, qid: q.id })
run({ questionId: q.id }, { text: q.q })
}
const present = (e: Evidence) => {
setTray(false)
if (busy) return
playSfx('present')
g.dispatch({ type: 'USEEV', sid, ev: e.id })
run({ presentEvidenceId: e.id }, { text: `Look at this. ${e.name}.`, ev: e.icon })
}
const sendFree = () => {
const txt = input.trim()
if (!txt || busy) return
setInput('')
run({ freeText: txt }, { text: txt })
}
const px = g.mode === 'mobile' ? 8 : 13
return (
<div class="app__view">
<Hud
title={s.name}
sub={s.role}
right={
<>
<div class="col" style={{ width: g.mode === 'mobile' ? 96 : 180, position: 'relative' }}>
<SuspicionBar value={susp} />
{floater && <DeltaFloater key={floater.id} delta={floater.delta} />}
{susp >= HOT_SUSPICION && (
<button class="accuse-chip" onClick={() => g.nav('accuse', { suspect: sid })}>CORNERED — ACCUSE ▸</button>
)}
</div>
<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
</>
}
/>
<div class="interro" style={{ position: 'relative' }}>
<div class="interro__stage">
<Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
<div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={s.sprite} px={px} gender={s.gender} accent={s.accentColor} talking={talking} /></div>
{crack && <CrackBanner />}
<div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 4 }}>
<Panel style={{ padding: 8, width: 'fit-content', maxWidth: '100%' }}>
<span class="t-label">{s.tag}</span>
<div class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>ALIBI · {s.alibi}</div>
</Panel>
</div>
</div>
<div class="interro__right">
<div class="transcript" ref={scroller}>
{transcript.map((l, i) => (
<div key={i} class={'tline tline--' + (l.role === 'det' ? 'det' : 'sus')}>
<div class="tline__who">{l.role === 'det' ? 'YOU' : s.name}</div>
<div class="tline__b">
{l.ev && (
<span style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6, background: 'var(--ink-1)', padding: 2 }}>
<EvIcon icon={l.ev} px={2} />
</span>
)}
{l.text}
</div>
</div>
))}
{thinking && (
<div class="tline tline--sus">
<div class="tline__who">{s.name}</div>
<div class="tline__b"><span class="cursor" /></div>
</div>
)}
{pending && (
<div class="tline tline--sus">
<div class="tline__who">{s.name}{pending.tag && <span class="ox"> · {pending.tag}</span>}</div>
<div class="tline__b"><TypeOnce text={pending.text} speed={pending.speed ?? (g.state.tweaks.typeSpeed || 22)} onDone={commit} /></div>
</div>
)}
</div>
<div class="composer">
<div class="qsuggest">
{s.suggestedQuestions.map((q) => (
<button key={q.id} class="qchip" disabled={usedQ.includes(q.id) || busy} onClick={() => ask(q)}>
{usedQ.includes(q.id) ? '✓ ' : '? '}
{q.q}
</button>
))}
</div>
<div class="composer__input">
<input
class="pinput"
placeholder="Type your question…"
value={input}
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === 'Enter' && sendFree()}
/>
<Btn variant="amber" sm onClick={sendFree} disabled={busy}>Ask</Btn>
<Btn variant="ox" sm onClick={() => setTray(true)} disabled={busy}>Evidence ▴</Btn>
</div>
</div>
</div>
{tray && (
<div class="tray" onClick={() => setTray(false)}>
<div class="tray__sheet" onClick={(e) => e.stopPropagation()}>
<div class="between" style={{ marginBottom: 12 }}>
<span class="t-display amber" style={{ fontSize: 12 }}>PRESENT EVIDENCE</span>
<Btn sm variant="ghost" onClick={() => setTray(false)}>✕ Close</Btn>
</div>
<div class="tray__grid">
{c.evidence.map((e) => (
<EvidenceCard key={e.id} e={e} active={usedEv.includes(e.id)} onClick={() => present(e)} />
))}
</div>
</div>
</div>
)}
</div>
</div>
)
}