| 1 | |
| 2 | |
| 3 | |
| 4 | import { useEffect, useRef, useState } from 'react'; |
| 5 | import type { GameView, ViewEvent } from '../../../src/engine/types'; |
| 6 | import type { QueuedEvent } from '../../../src/client/hooks/useEventPlayback'; |
| 7 | import type { Face } from './Card'; |
| 8 | |
| 9 | export interface CenterCard { |
| 10 | id: string; |
| 11 | face: Face; |
| 12 | caption: string; |
| 13 | from: number | null; |
| 14 | cardId?: string; |
| 15 | at: number; |
| 16 | } |
| 17 | |
| 18 | export interface Word { |
| 19 | id: string; |
| 20 | seat: number; |
| 21 | text: string; |
| 22 | at: number; |
| 23 | } |
| 24 | export interface Line { |
| 25 | id: string; |
| 26 | from: number; |
| 27 | to: number; |
| 28 | kind: 'sha' | 'trick'; |
| 29 | at: number; |
| 30 | } |
| 31 | |
| 32 | const KEEP_MS = 4200; |
| 33 | const LINE_MS = 1400; |
| 34 | const WORD_MS = 1100; |
| 35 | const WORDS: Record<string, string> = { 杀: 'sha', 火杀: 'sha', 雷杀: 'sha', 闪: 'shan', 桃: 'tao', 酒: 'jiu' }; |
| 36 | const MAX_CENTER = 7; |
| 37 | let n = 0; |
| 38 | const id = () => `fx${++n}`; |
| 39 | |
| 40 | export function useFx(ingested: readonly QueuedEvent[] | undefined, view: GameView) { |
| 41 | const [center, setCenter] = useState<CenterCard[]>([]); |
| 42 | const [lines, setLines] = useState<Line[]>([]); |
| 43 | const [words, setWords] = useState<Word[]>([]); |
| 44 | const last = useRef(-1); |
| 45 | const viewRef = useRef(view); |
| 46 | viewRef.current = view; |
| 47 | |
| 48 | useEffect(() => { |
| 49 | const batch = ingested ?? []; |
| 50 | if (!batch.length) return; |
| 51 | |
| 52 | if (batch.every((e) => e.seq <= last.current)) { |
| 53 | last.current = Math.min(...batch.map((e) => e.seq)) - 1; |
| 54 | setCenter([]); |
| 55 | setLines([]); |
| 56 | setWords([]); |
| 57 | } |
| 58 | const fresh = batch.filter((e) => e.seq > last.current); |
| 59 | if (!fresh.length) return; |
| 60 | last.current = Math.max(...fresh.map((e) => e.seq)); |
| 61 | const v = viewRef.current; |
| 62 | const name = (i: unknown) => v.players[i as number]?.character || v.players[i as number]?.name || ''; |
| 63 | const now = Date.now(); |
| 64 | const add: CenterCard[] = []; |
| 65 | const newLines: Line[] = []; |
| 66 | const newWords: Word[] = []; |
| 67 | const captions = new Map<string, string>(); |
| 68 | for (const { event } of fresh) { |
| 69 | const e = event as ViewEvent & Record<string, unknown>; |
| 70 | const card = e.card as { name?: string; suit?: string; rank?: string } | null | undefined; |
| 71 | const full = typeof e.cardId === 'string' ? v.cardMap[e.cardId] : undefined; |
| 72 | const face = (c: { name?: string; suit?: string; rank?: string } | undefined, cid?: string): Face | null => { |
| 73 | const f = cid ? v.cardMap[cid] : undefined; |
| 74 | const nm = c?.name ?? f?.name; |
| 75 | return nm ? { name: nm, suit: c?.suit ?? f?.suit, rank: c?.rank ?? f?.rank, damageType: f?.damageType } : null; |
| 76 | }; |
| 77 | if (e.type === '打出' || e.type === '弃牌' || e.type === '展示') { |
| 78 | const f = face(card ?? undefined, e.cardId as string | undefined); |
| 79 | const verb = e.type === '打出' ? '' : e.type === '弃牌' ? ' 弃置' : ' 展示'; |
| 80 | 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 }); |
| 81 | const label = f && (f.damageType === '火焰' ? '火杀' : f.damageType === '雷电' ? '雷杀' : f.name); |
| 82 | if (e.type === '打出' && label && WORDS[label]) newWords.push({ id: id(), seat: e.player as number, text: label, at: now }); |
| 83 | } else if (e.type === '弃置') { |
| 84 | const ids = (e.cardIds as string[] | undefined) ?? []; |
| 85 | const names = (e.cardNames as string[] | undefined) ?? []; |
| 86 | for (let i = 0; i < Math.max(ids.length, names.length); i++) { |
| 87 | const f = face(names[i] ? { name: names[i], ...pick(v.cardMap[ids[i]]) } : undefined, ids[i]); |
| 88 | if (f) add.push({ id: id(), face: f, caption: `${name(e.player)} 弃置`, from: e.player as number, cardId: ids[i], at: now }); |
| 89 | } |
| 90 | } else if (e.type === '判定') { |
| 91 | const f = face(card ?? undefined, e.cardId as string | undefined); |
| 92 | 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 }); |
| 93 | } else if (e.type === '指定目标' && typeof e.source === 'number' && typeof e.target === 'number') { |
| 94 | const cardName = (e.cardName as string) ?? full?.name; |
| 95 | if (e.source !== e.target) newLines.push({ id: id(), from: e.source, to: e.target, kind: cardName === '杀' ? 'sha' : 'trick', at: now }); |
| 96 | if (typeof e.cardId === 'string') { |
| 97 | const prev = captions.get(e.cardId); |
| 98 | captions.set(e.cardId, prev ? `${prev}、${name(e.target)}` : `${name(e.source)} → ${name(e.target)}`); |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | if (add.length || captions.size) |
| 103 | setCenter((prev) => { |
| 104 | let next = [...prev.filter((c) => now - c.at < KEEP_MS), ...add]; |
| 105 | if (captions.size) next = next.map((c) => (c.cardId && captions.has(c.cardId) ? { ...c, caption: captions.get(c.cardId)! } : c)); |
| 106 | return next.slice(-MAX_CENTER); |
| 107 | }); |
| 108 | if (newLines.length) setLines((prev) => [...prev.filter((l) => now - l.at < LINE_MS), ...newLines]); |
| 109 | if (newWords.length) setWords((prev) => [...prev.filter((w) => now - w.at < WORD_MS), ...newWords]); |
| 110 | }, [ingested]); |
| 111 | |
| 112 | |
| 113 | useEffect(() => { |
| 114 | if (!center.length && !lines.length && !words.length) return; |
| 115 | const t = setInterval(() => { |
| 116 | const now = Date.now(); |
| 117 | setCenter((p) => (p.some((c) => now - c.at >= KEEP_MS) ? p.filter((c) => now - c.at < KEEP_MS) : p)); |
| 118 | setLines((p) => (p.some((l) => now - l.at >= LINE_MS) ? p.filter((l) => now - l.at < LINE_MS) : p)); |
| 119 | setWords((p) => (p.some((w) => now - w.at >= WORD_MS) ? p.filter((w) => now - w.at < WORD_MS) : p)); |
| 120 | }, 300); |
| 121 | return () => clearInterval(t); |
| 122 | }, [center.length, lines.length, words.length]); |
| 123 | |
| 124 | return { center, lines, words }; |
| 125 | } |
| 126 | |
| 127 | const pick = (c?: { suit?: string; rank?: string }) => (c ? { suit: c.suit, rank: c.rank } : {}); |