// The table, drawn as 无名杀 draws an identity game (its mobile layout and woodden theme): the other seats around an // oval, each a portrait card; your seat along the bottom with your hand, equipment, skills and the 确定/取消/结束回合 // buttons; the cards just played in the middle; the log on the right. It renders one seat's view (view.viewer, or the // spectator's), and everything it does goes through the upstream's own hooks and action building, as the upstream's // GameView does (src/client/components/GameView.tsx, whose state and handlers this follows line for line): the same // usePlayInteraction, the same prompts and the same messages, so a move made here is the move the upstream table makes. import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from 'react'; import type { GameView as EngineGameView, Card, Json, ViewEvent } from '../../../src/engine/types'; import { getAtomDef } from '../../../src/engine/core/atom'; import { viewSlashMax, viewSlashUsed } from '../../../src/engine/rules/action-active'; import { viewEffectiveDistance } from '../../../src/engine/rules/viewDistance'; import { useCountdownSeconds } from '../../../src/client/hooks/useCountdown'; import { AwaitingPrompt } from '../../../src/client/components/AwaitingPrompt'; import { HandArea } from '../../../src/client/components/HandArea'; import { GameViewProvider, type GameViewCtxValue } from '../../../src/client/components/GameViewCtx'; import { findUseActionForCard, hasUseEntry, isActiveAction, canShowCancelSelectionButton, canShowEndTurnButton } from '../../../src/client/utils/gameViewHelpers'; import { displaySkillName } from '../../../src/client/utils/skillDisplay'; import { useAnimationState } from '../../../src/client/hooks/useAnimationState'; import { useCardMoveAnimation } from '../../../src/client/hooks/useCardMoveAnimation'; import { useSkillActions } from '../../../src/client/hooks/useSkillActions'; import { usePendingState } from '../../../src/client/hooks/usePendingState'; import { useCharSelect } from '../../../src/client/hooks/useCharSelect'; import { useSeatOrder } from '../../../src/client/hooks/useSeatOrder'; import { useHandReorder } from '../../../src/client/hooks/useHandReorder'; import { usePlayInteraction } from '../../../src/client/hooks/usePlayInteraction'; import { useHotkeys } from '../../../src/client/hooks/useHotkeys'; import { useProcessingPicks } from '../../../src/client/hooks/useProcessingPicks'; import { useAutoSkipPrefs } from '../../../src/client/hooks/useAutoSkipPrefs'; import { useAutoSkip } from '../../../src/client/hooks/useAutoSkip'; import type { QueuedEvent } from '../../../src/client/hooks/useEventPlayback'; import type { ActionMsg } from '../../../src/client/types'; import { Seat, Timer } from './Seat'; import { Me } from './Me'; import { ActionBar } from './ActionBar'; import { CharSelect } from './CharSelect'; import { Center, Lines } from './Center'; import { Log } from './Log'; import { useFx } from './useFx'; /** 弃牌超时兜底的保留优先级(as GameView: the engine's own 请求回应 onTimeout order). */ const DISCARD_KEEP_PRIORITY: Record = { 桃: 0, 杀: 1, 闪: 2, 无懈可击: 3 }; const PHASE: Record = { 准备: '准备阶段', 判定: '判定阶段', 摸牌: '摸牌阶段', 出牌: '出牌阶段', 弃牌: '弃牌阶段', 回合结束: '结束阶段' }; /** Where the others sit, in % of the canvas: on an oval from your left (上家) over the top to your right (下家), as * 无名杀 seats 2 to 8 players; past five others the oval runs a little below the sides and the cards get smaller. */ export function ovalSpot(i: number, k: number): { x: number; y: number; scale: number } { const start = k === 1 ? 90 : k === 2 ? 150 : k >= 6 ? 200 : k === 5 ? 195 : 180; const a = k === 1 ? 90 : start - (i * (2 * start - 180)) / (k - 1); const r = (a * Math.PI) / 180; return { x: 50 + 41 * Math.cos(r), y: 52 - 33 * Math.sin(r), scale: k <= 4 ? 1 : k === 5 ? 0.9 : k === 6 ? 0.82 : 0.75 }; } interface Props { view: EngineGameView; onAction: (action: ActionMsg) => void; readOnly?: boolean; currentEvent?: QueuedEvent | null; ingestedEvents?: QueuedEvent[]; /** The top bar's own controls: at the left (the replay's steps, the live seat switch) and at the right. */ top?: ReactNode; side?: ReactNode; /** Shown over the table (the end of a game). */ overlay?: ReactNode; /** Who sits in each seat (the replay's route and bot names), shown under the seats. */ labels?: string[]; } export function Table({ view, onAction, readOnly = false, currentEvent, ingestedEvents, top, side, overlay, labels }: Props) { const perspectiveIdx = view.viewer >= 0 && view.viewer < view.players.length ? view.viewer : 0; const isSpectating = view.viewer < 0; const viewRef = useRef(view); viewRef.current = view; const { skillActions } = useSkillActions(view, perspectiveIdx); const pendingState = usePendingState(view, perspectiveIdx, skillActions); const { pending, pendingTargetIdx, isPerspectiveAwaiting, isDiscardPhase, discardMin, discardMax, skippedBroadcast, markBroadcastSkipped, deadline, deadlineTotalMs, pendingRespondInfo, broadcastKey } = pendingState; const { prefs: autoSkipPrefs, toggleOptIn: toggleAutoSkip } = useAutoSkipPrefs(); const { isCharSelectPending, charSelect } = useCharSelect(view, perspectiveIdx); const orderedPlayers = useSeatOrder(view, perspectiveIdx); const anim = useAnimationState(view, perspectiveIdx); useCardMoveAnimation(ingestedEvents ?? [], view); const fx = useFx(ingestedEvents, view); // 展示 (火攻 and the like): the latest shown card stays until a move, a new show or the engine's 展示结束. const [revealEvent, setRevealEvent] = useState(null); useEffect(() => { const reveals = (ingestedEvents ?? []).filter((e) => e.event.type === '展示'); if (reveals.length > 0) setRevealEvent(reveals[reveals.length - 1].event); if ((ingestedEvents ?? []).some((e) => e.event.type === '展示结束')) setRevealEvent(null); }, [ingestedEvents]); const handListRef = useRef(null); const perspectivePlayer = view.players[perspectiveIdx]; const perspectiveName = perspectivePlayer.name ?? `P${perspectiveIdx}`; const isMyTurn = view.currentPlayerIndex === perspectiveIdx; const currentPlayer = view.players[view.currentPlayerIndex]; const currentPlayerName = currentPlayer.name; const perspectiveHand: Card[] = perspectivePlayer.hand ?? []; const { orderedHand, handleDragStart, handleDrop } = useHandReorder(perspectiveHand, undefined); const canOperate = !readOnly; const send = useCallback( (skillId: string, actionType: string, params: Record, preceding?: Array<{ skillId: string; actionType: string; params: Record }>) => { setRevealEvent(null); onAction({ skillId, actionType, ownerId: perspectiveIdx, params, preceding }); }, [onAction, perspectiveIdx], ); const processingPicks = useProcessingPicks(view); useAutoSkip({ view, perspectiveIdx, skillActions, pendingRespondInfo, prefs: autoSkipPrefs, canOperate, isPerspectiveAwaiting, markBroadcastSkipped, broadcastKey, send }); const play = usePlayInteraction(isMyTurn, canOperate, { view, perspectiveIdx, perspectiveHand, skillActions, pending, isDiscardPhase, discardMin, discardMax, isPerspectiveAwaiting, pendingRespondInfo, broadcastKey, markBroadcastSkipped, pendingTargetIdx, send, handListRef, }); const { selectedCardId, selectedCard, selectedTarget, selectedKillTarget, selectedMultiTargets, selectedForDiscard, transformMode, transformWrapperName, activeDistribute, isDistributeActive, distTargetName, selectedRespondCardId, respondTargetName, respondNeedsTarget, respondTargetReady, playButtonState, playRules, handleCardClick, handlePlayCard, handleTargetClick, handleSkillAction, isRespondTransformContext, handleRespond, handlePlayRespond, handleEndTurn, isTargetable, skillTargetMode, clearRespondSelection, handleCardDoubleClick, handleTransformPlay, handleConfirmDiscard, handleDistSubmit, transformSubmit, distSubmit, cancelTransform, cancelSelection, distSelected, } = play; const isMyAwaiting = isPerspectiveAwaiting && canOperate; const broadcastSkipped = pendingTargetIdx < 0 && skippedBroadcast.has(broadcastKey); const canDiscardClick = isDiscardPhase && isPerspectiveAwaiting && canOperate; const canPlayHandCard = useCallback( (card: Card) => { const useAction = findUseActionForCard(skillActions, card); return isMyTurn && canOperate && hasUseEntry(card) && (!useAction || isActiveAction(useAction, { view, perspectiveIdx })); }, [isMyTurn, canOperate, skillActions, view, perspectiveIdx], ); const isRespondableCard = useCallback((card: Card) => !isDistributeActive && isMyAwaiting && !!pendingRespondInfo?.cardFilter?.(card), [isDistributeActive, isMyAwaiting, pendingRespondInfo]); const handDisabledReason = useCallback( (card: Card): string | null => { if (canPlayHandCard(card)) return null; if (!isMyTurn || view.phase !== '出牌' || pending) return null; if (!hasUseEntry(card)) return '仅用于响应,出牌阶段不能主动使用'; const useAction = findUseActionForCard(skillActions, card); if (useAction && isActiveAction(useAction, { view, perspectiveIdx })) return null; if (card.name === '杀') { const max = viewSlashMax(view, perspectiveIdx); const used = viewSlashUsed(view, perspectiveIdx); if (Number.isFinite(max) && used >= max) return `本回合杀次数已用完(${used}/${max})`; return '本回合不能使用杀(次数已尽或被限制)'; } if (card.name === '桃' && perspectivePlayer.health >= perspectivePlayer.maxHealth) return '体力已满,不需要使用桃'; if (card.name === '酒' && perspectivePlayer.turnUsage?.['酒/usedThisTurn']) return '本回合已使用过酒'; return '当前不满足使用条件(阶段/次数/目标限制)'; }, [canPlayHandCard, isMyTurn, view, pending, skillActions, perspectiveIdx, perspectivePlayer], ); const countdownSec = useCountdownSeconds(deadline); const discardFallbackIds = useMemo(() => { if (!isDiscardPhase || !isPerspectiveAwaiting) return null; if (countdownSec == null || countdownSec > 5) return null; if (selectedForDiscard.length >= discardMin) return null; const sorted = [...perspectiveHand].sort((a, b) => (DISCARD_KEEP_PRIORITY[b.name] ?? 9) - (DISCARD_KEEP_PRIORITY[a.name] ?? 9)); return new Set(sorted.slice(0, discardMin).map((c) => c.id)); }, [isDiscardPhase, isPerspectiveAwaiting, countdownSec, selectedForDiscard.length, discardMin, perspectiveHand]); const seatTargetInfo = useCallback( (idx: number): { distance: number | null; reason: string | null } => { const empty = { distance: null, reason: null }; const player = view.players[idx]; if (!player) return empty; const distance = viewEffectiveDistance(view.players, perspectiveIdx, idx); if (!Number.isFinite(distance)) return empty; if (!player.alive) return { distance, reason: '已阵亡' }; if (isTargetable(idx)) return { distance, reason: null }; if (idx === perspectiveIdx) return { distance, reason: '不能选择自己' }; if (selectedCard?.name === '杀' || isRespondTransformContext) { const range = view.players[perspectiveIdx]?.distanceVars?.attackRange ?? 1; if (distance > range) return { distance, reason: `距离 ${distance},超出攻击范围 ${range}` }; } return { distance, reason: '不满足目标条件' }; }, [view, perspectiveIdx, isTargetable, selectedCard, isRespondTransformContext], ); const handleEquipCardClick = useCallback( (cardId: string) => { const card = viewRef.current.cardMap[cardId]; if (card) handleCardClick(card); }, [handleCardClick], ); const isPlayingFlipAnim = !!currentEvent && (() => { const t = (currentEvent.event as { atomType?: string; type?: string }).atomType ?? currentEvent.event.type; try { return getAtomDef(t)?.effect?.animation === 'flip'; } catch { return false; } })(); const showEndTurn = canShowEndTurnButton({ canOperate, isMyTurn, phase: view.phase, pending }); const showCancelSelection = canShowCancelSelectionButton({ selectedCardId, isMyTurn, phase: view.phase, pending }); const isRespondPending = isMyAwaiting && !isDiscardPhase && !broadcastSkipped && (pending?.prompt?.type === 'useCard' || pending?.prompt?.type === 'useCardAndTarget'); const respondCandidateCount = useMemo(() => { if (!isRespondPending || !pendingRespondInfo?.cardFilter) return null; return perspectiveHand.filter(pendingRespondInfo.cardFilter).length; }, [isRespondPending, pendingRespondInfo, perspectiveHand]); useHotkeys( readOnly ? {} : { enter: () => { if (transformMode) { if (transformSubmit?.canSubmit) handleTransformPlay(transformSubmit.needsTarget ? selectedTarget! : ''); return; } if (isRespondPending) { if (selectedRespondCardId && respondTargetReady) handlePlayRespond(); return; } if (isDiscardPhase && isPerspectiveAwaiting) { if (selectedForDiscard.length >= discardMin && selectedForDiscard.length <= discardMax) handleConfirmDiscard(); return; } if (isDistributeActive) { if (distSubmit?.canSubmit) handleDistSubmit(); return; } if (playButtonState?.canPlay) handlePlayCard(); }, escape: () => { if (transformMode) cancelTransform(); else if (selectedRespondCardId || respondTargetName) clearRespondSelection(); else if (showCancelSelection || skillTargetMode) cancelSelection(); }, space: (e) => { if (isRespondPending) { e.preventDefault(); handleRespond(); } }, e: () => { if (showEndTurn) handleEndTurn(); }, ...Object.fromEntries( [1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n): [string, () => void] => [ String(n), () => { const card = perspectiveHand[n === 0 ? 9 : n - 1]; if (!card) return; const inFreePlay = isMyTurn && view.phase === '出牌' && !pending; const transformActive = !!transformMode && (isMyTurn || isRespondTransformContext); if ((inFreePlay && canPlayHandCard(card)) || isRespondableCard(card) || canDiscardClick || transformActive || isDistributeActive) handleCardClick(card); }, ]), ), }, ); const selectedNeedsTarget = (!!playRules && playRules.needsTarget) || (isDistributeActive && !!activeDistribute?.externalTargetSelection) || respondNeedsTarget || skillTargetMode; const selectedTargetNames = isDistributeActive && activeDistribute?.externalTargetSelection ? distTargetName ? [distTargetName] : [] : playRules?.hasSlots ? [selectedTarget, selectedKillTarget].filter((x): x is string => !!x) : playRules?.multiTarget ? selectedMultiTargets : respondNeedsTarget ? respondTargetName ? [respondTargetName] : [] : selectedTarget ? [selectedTarget] : []; const selfTargetable = canOperate && selectedNeedsTarget && isTargetable(perspectiveIdx); const ctxValue = useMemo( () => ({ view, perspectiveIdx, perspectiveName, isSpectating, canOperate, currentPlayerName, skillActions, send }), [view, perspectiveIdx, perspectiveName, isSpectating, canOperate, currentPlayerName, skillActions, send], ); /** A seat's countdown: the pending ask is to it, or to everyone alive (无懈可击). */ const seatDeadline = (idx: number): number | null => { if (isPlayingFlipAnim || !view.pending) return null; if (broadcastSkipped && idx === perspectiveIdx && view.pending.target < 0) return null; if (view.pending.target < 0) return view.players[idx]?.alive ? (view.pending.deadline ?? null) : null; return view.pending.target === idx ? (view.pending.deadline ?? null) : null; }; const others = orderedPlayers.slice(1); const showPrompt = canOperate && isPerspectiveAwaiting && pending && !isDiscardPhase && !isPlayingFlipAnim && pending.atom?.type !== '选将询问' && pending.prompt.type !== 'distribute'; const reveal = revealEvent?.card as { name: string; suit: string; rank: string } | undefined; const deck = view.zones?.deckCount ?? 0; return (
剩余 {deck} 张
弃牌{view.zones?.discardPileCount ?? 0}第{view.turn?.round ?? 1}轮 {currentPlayer.character || currentPlayerName} {PHASE[view.phase] ?? view.phase}
{top}
{side &&
{side}
}
{others.map((player, i) => { const idx = view.players.findIndex((p) => p.name === player.name); const { x, y, scale } = ovalSpot(i, others.length); const info = selectedNeedsTarget ? seatTargetInfo(idx) : { distance: null, reason: null }; return ( handleTargetClick(player.name)} damaged={anim.damageFlashIndices.get(idx)} healed={anim.healFlashIndices.get(idx)} hpChange={anim.hpChangeNumbers.get(idx)} deadline={seatDeadline(idx)} totalMs={view.pending?.totalMs} distance={info.distance} reason={info.reason} label={labels?.[idx]} /> ); })}
{showPrompt && ( )} {canOperate && isDiscardPhase && isPerspectiveAwaiting && (
请弃置 {discardMin === discardMax ? discardMin : `${discardMin}~${discardMax}`} 张手牌(已选 {selectedForDiscard.length})
)} {canOperate && isMyTurn && view.phase === '出牌' && !pending && !selectedCardId && !transformMode && !skillTargetMode &&
出牌阶段,请选择要使用的牌
} {canOperate && selectedCardId && selectedNeedsTarget && !selectedTargetNames.length &&
请选择目标
} {transformMode && (
{displaySkillName(transformMode.skillId)}:选{transformMode.minCards > 1 ? `${transformMode.minCards}张牌` : '一张牌'}当【{transformWrapperName ?? transformMode.wrapperName}】 {transformMode.minCards > 1 ? `(${transformMode.selectedCardIds.length}/${transformMode.maxCards})` : ''}
)} {canOperate && ( )}
selfTargetable && handleTargetClick(perspectiveName)} damaged={anim.damageFlashIndices.get(perspectiveIdx)} healed={anim.healFlashIndices.get(perspectiveIdx)} hpChange={anim.hpChangeNumbers.get(perspectiveIdx)} timer={(isPerspectiveAwaiting || (isMyTurn && view.phase === '出牌')) && !broadcastSkipped && !readOnly && !isPlayingFlipAnim ? : null} hand={ isSpectating ? (
{Array.from({ length: Math.min(perspectivePlayer.handCount, 20) }, (_, i) => (
))}
) : ( ) } /> {isCharSelectPending && charSelect && charSelect.target >= 0 && !readOnly && ( )} {overlay}
); } const DESIGN_H = 720; const MIN_W = 1180; /** The canvas is drawn for a 720 px high frame at least 1180 wide and scaled to fit the frame, filling it. */ function Scaler({ children }: { children: ReactNode }) { const ref = useRef(null); const [m, setM] = useState({ w: MIN_W, h: DESIGN_H, s: 1 }); useEffect(() => { const el = ref.current!; const update = () => { const W = el.clientWidth; const H = el.clientHeight; if (!W || !H) return; const s = Math.min(H / DESIGN_H, W / MIN_W); setM({ w: W / s, h: H / s, s }); document.documentElement.style.setProperty('--nm-scale', String(s)); }; update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); return (
{children}
); }