// What the table shows of each engine event beyond the state itself, as 无名杀 does: a card played, discarded, judged // or shown flies from its seat to the centre and stays there a moment with a caption (谁 → 谁), and a line runs from // the user to each target; a basic card's name flashes on its user. Fed by the same event batches as the upstream's play history (usePlayHistory). import { useEffect, useRef, useState } from 'react'; import type { GameView, ViewEvent } from '../../../src/engine/types'; import type { QueuedEvent } from '../../../src/client/hooks/useEventPlayback'; import type { Face } from './Card'; export interface CenterCard { id: string; face: Face; caption: string; from: number | null; cardId?: string; at: number; } /** A basic card's name flashing large on its user, as 无名杀's cardeffect. */ export interface Word { id: string; seat: number; text: string; at: number; } export interface Line { id: string; from: number; to: number; kind: 'sha' | 'trick'; at: number; } const KEEP_MS = 4200; const LINE_MS = 1400; const WORD_MS = 1100; const WORDS: Record = { 杀: 'sha', 火杀: 'sha', 雷杀: 'sha', 闪: 'shan', 桃: 'tao', 酒: 'jiu' }; const MAX_CENTER = 7; let n = 0; const id = () => `fx${++n}`; export function useFx(ingested: readonly QueuedEvent[] | undefined, view: GameView) { const [center, setCenter] = useState([]); const [lines, setLines] = useState([]); const [words, setWords] = useState([]); const last = useRef(-1); const viewRef = useRef(view); viewRef.current = view; useEffect(() => { const batch = ingested ?? []; if (!batch.length) return; // A batch from before the last one is a jump back (a replay seek): start the table's effects over. if (batch.every((e) => e.seq <= last.current)) { last.current = Math.min(...batch.map((e) => e.seq)) - 1; setCenter([]); setLines([]); setWords([]); } const fresh = batch.filter((e) => e.seq > last.current); if (!fresh.length) return; last.current = Math.max(...fresh.map((e) => e.seq)); const v = viewRef.current; const name = (i: unknown) => v.players[i as number]?.character || v.players[i as number]?.name || ''; const now = Date.now(); const add: CenterCard[] = []; const newLines: Line[] = []; const newWords: Word[] = []; const captions = new Map(); for (const { event } of fresh) { const e = event as ViewEvent & Record; const card = e.card as { name?: string; suit?: string; rank?: string } | null | undefined; const full = typeof e.cardId === 'string' ? v.cardMap[e.cardId] : undefined; const face = (c: { name?: string; suit?: string; rank?: string } | undefined, cid?: string): Face | null => { const f = cid ? v.cardMap[cid] : undefined; const nm = c?.name ?? f?.name; return nm ? { name: nm, suit: c?.suit ?? f?.suit, rank: c?.rank ?? f?.rank, damageType: f?.damageType } : null; }; if (e.type === '打出' || e.type === '弃牌' || e.type === '展示') { const f = face(card ?? undefined, e.cardId as string | undefined); const verb = e.type === '打出' ? '' : e.type === '弃牌' ? ' 弃置' : ' 展示'; if (f) add.push({ id: id(), face: f, caption: `${name(e.player)}${verb}`, from: e.player as number, cardId: e.cardId as string, at: now }); const label = f && (f.damageType === '火焰' ? '火杀' : f.damageType === '雷电' ? '雷杀' : f.name); if (e.type === '打出' && label && WORDS[label]) newWords.push({ id: id(), seat: e.player as number, text: label, at: now }); } else if (e.type === '弃置') { const ids = (e.cardIds as string[] | undefined) ?? []; const names = (e.cardNames as string[] | undefined) ?? []; for (let i = 0; i < Math.max(ids.length, names.length); i++) { const f = face(names[i] ? { name: names[i], ...pick(v.cardMap[ids[i]]) } : undefined, ids[i]); if (f) add.push({ id: id(), face: f, caption: `${name(e.player)} 弃置`, from: e.player as number, cardId: ids[i], at: now }); } } else if (e.type === '判定') { const f = face(card ?? undefined, e.cardId as string | undefined); if (f) add.push({ id: id(), face: f, caption: `${name(e.player)} ${(e.judgeType as string) ?? ''}判定`, from: null, cardId: e.cardId as string, at: now }); } else if (e.type === '指定目标' && typeof e.source === 'number' && typeof e.target === 'number') { const cardName = (e.cardName as string) ?? full?.name; if (e.source !== e.target) newLines.push({ id: id(), from: e.source, to: e.target, kind: cardName === '杀' ? 'sha' : 'trick', at: now }); if (typeof e.cardId === 'string') { const prev = captions.get(e.cardId); captions.set(e.cardId, prev ? `${prev}、${name(e.target)}` : `${name(e.source)} → ${name(e.target)}`); } } } if (add.length || captions.size) setCenter((prev) => { let next = [...prev.filter((c) => now - c.at < KEEP_MS), ...add]; if (captions.size) next = next.map((c) => (c.cardId && captions.has(c.cardId) ? { ...c, caption: captions.get(c.cardId)! } : c)); return next.slice(-MAX_CENTER); }); if (newLines.length) setLines((prev) => [...prev.filter((l) => now - l.at < LINE_MS), ...newLines]); if (newWords.length) setWords((prev) => [...prev.filter((w) => now - w.at < WORD_MS), ...newWords]); }, [ingested]); // expire useEffect(() => { if (!center.length && !lines.length && !words.length) return; const t = setInterval(() => { const now = Date.now(); setCenter((p) => (p.some((c) => now - c.at >= KEEP_MS) ? p.filter((c) => now - c.at < KEEP_MS) : p)); setLines((p) => (p.some((l) => now - l.at >= LINE_MS) ? p.filter((l) => now - l.at < LINE_MS) : p)); setWords((p) => (p.some((w) => now - w.at >= WORD_MS) ? p.filter((w) => now - w.at < WORD_MS) : p)); }, 300); return () => clearInterval(t); }, [center.length, lines.length, words.length]); return { center, lines, words }; } const pick = (c?: { suit?: string; rank?: string }) => (c ? { suit: c.suit, rank: c.rank } : {});