| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from 'react'; |
| 8 | import type { GameView as EngineGameView, Card, Json, ViewEvent } from '../../../src/engine/types'; |
| 9 | import { getAtomDef } from '../../../src/engine/core/atom'; |
| 10 | import { viewSlashMax, viewSlashUsed } from '../../../src/engine/rules/action-active'; |
| 11 | import { viewEffectiveDistance } from '../../../src/engine/rules/viewDistance'; |
| 12 | import { useCountdownSeconds } from '../../../src/client/hooks/useCountdown'; |
| 13 | import { AwaitingPrompt } from '../../../src/client/components/AwaitingPrompt'; |
| 14 | import { HandArea } from '../../../src/client/components/HandArea'; |
| 15 | import { GameViewProvider, type GameViewCtxValue } from '../../../src/client/components/GameViewCtx'; |
| 16 | import { findUseActionForCard, hasUseEntry, isActiveAction, canShowCancelSelectionButton, canShowEndTurnButton } from '../../../src/client/utils/gameViewHelpers'; |
| 17 | import { displaySkillName } from '../../../src/client/utils/skillDisplay'; |
| 18 | import { useAnimationState } from '../../../src/client/hooks/useAnimationState'; |
| 19 | import { useCardMoveAnimation } from '../../../src/client/hooks/useCardMoveAnimation'; |
| 20 | import { useSkillActions } from '../../../src/client/hooks/useSkillActions'; |
| 21 | import { usePendingState } from '../../../src/client/hooks/usePendingState'; |
| 22 | import { useCharSelect } from '../../../src/client/hooks/useCharSelect'; |
| 23 | import { useSeatOrder } from '../../../src/client/hooks/useSeatOrder'; |
| 24 | import { useHandReorder } from '../../../src/client/hooks/useHandReorder'; |
| 25 | import { usePlayInteraction } from '../../../src/client/hooks/usePlayInteraction'; |
| 26 | import { useHotkeys } from '../../../src/client/hooks/useHotkeys'; |
| 27 | import { useProcessingPicks } from '../../../src/client/hooks/useProcessingPicks'; |
| 28 | import { useAutoSkipPrefs } from '../../../src/client/hooks/useAutoSkipPrefs'; |
| 29 | import { useAutoSkip } from '../../../src/client/hooks/useAutoSkip'; |
| 30 | import type { QueuedEvent } from '../../../src/client/hooks/useEventPlayback'; |
| 31 | import type { ActionMsg } from '../../../src/client/types'; |
| 32 | import { Seat, Timer } from './Seat'; |
| 33 | import { Me } from './Me'; |
| 34 | import { ActionBar } from './ActionBar'; |
| 35 | import { CharSelect } from './CharSelect'; |
| 36 | import { Center, Lines } from './Center'; |
| 37 | import { Log } from './Log'; |
| 38 | import { useFx } from './useFx'; |
| 39 | |
| 40 | |
| 41 | const DISCARD_KEEP_PRIORITY: Record<string, number> = { 桃: 0, 杀: 1, 闪: 2, 无懈可击: 3 }; |
| 42 | const PHASE: Record<string, string> = { 准备: '准备阶段', 判定: '判定阶段', 摸牌: '摸牌阶段', 出牌: '出牌阶段', 弃牌: '弃牌阶段', 回合结束: '结束阶段' }; |
| 43 | |
| 44 | |
| 45 | |
| 46 | export function ovalSpot(i: number, k: number): { x: number; y: number; scale: number } { |
| 47 | const start = k === 1 ? 90 : k === 2 ? 150 : k >= 6 ? 200 : k === 5 ? 195 : 180; |
| 48 | const a = k === 1 ? 90 : start - (i * (2 * start - 180)) / (k - 1); |
| 49 | const r = (a * Math.PI) / 180; |
| 50 | 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 }; |
| 51 | } |
| 52 | |
| 53 | interface Props { |
| 54 | view: EngineGameView; |
| 55 | onAction: (action: ActionMsg) => void; |
| 56 | readOnly?: boolean; |
| 57 | currentEvent?: QueuedEvent | null; |
| 58 | ingestedEvents?: QueuedEvent[]; |
| 59 | |
| 60 | top?: ReactNode; |
| 61 | side?: ReactNode; |
| 62 | |
| 63 | overlay?: ReactNode; |
| 64 | |
| 65 | labels?: string[]; |
| 66 | } |
| 67 | |
| 68 | export function Table({ view, onAction, readOnly = false, currentEvent, ingestedEvents, top, side, overlay, labels }: Props) { |
| 69 | const perspectiveIdx = view.viewer >= 0 && view.viewer < view.players.length ? view.viewer : 0; |
| 70 | const isSpectating = view.viewer < 0; |
| 71 | const viewRef = useRef(view); |
| 72 | viewRef.current = view; |
| 73 | |
| 74 | const { skillActions } = useSkillActions(view, perspectiveIdx); |
| 75 | const pendingState = usePendingState(view, perspectiveIdx, skillActions); |
| 76 | const { pending, pendingTargetIdx, isPerspectiveAwaiting, isDiscardPhase, discardMin, discardMax, skippedBroadcast, markBroadcastSkipped, deadline, deadlineTotalMs, pendingRespondInfo, broadcastKey } = pendingState; |
| 77 | const { prefs: autoSkipPrefs, toggleOptIn: toggleAutoSkip } = useAutoSkipPrefs(); |
| 78 | const { isCharSelectPending, charSelect } = useCharSelect(view, perspectiveIdx); |
| 79 | const orderedPlayers = useSeatOrder(view, perspectiveIdx); |
| 80 | const anim = useAnimationState(view, perspectiveIdx); |
| 81 | useCardMoveAnimation(ingestedEvents ?? [], view); |
| 82 | const fx = useFx(ingestedEvents, view); |
| 83 | |
| 84 | |
| 85 | const [revealEvent, setRevealEvent] = useState<ViewEvent | null>(null); |
| 86 | useEffect(() => { |
| 87 | const reveals = (ingestedEvents ?? []).filter((e) => e.event.type === '展示'); |
| 88 | if (reveals.length > 0) setRevealEvent(reveals[reveals.length - 1].event); |
| 89 | if ((ingestedEvents ?? []).some((e) => e.event.type === '展示结束')) setRevealEvent(null); |
| 90 | }, [ingestedEvents]); |
| 91 | |
| 92 | const handListRef = useRef<HTMLDivElement>(null); |
| 93 | const perspectivePlayer = view.players[perspectiveIdx]; |
| 94 | const perspectiveName = perspectivePlayer.name ?? `P${perspectiveIdx}`; |
| 95 | const isMyTurn = view.currentPlayerIndex === perspectiveIdx; |
| 96 | const currentPlayer = view.players[view.currentPlayerIndex]; |
| 97 | const currentPlayerName = currentPlayer.name; |
| 98 | const perspectiveHand: Card[] = perspectivePlayer.hand ?? []; |
| 99 | const { orderedHand, handleDragStart, handleDrop } = useHandReorder(perspectiveHand, undefined); |
| 100 | const canOperate = !readOnly; |
| 101 | |
| 102 | const send = useCallback( |
| 103 | (skillId: string, actionType: string, params: Record<string, Json>, preceding?: Array<{ skillId: string; actionType: string; params: Record<string, Json> }>) => { |
| 104 | setRevealEvent(null); |
| 105 | onAction({ skillId, actionType, ownerId: perspectiveIdx, params, preceding }); |
| 106 | }, |
| 107 | [onAction, perspectiveIdx], |
| 108 | ); |
| 109 | const processingPicks = useProcessingPicks(view); |
| 110 | useAutoSkip({ view, perspectiveIdx, skillActions, pendingRespondInfo, prefs: autoSkipPrefs, canOperate, isPerspectiveAwaiting, markBroadcastSkipped, broadcastKey, send }); |
| 111 | const play = usePlayInteraction(isMyTurn, canOperate, { |
| 112 | view, perspectiveIdx, perspectiveHand, skillActions, pending, isDiscardPhase, discardMin, discardMax, isPerspectiveAwaiting, pendingRespondInfo, broadcastKey, markBroadcastSkipped, pendingTargetIdx, send, handListRef, |
| 113 | }); |
| 114 | const { |
| 115 | selectedCardId, selectedCard, selectedTarget, selectedKillTarget, selectedMultiTargets, selectedForDiscard, transformMode, transformWrapperName, activeDistribute, isDistributeActive, |
| 116 | distTargetName, selectedRespondCardId, respondTargetName, respondNeedsTarget, respondTargetReady, playButtonState, playRules, handleCardClick, handlePlayCard, handleTargetClick, |
| 117 | handleSkillAction, isRespondTransformContext, handleRespond, handlePlayRespond, handleEndTurn, isTargetable, skillTargetMode, clearRespondSelection, handleCardDoubleClick, |
| 118 | handleTransformPlay, handleConfirmDiscard, handleDistSubmit, transformSubmit, distSubmit, cancelTransform, cancelSelection, distSelected, |
| 119 | } = play; |
| 120 | |
| 121 | const isMyAwaiting = isPerspectiveAwaiting && canOperate; |
| 122 | const broadcastSkipped = pendingTargetIdx < 0 && skippedBroadcast.has(broadcastKey); |
| 123 | const canDiscardClick = isDiscardPhase && isPerspectiveAwaiting && canOperate; |
| 124 | const canPlayHandCard = useCallback( |
| 125 | (card: Card) => { |
| 126 | const useAction = findUseActionForCard(skillActions, card); |
| 127 | return isMyTurn && canOperate && hasUseEntry(card) && (!useAction || isActiveAction(useAction, { view, perspectiveIdx })); |
| 128 | }, |
| 129 | [isMyTurn, canOperate, skillActions, view, perspectiveIdx], |
| 130 | ); |
| 131 | const isRespondableCard = useCallback((card: Card) => !isDistributeActive && isMyAwaiting && !!pendingRespondInfo?.cardFilter?.(card), [isDistributeActive, isMyAwaiting, pendingRespondInfo]); |
| 132 | const handDisabledReason = useCallback( |
| 133 | (card: Card): string | null => { |
| 134 | if (canPlayHandCard(card)) return null; |
| 135 | if (!isMyTurn || view.phase !== '出牌' || pending) return null; |
| 136 | if (!hasUseEntry(card)) return '仅用于响应,出牌阶段不能主动使用'; |
| 137 | const useAction = findUseActionForCard(skillActions, card); |
| 138 | if (useAction && isActiveAction(useAction, { view, perspectiveIdx })) return null; |
| 139 | if (card.name === '杀') { |
| 140 | const max = viewSlashMax(view, perspectiveIdx); |
| 141 | const used = viewSlashUsed(view, perspectiveIdx); |
| 142 | if (Number.isFinite(max) && used >= max) return `本回合杀次数已用完(${used}/${max})`; |
| 143 | return '本回合不能使用杀(次数已尽或被限制)'; |
| 144 | } |
| 145 | if (card.name === '桃' && perspectivePlayer.health >= perspectivePlayer.maxHealth) return '体力已满,不需要使用桃'; |
| 146 | if (card.name === '酒' && perspectivePlayer.turnUsage?.['酒/usedThisTurn']) return '本回合已使用过酒'; |
| 147 | return '当前不满足使用条件(阶段/次数/目标限制)'; |
| 148 | }, |
| 149 | [canPlayHandCard, isMyTurn, view, pending, skillActions, perspectiveIdx, perspectivePlayer], |
| 150 | ); |
| 151 | const countdownSec = useCountdownSeconds(deadline); |
| 152 | const discardFallbackIds = useMemo(() => { |
| 153 | if (!isDiscardPhase || !isPerspectiveAwaiting) return null; |
| 154 | if (countdownSec == null || countdownSec > 5) return null; |
| 155 | if (selectedForDiscard.length >= discardMin) return null; |
| 156 | const sorted = [...perspectiveHand].sort((a, b) => (DISCARD_KEEP_PRIORITY[b.name] ?? 9) - (DISCARD_KEEP_PRIORITY[a.name] ?? 9)); |
| 157 | return new Set(sorted.slice(0, discardMin).map((c) => c.id)); |
| 158 | }, [isDiscardPhase, isPerspectiveAwaiting, countdownSec, selectedForDiscard.length, discardMin, perspectiveHand]); |
| 159 | const seatTargetInfo = useCallback( |
| 160 | (idx: number): { distance: number | null; reason: string | null } => { |
| 161 | const empty = { distance: null, reason: null }; |
| 162 | const player = view.players[idx]; |
| 163 | if (!player) return empty; |
| 164 | const distance = viewEffectiveDistance(view.players, perspectiveIdx, idx); |
| 165 | if (!Number.isFinite(distance)) return empty; |
| 166 | if (!player.alive) return { distance, reason: '已阵亡' }; |
| 167 | if (isTargetable(idx)) return { distance, reason: null }; |
| 168 | if (idx === perspectiveIdx) return { distance, reason: '不能选择自己' }; |
| 169 | if (selectedCard?.name === '杀' || isRespondTransformContext) { |
| 170 | const range = view.players[perspectiveIdx]?.distanceVars?.attackRange ?? 1; |
| 171 | if (distance > range) return { distance, reason: `距离 ${distance},超出攻击范围 ${range}` }; |
| 172 | } |
| 173 | return { distance, reason: '不满足目标条件' }; |
| 174 | }, |
| 175 | [view, perspectiveIdx, isTargetable, selectedCard, isRespondTransformContext], |
| 176 | ); |
| 177 | const handleEquipCardClick = useCallback( |
| 178 | (cardId: string) => { |
| 179 | const card = viewRef.current.cardMap[cardId]; |
| 180 | if (card) handleCardClick(card); |
| 181 | }, |
| 182 | [handleCardClick], |
| 183 | ); |
| 184 | const isPlayingFlipAnim = |
| 185 | !!currentEvent && |
| 186 | (() => { |
| 187 | const t = (currentEvent.event as { atomType?: string; type?: string }).atomType ?? currentEvent.event.type; |
| 188 | try { |
| 189 | return getAtomDef(t)?.effect?.animation === 'flip'; |
| 190 | } catch { |
| 191 | return false; |
| 192 | } |
| 193 | })(); |
| 194 | const showEndTurn = canShowEndTurnButton({ canOperate, isMyTurn, phase: view.phase, pending }); |
| 195 | const showCancelSelection = canShowCancelSelectionButton({ selectedCardId, isMyTurn, phase: view.phase, pending }); |
| 196 | const isRespondPending = isMyAwaiting && !isDiscardPhase && !broadcastSkipped && (pending?.prompt?.type === 'useCard' || pending?.prompt?.type === 'useCardAndTarget'); |
| 197 | const respondCandidateCount = useMemo(() => { |
| 198 | if (!isRespondPending || !pendingRespondInfo?.cardFilter) return null; |
| 199 | return perspectiveHand.filter(pendingRespondInfo.cardFilter).length; |
| 200 | }, [isRespondPending, pendingRespondInfo, perspectiveHand]); |
| 201 | |
| 202 | useHotkeys( |
| 203 | readOnly |
| 204 | ? {} |
| 205 | : { |
| 206 | enter: () => { |
| 207 | if (transformMode) { |
| 208 | if (transformSubmit?.canSubmit) handleTransformPlay(transformSubmit.needsTarget ? selectedTarget! : ''); |
| 209 | return; |
| 210 | } |
| 211 | if (isRespondPending) { |
| 212 | if (selectedRespondCardId && respondTargetReady) handlePlayRespond(); |
| 213 | return; |
| 214 | } |
| 215 | if (isDiscardPhase && isPerspectiveAwaiting) { |
| 216 | if (selectedForDiscard.length >= discardMin && selectedForDiscard.length <= discardMax) handleConfirmDiscard(); |
| 217 | return; |
| 218 | } |
| 219 | if (isDistributeActive) { |
| 220 | if (distSubmit?.canSubmit) handleDistSubmit(); |
| 221 | return; |
| 222 | } |
| 223 | if (playButtonState?.canPlay) handlePlayCard(); |
| 224 | }, |
| 225 | escape: () => { |
| 226 | if (transformMode) cancelTransform(); |
| 227 | else if (selectedRespondCardId || respondTargetName) clearRespondSelection(); |
| 228 | else if (showCancelSelection || skillTargetMode) cancelSelection(); |
| 229 | }, |
| 230 | space: (e) => { |
| 231 | if (isRespondPending) { |
| 232 | e.preventDefault(); |
| 233 | handleRespond(); |
| 234 | } |
| 235 | }, |
| 236 | e: () => { |
| 237 | if (showEndTurn) handleEndTurn(); |
| 238 | }, |
| 239 | ...Object.fromEntries( |
| 240 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n): [string, () => void] => [ |
| 241 | String(n), |
| 242 | () => { |
| 243 | const card = perspectiveHand[n === 0 ? 9 : n - 1]; |
| 244 | if (!card) return; |
| 245 | const inFreePlay = isMyTurn && view.phase === '出牌' && !pending; |
| 246 | const transformActive = !!transformMode && (isMyTurn || isRespondTransformContext); |
| 247 | if ((inFreePlay && canPlayHandCard(card)) || isRespondableCard(card) || canDiscardClick || transformActive || isDistributeActive) handleCardClick(card); |
| 248 | }, |
| 249 | ]), |
| 250 | ), |
| 251 | }, |
| 252 | ); |
| 253 | |
| 254 | const selectedNeedsTarget = (!!playRules && playRules.needsTarget) || (isDistributeActive && !!activeDistribute?.externalTargetSelection) || respondNeedsTarget || skillTargetMode; |
| 255 | const selectedTargetNames = |
| 256 | isDistributeActive && activeDistribute?.externalTargetSelection |
| 257 | ? distTargetName |
| 258 | ? [distTargetName] |
| 259 | : [] |
| 260 | : playRules?.hasSlots |
| 261 | ? [selectedTarget, selectedKillTarget].filter((x): x is string => !!x) |
| 262 | : playRules?.multiTarget |
| 263 | ? selectedMultiTargets |
| 264 | : respondNeedsTarget |
| 265 | ? respondTargetName |
| 266 | ? [respondTargetName] |
| 267 | : [] |
| 268 | : selectedTarget |
| 269 | ? [selectedTarget] |
| 270 | : []; |
| 271 | const selfTargetable = canOperate && selectedNeedsTarget && isTargetable(perspectiveIdx); |
| 272 | |
| 273 | const ctxValue = useMemo<GameViewCtxValue>( |
| 274 | () => ({ view, perspectiveIdx, perspectiveName, isSpectating, canOperate, currentPlayerName, skillActions, send }), |
| 275 | [view, perspectiveIdx, perspectiveName, isSpectating, canOperate, currentPlayerName, skillActions, send], |
| 276 | ); |
| 277 | |
| 278 | |
| 279 | const seatDeadline = (idx: number): number | null => { |
| 280 | if (isPlayingFlipAnim || !view.pending) return null; |
| 281 | if (broadcastSkipped && idx === perspectiveIdx && view.pending.target < 0) return null; |
| 282 | if (view.pending.target < 0) return view.players[idx]?.alive ? (view.pending.deadline ?? null) : null; |
| 283 | return view.pending.target === idx ? (view.pending.deadline ?? null) : null; |
| 284 | }; |
| 285 | const others = orderedPlayers.slice(1); |
| 286 | const showPrompt = canOperate && isPerspectiveAwaiting && pending && !isDiscardPhase && !isPlayingFlipAnim && pending.atom?.type !== '选将询问' && pending.prompt.type !== 'distribute'; |
| 287 | const reveal = revealEvent?.card as { name: string; suit: string; rank: string } | undefined; |
| 288 | const deck = view.zones?.deckCount ?? 0; |
| 289 | |
| 290 | return ( |
| 291 | <GameViewProvider value={ctxValue}> |
| 292 | <Scaler> |
| 293 | <div className={`nm-arena${isSpectating ? ' spectating' : ''}${readOnly ? ' readonly' : ''}`}> |
| 294 | <div className="nm-topbar"> |
| 295 | <div className="nm-pile" data-zone-anchor="deck"> |
| 296 | <span>剩余</span> |
| 297 | <b>{deck}</b> |
| 298 | <span>张</span> |
| 299 | </div> |
| 300 | <div className="nm-round"> |
| 301 | <span className="nm-discards">弃牌<b>{view.zones?.discardPileCount ?? 0}</b></span>第<b>{view.turn?.round ?? 1}</b>轮 <span className="nm-turn-of">{currentPlayer.character || currentPlayerName}</span> |
| 302 | <span className="nm-phase">{PHASE[view.phase] ?? view.phase}</span> |
| 303 | </div> |
| 304 | <div className="nm-top-slot">{top}</div> |
| 305 | {side && <div className="nm-top-side">{side}</div>} |
| 306 | </div> |
| 307 | |
| 308 | {others.map((player, i) => { |
| 309 | const idx = view.players.findIndex((p) => p.name === player.name); |
| 310 | const { x, y, scale } = ovalSpot(i, others.length); |
| 311 | const info = selectedNeedsTarget ? seatTargetInfo(idx) : { distance: null, reason: null }; |
| 312 | return ( |
| 313 | <Seat |
| 314 | key={player.name} |
| 315 | player={player} |
| 316 | index={idx} |
| 317 | style={{ left: `${x}%`, top: `${y}%`, ['--seat-scale' as string]: scale }} |
| 318 | isCurrent={idx === view.currentPlayerIndex} |
| 319 | phase={view.phase} |
| 320 | needsTarget={selectedNeedsTarget} |
| 321 | targetable={isTargetable(idx)} |
| 322 | selected={selectedTargetNames.includes(player.name)} |
| 323 | onClick={() => handleTargetClick(player.name)} |
| 324 | damaged={anim.damageFlashIndices.get(idx)} |
| 325 | healed={anim.healFlashIndices.get(idx)} |
| 326 | hpChange={anim.hpChangeNumbers.get(idx)} |
| 327 | deadline={seatDeadline(idx)} |
| 328 | totalMs={view.pending?.totalMs} |
| 329 | distance={info.distance} |
| 330 | reason={info.reason} |
| 331 | label={labels?.[idx]} |
| 332 | /> |
| 333 | ); |
| 334 | })} |
| 335 | |
| 336 | <Center items={fx.center} reveal={reveal ? { face: reveal, caption: `${view.players[revealEvent!.player as number]?.character ?? ''} 展示` } : null} /> |
| 337 | <Lines lines={fx.lines} words={fx.words} /> |
| 338 | |
| 339 | <div className="nm-dock"> |
| 340 | {showPrompt && ( |
| 341 | <AwaitingPrompt |
| 342 | pending={pending} |
| 343 | pendingTargetIdx={pendingTargetIdx} |
| 344 | perspectiveHand={perspectiveHand} |
| 345 | pendingRespondInfo={pendingRespondInfo} |
| 346 | broadcastKey={broadcastKey} |
| 347 | skippedBroadcast={skippedBroadcast} |
| 348 | processingPicks={processingPicks} |
| 349 | autoSkipPrefs={autoSkipPrefs} |
| 350 | onToggleAutoSkip={toggleAutoSkip} |
| 351 | /> |
| 352 | )} |
| 353 | {canOperate && isDiscardPhase && isPerspectiveAwaiting && ( |
| 354 | <div className="nm-tip"> |
| 355 | 请弃置 <b>{discardMin === discardMax ? discardMin : `${discardMin}~${discardMax}`}</b> 张手牌(已选 {selectedForDiscard.length}) |
| 356 | </div> |
| 357 | )} |
| 358 | {canOperate && isMyTurn && view.phase === '出牌' && !pending && !selectedCardId && !transformMode && !skillTargetMode && <div className="nm-tip">出牌阶段,请选择要使用的牌</div>} |
| 359 | {canOperate && selectedCardId && selectedNeedsTarget && !selectedTargetNames.length && <div className="nm-tip">请选择目标</div>} |
| 360 | {transformMode && ( |
| 361 | <div className="nm-tip"> |
| 362 | {displaySkillName(transformMode.skillId)}:选{transformMode.minCards > 1 ? `${transformMode.minCards}张牌` : '一张牌'}当【{transformWrapperName ?? transformMode.wrapperName}】 |
| 363 | {transformMode.minCards > 1 ? `(${transformMode.selectedCardIds.length}/${transformMode.maxCards})` : ''} |
| 364 | </div> |
| 365 | )} |
| 366 | {canOperate && ( |
| 367 | <ActionBar |
| 368 | play={play} |
| 369 | pending={pending} |
| 370 | isRespondPending={isRespondPending} |
| 371 | respondCandidateCount={respondCandidateCount} |
| 372 | showCancelSelection={showCancelSelection} |
| 373 | showEndTurn={showEndTurn} |
| 374 | isDiscardPhase={isDiscardPhase} |
| 375 | isPerspectiveAwaiting={isPerspectiveAwaiting} |
| 376 | discardMin={discardMin} |
| 377 | discardMax={discardMax} |
| 378 | /> |
| 379 | )} |
| 380 | </div> |
| 381 | |
| 382 | <Me |
| 383 | player={perspectivePlayer} |
| 384 | index={perspectiveIdx} |
| 385 | isCurrent={isMyTurn} |
| 386 | phase={view.phase} |
| 387 | spectating={isSpectating} |
| 388 | onSkillAction={handleSkillAction} |
| 389 | onEquipClick={handleEquipCardClick} |
| 390 | distCandidates={activeDistribute ? new Set(activeDistribute.cardIds) : null} |
| 391 | distSelected={distSelected} |
| 392 | isDistributeActive={isDistributeActive} |
| 393 | targetable={selfTargetable} |
| 394 | selected={selectedTargetNames.includes(perspectiveName)} |
| 395 | onTarget={() => selfTargetable && handleTargetClick(perspectiveName)} |
| 396 | damaged={anim.damageFlashIndices.get(perspectiveIdx)} |
| 397 | healed={anim.healFlashIndices.get(perspectiveIdx)} |
| 398 | hpChange={anim.hpChangeNumbers.get(perspectiveIdx)} |
| 399 | timer={(isPerspectiveAwaiting || (isMyTurn && view.phase === '出牌')) && !broadcastSkipped && !readOnly && !isPlayingFlipAnim ? <Timer deadline={deadline} totalMs={deadlineTotalMs} /> : null} |
| 400 | hand={ |
| 401 | isSpectating ? ( |
| 402 | <div className="nm-backs"> |
| 403 | {Array.from({ length: Math.min(perspectivePlayer.handCount, 20) }, (_, i) => ( |
| 404 | <div key={i} className="nm-card back" /> |
| 405 | ))} |
| 406 | </div> |
| 407 | ) : ( |
| 408 | <HandArea |
| 409 | play={play} |
| 410 | phase={view.phase} |
| 411 | isSpectating={isSpectating} |
| 412 | perspectiveHand={perspectiveHand} |
| 413 | spectatorHandCount={perspectivePlayer.handCount} |
| 414 | handListRef={handListRef} |
| 415 | orderedHand={orderedHand} |
| 416 | handleDragStart={handleDragStart} |
| 417 | handleDrop={handleDrop} |
| 418 | canPlayHandCard={canPlayHandCard} |
| 419 | isRespondableCard={isRespondableCard} |
| 420 | canDiscardClick={canDiscardClick} |
| 421 | disabledReason={handDisabledReason} |
| 422 | onCardDoubleClick={handleCardDoubleClick} |
| 423 | timeoutFallbackIds={discardFallbackIds} |
| 424 | isMyAwaiting={isMyAwaiting} |
| 425 | isMyTurn={isMyTurn} |
| 426 | /> |
| 427 | ) |
| 428 | } |
| 429 | /> |
| 430 | |
| 431 | <Log view={view} /> |
| 432 | {isCharSelectPending && charSelect && charSelect.target >= 0 && !readOnly && ( |
| 433 | <CharSelect candidates={charSelect.candidates} target={charSelect.target} mine={charSelect.target === perspectiveIdx} deadline={charSelect.pending?.deadline ?? null} totalMs={charSelect.pending?.totalMs ?? 60000} onAction={onAction} /> |
| 434 | )} |
| 435 | {overlay} |
| 436 | </div> |
| 437 | </Scaler> |
| 438 | </GameViewProvider> |
| 439 | ); |
| 440 | } |
| 441 | |
| 442 | const DESIGN_H = 720; |
| 443 | const MIN_W = 1180; |
| 444 | |
| 445 | |
| 446 | function Scaler({ children }: { children: ReactNode }) { |
| 447 | const ref = useRef<HTMLDivElement>(null); |
| 448 | const [m, setM] = useState({ w: MIN_W, h: DESIGN_H, s: 1 }); |
| 449 | useEffect(() => { |
| 450 | const el = ref.current!; |
| 451 | const update = () => { |
| 452 | const W = el.clientWidth; |
| 453 | const H = el.clientHeight; |
| 454 | if (!W || !H) return; |
| 455 | const s = Math.min(H / DESIGN_H, W / MIN_W); |
| 456 | setM({ w: W / s, h: H / s, s }); |
| 457 | document.documentElement.style.setProperty('--nm-scale', String(s)); |
| 458 | }; |
| 459 | update(); |
| 460 | const ro = new ResizeObserver(update); |
| 461 | ro.observe(el); |
| 462 | return () => ro.disconnect(); |
| 463 | }, []); |
| 464 | return ( |
| 465 | <div className="nm-scaler" ref={ref}> |
| 466 | <div className="nm-canvas" style={{ width: m.w, height: m.h, transform: `scale(${m.s})`, ['--gv-scale' as string]: String(m.s) }}> |
| 467 | {children} |
| 468 | </div> |
| 469 | </div> |
| 470 | ); |
| 471 | } |