Play liveChoose your opponent
Join
471 lines · 24.7 KB Raw Permalink History
1// The table, drawn as 无名杀 draws an identity game (its mobile layout and woodden theme): the other seats around an
2// oval, each a portrait card; your seat along the bottom with your hand, equipment, skills and the 确定/取消/结束回合
3// buttons; the cards just played in the middle; the log on the right. It renders one seat's view (view.viewer, or the
4// spectator's), and everything it does goes through the upstream's own hooks and action building, as the upstream's
5// GameView does (src/client/components/GameView.tsx, whose state and handlers this follows line for line): the same
6// usePlayInteraction, the same prompts and the same messages, so a move made here is the move the upstream table makes.
7import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from 'react';
8import type { GameView as EngineGameView, Card, Json, ViewEvent } from '../../../src/engine/types';
9import { getAtomDef } from '../../../src/engine/core/atom';
10import { viewSlashMax, viewSlashUsed } from '../../../src/engine/rules/action-active';
11import { viewEffectiveDistance } from '../../../src/engine/rules/viewDistance';
12import { useCountdownSeconds } from '../../../src/client/hooks/useCountdown';
13import { AwaitingPrompt } from '../../../src/client/components/AwaitingPrompt';
14import { HandArea } from '../../../src/client/components/HandArea';
15import { GameViewProvider, type GameViewCtxValue } from '../../../src/client/components/GameViewCtx';
16import { findUseActionForCard, hasUseEntry, isActiveAction, canShowCancelSelectionButton, canShowEndTurnButton } from '../../../src/client/utils/gameViewHelpers';
17import { displaySkillName } from '../../../src/client/utils/skillDisplay';
18import { useAnimationState } from '../../../src/client/hooks/useAnimationState';
19import { useCardMoveAnimation } from '../../../src/client/hooks/useCardMoveAnimation';
20import { useSkillActions } from '../../../src/client/hooks/useSkillActions';
21import { usePendingState } from '../../../src/client/hooks/usePendingState';
22import { useCharSelect } from '../../../src/client/hooks/useCharSelect';
23import { useSeatOrder } from '../../../src/client/hooks/useSeatOrder';
24import { useHandReorder } from '../../../src/client/hooks/useHandReorder';
25import { usePlayInteraction } from '../../../src/client/hooks/usePlayInteraction';
26import { useHotkeys } from '../../../src/client/hooks/useHotkeys';
27import { useProcessingPicks } from '../../../src/client/hooks/useProcessingPicks';
28import { useAutoSkipPrefs } from '../../../src/client/hooks/useAutoSkipPrefs';
29import { useAutoSkip } from '../../../src/client/hooks/useAutoSkip';
30import type { QueuedEvent } from '../../../src/client/hooks/useEventPlayback';
31import type { ActionMsg } from '../../../src/client/types';
32import { Seat, Timer } from './Seat';
33import { Me } from './Me';
34import { ActionBar } from './ActionBar';
35import { CharSelect } from './CharSelect';
36import { Center, Lines } from './Center';
37import { Log } from './Log';
38import { useFx } from './useFx';
39
40/** 弃牌超时兜底的保留优先级(as GameView: the engine's own 请求回应 onTimeout order). */
41const DISCARD_KEEP_PRIORITY: Record<string, number> = { 桃: 0, 杀: 1, 闪: 2, 无懈可击: 3 };
42const PHASE: Record<string, string> = { 准备: '准备阶段', 判定: '判定阶段', 摸牌: '摸牌阶段', 出牌: '出牌阶段', 弃牌: '弃牌阶段', 回合结束: '结束阶段' };
43
44/** Where the others sit, in % of the canvas: on an oval from your left (上家) over the top to your right (下家), as
45 * 无名杀 seats 2 to 8 players; past five others the oval runs a little below the sides and the cards get smaller. */
46export 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
53interface Props {
54 view: EngineGameView;
55 onAction: (action: ActionMsg) => void;
56 readOnly?: boolean;
57 currentEvent?: QueuedEvent | null;
58 ingestedEvents?: QueuedEvent[];
59 /** The top bar's own controls: at the left (the replay's steps, the live seat switch) and at the right. */
60 top?: ReactNode;
61 side?: ReactNode;
62 /** Shown over the table (the end of a game). */
63 overlay?: ReactNode;
64 /** Who sits in each seat (the replay's route and bot names), shown under the seats. */
65 labels?: string[];
66}
67
68export 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 // 展示 (火攻 and the like): the latest shown card stays until a move, a new show or the engine's 展示结束.
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 /** A seat's countdown: the pending ask is to it, or to everyone alive (无懈可击). */
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
442const DESIGN_H = 720;
443const MIN_W = 1180;
444
445/** The canvas is drawn for a 720 px high frame at least 1180 wide and scaled to fit the frame, filling it. */
446function 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}