// Game screen — every in-match screen, dispatched by m.public.phase (DESIGN §10):
// INFO_CHECK → Briefing (screens/briefing.js), BAND_DRAFT → Band draft (screens/bandDraft.js),
// RESULT / m.result → Result (screens/result.js), everything else → MatchScreen below.
//
// MatchScreen = full-screen field view (render engine per DESIGN §9, DOM fallback when it can't load)
// with the DOM HUD layered on top: top bar, bond strip + popup, team panel (click = g.watch), effects,
// shop bar (prep), merge-reward and 机变 overlays, detail panel, enemy / match-info drawer, combat HUD
// (DP, "作战结束,等待队友完成作战" + teammates' progress, observing pill, 联防 / 最终攻势 ‹ › camera halves),
// phase banners, the round's result box at SETTLE (官方「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 and the BATTLEOVER sound;
// GitHub #235, PR #112), ticker, emote wheel + bubbles,
// settings, exit flow. Drag & drop from the view: pieceDrop on a board tile (also the piece's own tile) opens the
// direction wheel (ui/facingWheel.js, research 09 §1.2) and its release sends g.move {uid, to, dir} (g.art {…, dir}
// for 画卷); pieceDrop on a bench slot → g.move; items → g.equip (confirm when replacing) / g.art. Equipment
// released over an operator's sprite equips that operator even when the tile under the pointer is the one behind
// it; a refused drop toasts the reason (gameLogic dropFailureReason / canPlace). Tapping an own piece selects it:
// its underframe (ui/underframe.js: 撤退 / 出售 +N / 销毁), its rotated range tiles and its detail card; right-click /
// long-press opens the detail card only. There is no drag-to-sell. The own board is drawn and checked with the
// player's stage overrides (terrain 机变 cards: gameLogic stageOverrides / effectiveStage); in a boss round's prep the
// legality reads the player's half of the boss field (gameLogic deployFieldOf, user playtest #5 item 7).
// Shortcuts: R refresh, F freeze, D level-up, Space ready, Esc closes the topmost popup.
// The UI never mutates match state locally; it re-renders from m.public / m.private / m.field pushes.
// Client-side combat (DESIGN §14, m.public.combatMode 'client'): battles are simulated in this browser by
// battle/runner.js, which publishes the battle's field meta into store.match.field and feeds the view the same b.snap /
// b.ev frames the server used to stream. Observing follows research 09 §3.1 (battle/observe.js): no looking elsewhere
// while the own normal battle runs; afterwards a teammate row → 前往查看 → a local replica, 返回战场 goes back; in
// 联防 / 最终攻势 the ‹ › pill switches the camera between the field's halves and 全景.
// Enemy preview pen (research 09 §2 / §6.2 item 3): in 休整期 the right HUD 🔍▶▶ pans the camera to the pen
// (view.setCamera('pen')); the left button turns into 🔍◀◀ and returns to the camera in use before (the grey right
// button, Esc or any camera change of the game flow return too). The shop bar folds away while the pen is shown and
// comes back as it was. Tapping an enemy in the pen (view pieceClick with `enemyKey` / a `preview` enemy unit) opens its
// detail card; the old enemy list lives on as the 敌方情报 tab of the 本局信息 dialog (left 🔍).
// Every camera request of this screen goes through setCam (it remembers the camera the pen returns to);
// `.gm[data-camera]` mirrors the current camera kind (E2E / styling). Folding the shop bar (收起) on the own prep board
// flies the camera to the official shop-collapsed framing and unfolding back to the shop camera (public issue #5:
// gameLogic prepCameraFor / foldCamera — never over the pen, a teammate's board or a battle; deferred while a piece is
// dragged or its direction is chosen).
// Equipment dropped on an operator whose two slots are used opens the equip-replace dialog (ui/equipReplace.js): the
// player picks the equipped item to destroy → g.equip {itemUid, targetUid, replaceUid}; 取消 sends nothing. 销毁 is only
// offered for loose items (equipped ones are locked, ui/facing.js itemDestroyable).
// Solo battles (ui/matchStatus.js pauseAvailable): the top bar's pause button / Space send g.pause {on}; m.public.paused
// shows the paused overlay (继续作战 / 放弃模拟) and freezes the HUD clocks. Boss rounds: the top bar shows the level's
// 120 s countdown and the red DOT overtime warning (m.public.overtimeAt).
// User playtest #3: the own LP drops live while the own battle's enemies enter the blue gate (item 2: the runner's
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp; in 联防 a
// leaker's enemies still standing — runner state().uniteLeft / m.public players[].uniteLeft, the ×N tag — user
// playtest #6 item 7); while the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board
// (ui/underframe.js TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
// User playtest #4: the detail card shows live stats (item 7) — in battle the local sim's unit (battle/runner.js
// unitStats), in prep an own board unit's start-of-battle stats (g.unitStats → m.unitStats); 机变 cards take two taps
// (item 2, ui/choiceOverlay.js).
// Merges (user playtest #6 follow-up, PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): a shop /
// reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the
// deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand);
// the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote).
// The bond strip, its popup and a unit card's bond chips follow the player on screen (DESIGN §20.15, ui/watchBonds.js):
// a scouted / watched / auto-observed teammate's field → that teammate's bonds (m.public players[].bonds, tagged
// "👁 name"); 联防 / 最终攻势 → the player on the ‹ › half (全景: yours when you fight there, else the teammate picked
// with 前往查看 — `watchWho` — or the field's first player, never the viewer's own); in battle with the live layers of
// the battle on screen (battle/runner.js state().bondLayers). Picking a teammate on such a field (a 联防 leaker, an
// eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the
// strip changes hands.
// Spectator seats (community report #26, a remake feature): a spectator has no m.private and plays like an ELIMINATED
// player — no shop, no ready, no emotes (▸ [ASSUMED] off), the server auto-observes the first field of every battle, a
// team row → 前往查看 any player; in 休整期 / 机变 / round start it is shown the first player's board by itself (once per
// phase). Its pill reads 观战中, never "你已被淘汰"; its exit only leaves the seat (ui/matchChrome.js ExitModal).
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js';
import { html, Spinner, PhaseBanner, ResultDialog, Icon, Button, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js';
import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js';
import { BondStrip, BondPopup } from '../ui/bondStrip.js';
import { TeamPanel } from '../ui/teamPanel.js';
import { ShopBar } from '../ui/shopBar.js';
import { DetailPanel, resolveDetail } from '../ui/detailPanel.js';
import { RewardOverlay } from '../ui/rewardOverlay.js';
import { ChoiceOverlay } from '../ui/choiceOverlay.js';
import { EnemyDrawer } from '../ui/enemyDrawer.js';
import { Ticker } from '../ui/ticker.js';
import { EmoteWheel } from '../ui/emotes.js';
import { EffectsList } from '../ui/effectsList.js';
import { CombatHud } from '../ui/combatHud.js';
import { SettingsModal, settingsStore } from '../ui/settings.js';
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
import { openGuide } from '../ui/guide.js';
import { actions, onRefreshBusy } from '../ui/gameActions.js';
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
import { StandInTags } from './game/standInTags.js';
import { needsFacing, facingIntent, previewGrid, pieceDir, underframeActions, retreatSlot, itemDestroyable } from '../ui/facing.js';
import { EquipReplaceDialog, replaceRequest, replaceIntent } from '../ui/equipReplace.js';
import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
import { BandDraftScreen } from './bandDraft.js';
import { ResultScreen } from './result.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, serverNow, isSpectating } from '../store.js';
import { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch, followedScout } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
import { data, getMode } from '../data.js';
import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js.
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
import { keepEarly, audioEarly } from './game/early.js';
import { MatchEnded, PausedOverlay } from './game/overlays.js';
import { inspectRange } from './game/range.js';
import { t, tParts } from '../../../shared/i18n.js';
/** Router for the in-match screens. */
export function GameScreen() {
const pub = useStore((s) => s.match.public);
const hasResult = useStore((s) => !!s.match.result);
const ended = useStore((s) => !!s.room && !s.room.inMatch && !!s.match.public);
const away = useStore((s) => s.away, Object.is, awayStore);
const autoplay = useStore((s) => !!(Array.isArray(s.match.public?.players) && s.match.public.players.find((p) => p && p.playerId === s.me.playerId)?.autoplay));
const gd = useGameData();
if (!pub || !gd.ready) {
return html`
`;
}
const mode = phaseMode(pub.phase);
let body;
if (hasResult || mode === 'result') body = html`<${ResultScreen} />`;
else if (mode === 'briefing') body = html`<${BriefingScreen} />`;
else if (mode === 'draft') body = html`<${BandDraftScreen} />`;
else body = html`<${MatchScreen} />`;
return html`${body}
${(away || autoplay) && !hasResult && mode !== 'result' && !ended ? html`<${AwayOverlay} />` : null}
${ended && !hasResult && mode !== 'result' ? html`<${MatchEnded} />` : null}`;
}
// ---- match screen ----------------------------------------------------------------------------------------
/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
function MatchScreen() {
useDocClass('sp-in-match');
const pub = useStore((s) => s.match.public);
const priv = useStore((s) => s.match.private);
const field = useStore((s) => s.match.field);
const myId = useStore((s) => s.me.playerId);
const conn = useStore((s) => s.connection, shallowEqual);
const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
const specFacts = useStore((s) => ({
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
isHost: !!s.room && s.room.hostId === s.me.playerId,
}), shallowEqual);
const gd = useGameData();
const hostRef = useRef(null);
const barRef = useRef(null);
const hudElRef = useRef(null); // .gm__hud (inside the safe-area insets: the panels' frame)
const { view, kind: viewKind } = useFieldView(hostRef);
const [watching, setWatching] = useState(null); // fieldId the player chose to watch (null = home)
const [watchWho, setWatchWho] = useState(null); // { fieldId, playerId }: the teammate picked with 前往查看
const [drawer, setDrawer] = useState(null); // 'enemies' | 'info' | null
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
const [bondsCollapsed, setBondsCollapsed] = useState(false);
const [detail, setDetail] = useState(null); // detail target
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
const cardTap = useRef(0);
const [collapsed, setCollapsed] = useState(false);
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
// single-flights the intent; the R hotkey is dropped by the same guard)
const [refreshBusy, setRefreshBusy] = useState(false);
useEffect(() => onRefreshBusy(setRefreshBusy), []);
const [rewardMin, setRewardMin] = useState(false);
const [emoteOpen, setEmoteOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [exitOpen, setExitOpen] = useState(false);
const [drag, setDrag] = useState(null); // { uid, kind, id } while dragging a piece
const [facing, setFacing] = useState(null); // direction step: { uid, piece, row, col, grid, name }
const [sel, setSel] = useState(null); // tapped own piece: { uid }
const [selBusy, setSelBusy] = useState(false);
const [holdSeq, setHoldSeq] = useState(0); // bumped when a held piece is released (re-apply the prep state)
const [hud, setHud] = useState(null);
const [banner, setBanner] = useState(null);
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
const [replace, setReplace] = useState(null); // equip-replace dialog: { request, resolve }
const [pauseBusy, setPauseBusy] = useState(false);
const [armedCard, setArmedCard] = useState(null); // the shop bar's armed card { kind, id } (merge tile cue)
const cc = isClientCombat(pub);
const battleState = useStore((s) => s.match.battle, shallowEqual); // local battle runner (client-side combat)
const phase = pub?.phase;
const mode = phaseMode(phase);
const combat = isCombatPhase(phase);
const solo = roomSolo || String(pub?.modeId || '').includes('single');
const players = sortedPlayers(pub);
const meP = players.find((p) => p.playerId === myId) || null;
// a spectator seat watches like an eliminated player (it has no m.private and no row in m.public)
const alive = spectator ? false : priv ? priv.alive !== false : meP?.alive !== false;
const home = homeFieldId(pub, myId);
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
// watching a teammate's prep board: the 本局信息 drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2)
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
const hasPersonalChoice = !!personalChoice;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
const total = phaseTotalSeconds(pub, gd.config, myId);
// solo pause: offered in the own battle; while m.public.paused every HUD clock stops at the pause moment
const paused = isPaused(pub);
const pauseSeenRef = useRef(null);
if (!paused) pauseSeenRef.current = null;
else if (pauseSeenRef.current == null) pauseSeenRef.current = serverNow();
const frozenAt = paused ? frozenNow(pub, serverNow(), pauseSeenRef.current) : null;
// live LP (user playtest #3 item 2): the own normal battle's counted leaks — the local runner's count (authoritative
// or display replica) and the server's m.public players[].pendingLp, whichever is further (both only grow during a
// round; a display replica stands still while the player watches a teammate's field, the server's count then moves
// on) — cost min(lpCapPerRound, n) at settlement; shown at once until the settled m.private lands (ui/hud.js liveLp
// keeps the base between renders); 联防 (user playtest #6 item 7): a leaker's enemies still standing on the 联防 field —
// the local replica's count while it runs, else m.public players[].uniteLeft (ui/hud.js uniteRemaining) — replace it;
// the teammates' rows take the same local counts (`uniteLocal`, ui/teamPanel.js rowLp)
const lpBaseRef = useRef(null);
const localLeaks = battleState && battleState.leaks ? battleState.leaks[ownFieldId(myId)] : undefined;
const uniteLocal = phase === PHASE.UNITE && battleState && battleState.uniteLeft ? battleState.uniteLeft : null;
const leaker = phase === PHASE.UNITE && Array.isArray(pub?.unite?.leakers) && pub.unite.leakers.includes(myId);
const localLeft = leaker && uniteLocal ? (uniteLocal[myId] ?? 0) : undefined;
const liveLpNow = liveLp(lpBaseRef.current, {
phase, round: pub?.round, lp: priv?.lp, statsLeaks: priv?.stats?.leaks, alive,
leaks: ownLeaks(localLeaks, meP?.pendingLp), cap: gd.config?.lpCapPerRound,
uniteLeft: leaker ? uniteRemaining(localLeft, meP?.uniteLeft) : null,
});
lpBaseRef.current = liveLpNow.base;
// what this round's battle cost me, kept while it runs for the result box and the 战斗结束 sound at SETTLE (GitHub
// #235, PR #112 by @Convey123): by then the settlement has landed (Match.flush sends m.private before the SETTLE
// m.public) and the live count reads 0. A per-round MAXIMUM — leaks only grow within a round, and a frame that lands
// after the settlement must not wipe a real count — and only for a player in the round (a spectator seat or an
// eliminated player fights nothing and gets no box, ui/gameLogic/phases.js battleResultBox). `unite`: a 联防 ran.
const roundLossRef = useRef(null);
if (isCombatPhase(phase) && alive) {
const prev = roundLossRef.current;
const same = !!prev && prev.round === pub?.round;
const leaks = ownLeaks(localLeaks, meP?.pendingLp);
roundLossRef.current = {
round: pub?.round,
leaks: same ? Math.max(prev.leaks, leaks) : leaks,
cap: gd.config?.lpCapPerRound,
unite: phase === PHASE.UNITE || (same && prev.unite),
};
}
// latest values for event handlers bound once
const live = useRef({});
// the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's
// prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server)
const deployField = deployFieldOf(pub, myId);
// the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range,
// name; gameLogic/diy.js), every other chess its data record
const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; };
// what the own pieces show (names and art of the facing wheel, the underframe, the equip-replace dialog): a chess the
// player does not own is its stand-in (0.2.0 补位 — the owner's recall of the official mode, 2026-10-06); rules keep
// ownChess (the composed record carries the chess's identity anyway)
const ownShown = (id) => { const c = ownChess(id); return ownStandIn(c, live.current.priv, data.get('backups')) || c; };
const placeCtx = useMemo(() => placementContext({
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
// (research 09 §1.2: the right-hand player's board mirrored; gameLogic prepCamera). The DOM fallback view keeps the
// own board's layout there (every coordinate the UI handles is a board coordinate either way) and draws the tiles of
// that half (ui/fallbackField.js: the legal fence tiles are floor, not the normal field's walls; user playtest #5 item 7).
const prepCam = prepCamera(pub, myId);
const prepCamKey = `${prepCam.kind}:${prepCam.opts.side}`;
const prepCamSeen = useRef(prepCamKey); // the prep camera last requested
const camRef = useRef({ kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } });
const penRef = useRef({ on: false, collapsed: false });
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
// the bar is folded for itself: the state it returns to is the one that changes.
const readySeen = useRef(null);
useEffect(() => {
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
const change = readyShopFold(readySeen.current, cur);
readySeen.current = cur;
if (!change) return;
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
else setCollapsed(change === 'fold');
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
const cancelFacingRef = useRef(() => {});
const setCam = useCallback((kind, opts) => {
camRef.current = { kind, opts: opts || {} };
setCamKind(kind);
if (penRef.current.on) { penRef.current.on = false; setPen(false); setCollapsed(penRef.current.collapsed); }
view?.setCamera(kind, opts);
}, [view]);
// 休整期 only; never while a piece is being placed (the wheel sits on a tile of the board camera)
const penAvail = phase === PHASE.PREP && !!view && viewKind !== 'loading';
/** Pan to the enemy preview pen (on) or back to the camera in use before (off). */
const togglePen = useCallback((on) => {
const P = penRef.current;
if (!view || on === P.on) return;
if (on) {
const L = live.current;
if (L.pub?.phase !== PHASE.PREP) return;
if (L.facing) return;
P.on = true;
P.collapsed = L.collapsedNow;
setPen(true);
setSel(null);
setDrawer(null); // the pen replaces the list
setBondOpen(null); // the bond strip hides: its popup must not float over the pen
setDetail((d) => (d?.kind === 'enemy' ? d : null)); // a shop card / unit card belongs to the board view
setCollapsed(true);
view.setCamera('pen', { side: camRef.current.opts?.side || 'L' });
audio.sfx('click', { volume: 0.5 });
} else {
P.on = false;
setPen(false);
setCollapsed(P.collapsed);
const c = camRef.current;
view.setCamera(c.kind, c.opts);
audio.sfx('back', { volume: 0.5 });
}
}, [view]);
const togglePenRef = useRef(togglePen);
togglePenRef.current = togglePen;
// the stage as the own board looks: terrain 机变 cards open / reclassify tiles and remove crates (stageOverrides)
const baseStage = gd.ready ? gd.stage(pub?.stageId) : null;
const overrides = stageOverrides(priv, gd.effect);
const overridesKey = JSON.stringify(overrides);
const ownStage = useMemo(() => effectiveStage(baseStage, overrides), [baseStage, overridesKey]);
// ---- field view wiring ---------------------------------------------------------------------------------
const lastFieldRef = useRef(null);
const viewModeRef = useRef(null);
const snapUnitsRef = useRef(new Map());
// the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
// for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
const deviceUnitsRef = useRef(new Map());
const hudRef = useRef(null);
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
const evBufRef = useRef(new Map()); // fieldId → ev tuples since that field's m.field
const snapBufRef = useRef(new Map()); // fieldId → latest snapshot
// a new m.field for the field already on screen (a resync, or the runner re-showing it after a hidden-tab backlog
// overflowed): its frames are buffered like a new field's until the enter effect re-enters it — enterBattle resets
// the view, so a 'spawn' or form fx pushed in between used to be lost (placeholder views, the old model)
const reentryRef = useRef(null);
// prep rendering (own board) vs battle rendering (m.field). The field object present when prep starts is stale
// (last round's battle): combat only enters an m.field pushed after that (a new object), and every new m.field
// push (watch switch, reconnect) re-enters the battle view.
const showPrep = (mode === 'prep' || mode === 'boot') && !watchingOther;
// own board (prep, or a battle field the player fights on) → the stage with the player's overrides; a teammate's
// board → the plain stage (their overrides are not known here)
const shownField = showPrep ? null : field;
const shownMembers = shownField ? ((Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === shownField.fieldId)?.players
|| (Array.isArray(shownField.players) ? shownField.players : null)) : null;
const ownView = showPrep || (shownField ? (shownMembers ? shownMembers.includes(myId) : shownField.fieldId === ownFieldId(myId)) : !watchingOther);
// every field is fought on the round's battlefield — 联防 too, its terrain, crates and water included (0.2.1, the
// owner's decision of 2026-10-07; 0.2.0 drew the escaped levels' empty road there and restored the board on leaving)
useEffect(() => {
if (!view) return;
const st = ownView ? ownStage : baseStage;
if (st) view.setStage(st);
}, [view, ownView, ownStage, baseStage]);
// the stage behind the board ON SCREEN — the own one (机变 overrides applied) or, while watching a teammate, the plain
// one — and how a tapped BOARD tile maps to it (GitHub issue #184: tileClick → gameLogic.terrainInfo). Everywhere but a
// boss-prep board the two spaces are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (GEO.BOSS_RECT),
// 联防 / normal rects are stage rows; the boss PREP draws the player's half (stage rows 2–5) as board rows 9–12
// (render/prepfield.js toDisp), which is exactly gameLogic.fieldTile.
live.current.terrainStage = ownView ? ownStage : baseStage;
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
? (row, col) => fieldTile(deployField, row, col)
: (row, col) => [row, col];
// a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
// unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
live.current.deviceBattle = !!shownField && !shownField.prep;
const staleFieldRef = useRef(null);
const enteredFieldRef = useRef(null);
const enteredViewRef = useRef(null);
const pressSel = useRef(null); // the selected piece when the current field press began
useEffect(() => {
// Read before a view change clears the refs. A later refresh of this same field (g.watch / reshow replaces the
// field object) must still know which battle the device units already noted belong to.
const previousFieldId = lastFieldRef.current;
const previousDevices = deviceUnitsRef.current;
if (enteredViewRef.current !== view) {
// A replacement engine has no field, even when the match's m.field object did not change. While it loads,
// buffer frames instead of sending them to a null/destroyed view; its subscription below asks for fresh meta.
enteredViewRef.current = view;
enteredFieldRef.current = null;
lastFieldRef.current = null;
viewModeRef.current = null;
reentryRef.current = null;
}
if (!view) return;
if (showPrep) {
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
if (viewModeRef.current !== 'prep') {
audio.stopBondCue('battle');
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
staleFieldRef.current = field;
const pc = prepCameraFor(pub, myId, shopFolded);
setCam(pc.kind, pc.opts);
prepCamSeen.current = prepCamKey;
viewModeRef.current = 'prep';
lastFieldRef.current = null;
enteredFieldRef.current = null;
reentryRef.current = null;
setHud(null);
}
if (priv) {
view.setPrep(priv, { editable, canPlace: (uid, target) => canPlace(live.current.placeCtx, uid, target).ok });
// stored facings (m.private board `dir`); the piece in the direction step shows the wheel's preview instead
syncPieceDirs(view, priv, live.current.facing?.uid ?? null);
}
return;
}
const wanted = combat || mode === 'settle' || watchingOther;
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId;
reentryRef.current = null;
viewModeRef.current = 'battle';
snapUnitsRef.current = new Map();
const early = evBufRef.current.get(field.fieldId);
evBufRef.current.delete(field.fieldId);
// Crates and turrets are absent from a start-of-battle meta and arrive once, as spawn events. Re-entering the
// same field from a refresh that repeats that meta (the subscription below asks for one) must not drop them.
const noted = noteDeviceUnits(new Map(), { units: field.units, events: early });
deviceUnitsRef.current = deviceUnitsForEntry(previousDevices, noted, previousFieldId != null && previousFieldId === field.fieldId);
// The renderer clears its unit metadata on entry too. Restore known devices there as well as in the card lookup;
// otherwise their next HP tuple becomes an unknown enemy whose pieceClick consumes the device's tileClick.
const units = Array.isArray(field.units) ? [...field.units] : [];
const ids = new Set(units.map((u) => u?.id));
for (const [id, info] of deviceUnitsRef.current) if (!ids.has(id)) units.push(info);
view.enterBattle({ ...field, units });
const earlySnap = snapBufRef.current.get(field.fieldId);
snapBufRef.current.delete(field.fieldId);
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
// 'prep' expands the rect's top to the hand row
const kind = field.prep ? 'prep' : (field.kind === 'hidden' ? 'boss' : field.kind || 'normal');
const pf = (Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === field.fieldId);
const members = Array.isArray(pf?.players) ? pf.players : Array.isArray(field.players) ? field.players : [];
const sides = field.sides && typeof field.sides === 'object' ? field.sides : null;
// a scouted board of a boss round's prep (Match.prepFieldMeta: the boss-field rows, `side` = the scouted player's
// half) is framed by the boss-field prep camera of that half — the leader standing at its spawn (item 55)
const side = field.prep && (field.side === 'L' || field.side === 'R') ? field.side
: sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L';
// local simulation (client-side combat) feeds a frame per animation frame: no network jitter buffer
view.raw?.setLocalFeed?.({ on: !!field.local, speed: field.speed });
setLayer('ALL');
// a lone player's boss field (solo modes, the odd player of a co-op Final Assault: the `_s` templates route every
// enemy to the left objective) is framed on its own half like the ‹ › half view; pairs start on 全景
const lone = kind === 'boss' && members.length === 1;
setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side, ...(field.prep ? { shop: false } : {}) });
audio.setFieldUnits(field.units);
if (early && early.length) {
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
view.pushEvents(early);
audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast)
}
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
// above and places the units again, including an empty board (GitHub #87).
view.pushSnapshot({
fieldId: field.fieldId, gt: 0,
units: field.units.filter((u) => u && u.id != null).map((u) => [u.id, Number(u.x) || 0, Number(u.y) || 0, u.maxHp || 1, u.maxHp || 1, 0, 0, 0, 0]),
});
}
if (earlySnap) {
view.pushSnapshot(earlySnap);
if (Array.isArray(earlySnap.units)) {
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
}
hudRef.current = snapHud(earlySnap);
setHud(hudRef.current);
}
}, [view, showPrep, priv, editable, field, combat, mode, watchingOther, watching, holdSeq]);
// battle frames straight from the socket (server-run combat, 20 Hz) or from the local simulation (client-side combat,
// battle/runner.js, every animation frame) — never through the store. Frames go to the view as received: the game
// time travels as `gt` and the render engine reads it (render/interp.js frameTime).
useEffect(() => {
let last = 0;
let pending = null;
const flush = () => { pending = null; last = performance.now(); setHud(hudRef.current); };
const onFieldMeta = (msg) => {
if (!msg || typeof msg.fieldId !== 'string') return;
evBufRef.current.set(msg.fieldId, []);
snapBufRef.current.delete(msg.fieldId);
if (msg.fieldId === lastFieldRef.current) reentryRef.current = msg.fieldId;
};
// the field the view shows now (null while a re-entry of it is pending: its frames are buffered)
const shownId = () => (reentryRef.current && reentryRef.current === lastFieldRef.current ? null : lastFieldRef.current);
const onSnap = (snap) => {
const cur = shownId();
if (!snap || typeof snap !== 'object') return;
if (!cur || (snap.fieldId && snap.fieldId !== cur)) {
if (typeof snap.fieldId === 'string') snapBufRef.current.set(snap.fieldId, snap);
return;
}
view?.pushSnapshot(snap);
if (Array.isArray(snap.units)) {
const mp = new Map();
for (const t of snap.units) if (Array.isArray(t)) mp.set(t[0], t);
snapUnitsRef.current = mp;
}
hudRef.current = snapHud(snap);
const dt = performance.now() - last;
if (dt >= HUD_HZ_MS) flush();
else if (!pending) pending = setTimeout(flush, HUD_HZ_MS - dt);
};
const onEv = (msg) => {
const cur = shownId();
if (!msg || !Array.isArray(msg.ev)) return;
if (!cur || (msg.fieldId && msg.fieldId !== cur)) {
if (typeof msg.fieldId !== 'string') return;
const buf = evBufRef.current.get(msg.fieldId) || [];
// only state-bearing tuples are replayed later (see the enter effect)
for (const e of msg.ev) if (keepEarly(e)) buf.push(e);
if (buf.length > 1500) buf.splice(0, buf.length - 1500);
evBufRef.current.set(msg.fieldId, buf);
if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value);
return;
}
view?.pushEvents(msg);
noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
// (完美作战 ⇒ 3星结束行动, 绝境 / 终极 ⇒ 完成高难行动, 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败)
const onResult = (msg) => {
try {
if (!msg || !msg.result) return;
const st = store.get();
const pid = st.me?.playerId;
const mine = (pid && msg.result.perPlayer && msg.result.perPlayer[pid]) || null;
const diff = st.match?.public?.difficulty;
// the speaker comes from THIS battle's own field (`mine.unitsEnd`), not from the field on screen: watching a
// teammate used to make THEIR operator say the viewer's line (review on #73). unitsEnd names chess ids: the
// record the own piece shows gives the operator whose voice bank speaks — a 补位 piece's stand-in (its own
// voice, like its 选中 line) and a filled 自选 slot's operator (ownShown), else the chess record's charId
const charId = resultSpeaker(mine, Math.random, (id) => ownShown(id)?.charId ?? null);
if (!charId) return;
audio.voice(charId, resultVoiceSlot({
perfect: !!(mine?.perfect),
leaked: Array.isArray(mine?.leaked) ? mine.leaked.length : 0,
killed: mine?.killed ?? msg.result.killed,
total: mine?.total ?? msg.result.total,
hard: diff === 'HARD' || diff === 'ABYSS',
}));
} catch { /* ignore */ }
};
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
// Metadata must include enemies/tokens spawned before this renderer existed, not only events buffered during
// loading. Subscribe first so the reshow's metadata and snapshot take the ordinary buffered re-entry path.
if (view && !showPrep && field?.fieldId) {
if (field.local) battleRunner?.reshow();
else if (combat || watchingOther) {
const who = watchWho?.fieldId === field.fieldId ? watchWho.playerId : null;
net.request('g.watch', { fieldId: field.fieldId, ...(who ? { playerId: who } : {}) }).catch(() => {});
}
}
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
}, [view]);
// the prep board moves while prep is shown (a boss round's prep begins, or a teammate left and the pairs changed):
// the next camera of the own board (the direction wheel sits on a board tile — it closes first)
useEffect(() => {
if (prepCamSeen.current === prepCamKey) return;
prepCamSeen.current = prepCamKey;
if (!view || viewModeRef.current !== 'prep' || !showPrep) return;
if (live.current.facing) cancelFacingRef.current();
setSel(null);
const pc = prepCameraFor(pub, myId, shopFolded);
setCam(pc.kind, pc.opts);
}, [view, prepCamKey, showPrep]);
// the shop bar folded (收起) or unfolded on the own prep board (public issue #5: the board did not grow, while the
// battle and scouting views had no bar to make room for): the official shop-collapsed camera or the shop camera
// again, flown like any camera change. Never over the pen or a teammate's board or in battle; deferred while a piece
// is dragged or its direction is chosen (the drop target and the wheel sit on tiles of the camera in use) — this
// effect runs again when that ends (gameLogic foldCamera). Picking reads the camera of every frame (render/pick.js).
useEffect(() => {
const next = foldCamera({
pub, myId, folded: shopFolded, ownPrep: !!view && viewModeRef.current === 'prep' && showPrep,
pen, busy: !!drag || !!facing, current: camRef.current,
});
if (next) setCam(next.kind, next.opts);
}, [view, shopFolded, showPrep, pen, !!drag, !!facing, prepCamKey]);
// 联防 / 最终攻势: the ‹ › pill moves the camera between the field's halves and 全景 (research 09 §3.1)
useEffect(() => {
if (!view || !field || !field.local || !isCombatPhase(phase)) return;
if (!cameraLayers(field, pub, myId).length) return;
const kind = field.kind === 'hidden' ? 'boss' : field.kind;
setCam(kind, layerCamera(field, layer, sidesOf(field)[myId] || 'L'));
}, [layer]);
// 前往查看 of a teammate on a two-half shared field (a 联防 leaker / an eliminated spectator tapping a helper or a pair
// player): the camera goes to that player's half, so the ‹ › pill reads "👁 name" like the strip's tag (DESIGN §20.15;
// official observing targets a player, research 09 §3.1). Once per pick and per field entered (entering resets 全景).
const whoAppliedRef = useRef({ who: null, field: null });
useEffect(() => {
const who = watchWho;
if (!view || !who || !field || field.fieldId !== who.fieldId || !field.local || !isCombatPhase(phase)) return;
const A = whoAppliedRef.current;
if (A.who === who && A.field === field) return;
whoAppliedRef.current = { who, field };
const k = playerLayer(field, pub, myId, who.playerId);
if (!k) return;
setLayer(k);
// the camera directly too: when the layer state already equals k the [layer] effect would not run
setCam(field.kind === 'hidden' ? 'boss' : field.kind, layerCamera(field, k, sidesOf(field)[myId] || 'L'));
}, [view, watchWho, field, phase]);
// graceful degradation: without WebGL / the render engine the DOM view takes over — say so once per match
useEffect(() => {
if (viewKind !== 'fallback') return;
let asked = null;
try { asked = new URLSearchParams(globalThis.location?.search || '').get('render'); } catch { asked = null; }
if (asked === 'fallback' || globalThis.__SP_RENDER__ === 'fallback') return;
toast(t('渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)'), 'info', { ttl: 5000 });
}, [viewKind]);
// phase changes: banners, sounds, resets
const phaseKey = `${phase}:${pub?.round}`;
const prevPhase = useRef(null);
const resultSeq = useRef(0); // re-keys the result box (ResultDialog) at every SETTLE
useEffect(() => {
const prev = prevPhase.current;
prevPhase.current = phase;
if (prev === phase) return;
const b = phaseBanner(phase, pub, { alive, spectator });
if (b) setBanner({ ...b, key: phaseKey });
if (phase === PHASE.ROUND_START) audio.sfx('roundStart');
else if (phase === PHASE.PREP) audio.sfx('rest', { volume: 0.7 });
else if (phase === PHASE.COMBAT) audio.sfx('battleStart');
else if (phase === PHASE.UNITE) audio.sfx('defenceUnite');
else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam');
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
else if (phase === PHASE.SETTLE) {
// the official 战斗结束 sound and result box (GitHub #235, PR #112 by @Convey123; ui/gameLogic/phases.js): the LP this
// round cost ME — after a 联防 the authority's own charge (m.public.uniteResult.losses: a leaker pays for the
// survivors, not for its own battle's leaks), else the own battle's as kept while it ran (roundLossRef)
const kept = roundLossRef.current;
roundLossRef.current = null;
const cost = kept && kept.round === pub?.round ? kept : null;
const sfx = battleOverSfx(cost, pub?.uniteResult?.losses?.[myId]);
if (sfx) audio.sfx(sfx);
const box = uniteResultBox(pub?.uniteResult, myId) || battleResultBox(cost);
resultSeq.current += 1;
if (box) setResultBox({ ...box, key: `result:${pub?.round}:${resultSeq.current}` });
}
// the server resets every watcher to its own field on phase changes — but an eliminated player / spectator seat
// keeps the prep board it follows from 回合开始 through 机变 and 休整期 (Match._followScout re-points it only at the
// round start): resetting here would fly the camera home and back at every prep phase (item 56)
const keepScout = (phase === PHASE.SP_DRAFT || phase === PHASE.PREP) && (!alive || spectator)
&& typeof live.current.watching === 'string' && live.current.watching.startsWith('n:');
if (!keepScout) {
setWatching(null);
setWatchWho(null);
}
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
setSel(null);
if (phase !== PHASE.PREP) setRewardMin(false);
setSpBusy(null);
}, [phaseKey]);
useEffect(() => {
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
if (!personalChoice) return;
setSel(null);
setDetail(null);
setArmedCard(null);
setDrag(null);
view?.highlightTiles(null, null);
cancelFacingRef.current();
}, [personalChoice?.id, view]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
const seenBattleRef = useRef(null);
useEffect(() => {
const r = resumedWatch(battleState, { pub, myId, alive, watching, seen: seenBattleRef.current });
seenBattleRef.current = r.seen;
if (r.fieldId) setWatching(r.fieldId);
}, [battleState?.battleId, battleState?.loading, !!pub]);
// timer ticks (≤ 10 s) while the player still has something to do
const cd = countdownState(pub?.deadline, serverNow(), total);
useTicker(cd.remain != null && cd.remain <= 10 ? 1000 : 0);
const lastTick = useRef(null);
useEffect(() => {
if (cd.remain == null || cd.remain > 10 || cd.remain === lastTick.current) return;
lastTick.current = cd.remain;
const busyPrep = phase === PHASE.PREP && priv && !priv.ready && alive;
const myPick = sp && (solo || sp.turnPid === myId) && !sp.pickOf.has(myId);
if ((busyPrep || myPick) && cd.remain > 0) audio.sfx('timer', { volume: 0.6 });
});
// your turn cue in the 机变 draft
const spMine = !!sp && !solo && sp.turnPid === myId && !sp.pickOf.has(myId);
useEffect(() => { if (spMine) audio.sfx('yourTurn'); }, [spMine]);
// promotion (merge reward offered) and bond activation cues; a special refresh's offer (凯瑟琳 定向投放 …) plays the
// shop's refresh sound instead of the promotion cue
const offerCue = priv?.shop?.rewardOffer ? (priv.shop.rewardOffer.source === 'special' ? 'refresh' : 'merge') : null;
useEffect(() => { if (offerCue) audio.sfx(offerCue); }, [offerCue]);
// a shop / reward card armed for a purchase that completes a merge lights the tile its elite will take (the deployed
// copy that deploys first — PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", gameLogic.mergeTarget)
const mergeAt = armedCard?.kind === 'chess' && editable && showPrep ? mergeTarget(priv, armedCard.id, gd.chess) : null;
const mergeAtKey = mergeAt ? `${mergeAt.row},${mergeAt.col}` : '';
useEffect(() => {
if (!view) return;
try { view.highlightTiles(mergeAt ? [[mergeAt.row, mergeAt.col]] : [], MERGE_HL); } catch { /* cosmetic */ }
}, [view, mergeAtKey]);
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
const prevActive = useRef(activeBonds);
useEffect(() => {
// a layer gain of the same update already started the cue (audio.bondCueRecent): keep it, don't restart it
if (activeBonds > prevActive.current && phase === PHASE.PREP && !audio.bondCueRecent('prep')) audio.sfx('bondUp', { volume: 0.7, scope: 'prep' });
prevActive.current = activeBonds;
}, [activeBonds]);
// emote bubbles (and a sound for teammates' emotes)
const bubbleMs = (gd.config?.timers?.chatBubble ?? 3) * 1000;
const bubbles = activeBubbles(emotes, Date.now(), bubbleMs);
useTicker(bubbles.size ? 500 : 0); // re-render only while a bubble is showing (to expire it)
const lastEmote = useRef(emotes.length ? emotes[emotes.length - 1].seq : 0);
useEffect(() => {
const e = emotes[emotes.length - 1];
if (e && e.seq > lastEmote.current) { lastEmote.current = e.seq; if (e.playerId !== myId) audio.sfx('emote', { volume: 0.6 }); }
}, [emotes]);
// ---- actions ----------------------------------------------------------------------------------------------
const buy = useCallback((i) => actions.buy(i), []);
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
const askingReady = useRef(false);
const toggleReady = useCallback(async (r) => {
const L = live.current;
const me = Array.isArray(L.pub?.players) ? L.pub.players.find((p) => p && p.playerId === L.myId) : null;
const ask = r ? readyFundsPrompt(L.priv, { keptBands: data.get('config')?.economy?.leftoverFundsKeptByBands, autoplay: !!me?.autoplay }) : null;
if (ask) {
if (askingReady.current) return;
askingReady.current = true;
const ok = await confirmDialog(ask);
askingReady.current = false;
const now = live.current;
if (!ok || now.pub?.phase !== PHASE.PREP || now.priv?.ready) return;
}
setReadyBusy(true);
await actions.ready(r);
setReadyBusy(false);
}, []);
// the prep ended (timer) while the question was open: drop it — the funds are gone either way
useEffect(() => { if (phase !== PHASE.PREP && askingReady.current) closeAllDialogs(); }, [phase]);
/** 出售 (operators, underframe +N) / 销毁 (items and Arts, confirmed: they cannot be sold). */
const sellPiece = useCallback(async (piece) => {
if (!piece) return false;
const closeIt = () => { setDetail((d) => (d?.kind === 'piece' && d.uid === piece.uid ? null : d)); setSel((x) => (x && x.uid === piece.uid ? null : x)); };
if (piece.kind === 'item') {
// an equipped item is locked (the server refuses g.destroy): replacing it is the equip-replace dialog's job
if (!itemDestroyable(live.current.placeCtx, piece.uid)) { toast(t('已配发的装备无法销毁'), 'warn'); audio.sfx('error', { volume: 0.5 }); return false; }
const it = data.lookup('items', piece.id);
const ok = await confirmDialog({ title: t('销毁道具'), text: t('道具无法出售。确定要销毁「{name}」吗?', { name: it?.name || t('道具') }), okText: t('销毁'), danger: true });
if (ok && await actions.destroy(piece.uid)) { closeIt(); return true; }
return false;
}
if (await actions.sell(piece.uid)) { closeIt(); return true; }
return false;
}, []);
// The watched field only changes once the server accepted g.watch: a refused target (an eliminated teammate, the
// other pair's boss field) must not move the eye icon / switcher label away from what is actually on screen.
// `playerId`: the teammate picked (a team row) — a shared field shows two players, the strip follows the picked one
// (DESIGN §20.15); null for a field picked as such (the legacy switcher).
const requestWatch = useCallback(async (fid, playerId = null) => {
const prev = live.current.watching;
const prevWho = live.current.watchWho;
const who = playerId ? { fieldId: fid, playerId } : null;
setWatching(fid);
setWatchWho(who);
const ok = await actions.watch(fid, playerId);
if (!ok) {
setWatching((w) => (w === fid ? prev : w));
setWatchWho((w) => (w === who ? prevWho : w));
}
return ok;
}, []);
/** Back to the own field (返回战场 / the own row). */
const backHome = useCallback(() => {
const L = live.current;
if (L.watching && L.watching !== L.home) {
const target = isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId);
// client-side combat without an own field to go back to (a 联防 leaker, an eliminated player): the screen keeps
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast); an
// eliminated player / spectator seat has no prep board of its own either
const combatNow = isCombatPhase(L.pub?.phase);
const exists = combatNow ? (!isClientCombat(L.pub) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target)))
: (L.alive && !L.spectator);
if (exists) actions.watch(target);
}
setWatching(null);
setWatchWho(null);
}, []);
const watchPlayer = useCallback((p) => {
const L = live.current;
if (isClientCombat(L.pub)) {
const observing = !!L.watching && L.watching !== L.home && L.watching !== ownFieldId(L.myId);
const t = observeTarget(p, L.pub, L.myId, { observing, ownDone: L.localDone });
if (t.back) { backHome(); return; }
if (t.reason) { toast(t.reason, 'warn'); audio.sfx('error', { volume: 0.5 }); return; }
if (t.fieldId) requestWatch(t.fieldId, p.playerId);
return;
}
const self = p.playerId === L.myId;
if (self) {
// (an eliminated player / spectator seat has no prep board of its own to ask for: item 56)
const combatNow = isCombatPhase(L.pub?.phase);
if (L.watching && L.watching !== L.home && (combatNow || (L.alive && !L.spectator))) actions.watch(combatNow ? L.home : ownFieldId(L.myId));
setWatching(null);
setWatchWho(null);
return;
}
const t = watchTarget(p, L.pub, L.myId);
if (t.reason) { toast(t.reason, 'warn'); audio.sfx('error', { volume: 0.5 }); return; }
requestWatch(t.fieldId, p.playerId);
}, []);
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
// an eliminated player or a spectator seat follows a player through every phase reset (community report of
// 2026-10-06, item 56; the idea of PR #189): the server pushes that player's prep board (Match._followScout) — the one
// it last watched, else the first player still in — and the screen adopts it like a 前往查看 tap, once per phase and
// board (a 返回战场 this phase is not overridden; a row switches to another player, who is followed from then on).
// Until 0.2.0 every round's reset dropped an eliminated player onto its own empty board (a spectator seat asked for
// the first player itself).
const followRef = useRef(null);
useEffect(() => {
const fid = followedScout({ field, watching, alive, spectator, myId });
if (!fid || !pub) return;
const key = `${phaseKey}:${fid}`;
if (followRef.current === key) return;
followRef.current = key;
// the phase reset's render (watching null) ran the own-prep branch with this scout meta in `field` and marked it
// stale: un-mark it, or the enter effect refuses it and the board stays blank until the player moves (PR #189)
if (staleFieldRef.current === field) staleFieldRef.current = null;
setWatching(fid);
setWatchWho({ fieldId: fid, playerId: fid.slice(2) });
}, [field, pub, watching, alive, spectator, phaseKey, myId]);
// ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
useEffect(() => {
if (!view) return undefined;
let moveOff = null;
// the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit —
// user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel)
const ptr = { released: false, tile: null };
const lookups = { getChess: ownChess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) };
const runIntent = async (intent) => {
const L = live.current;
if (intent.confirmReplace) {
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent)
const request = replaceRequest(L.placeCtx, intent, ownShown, gd.item);
if (request) {
const uid = await openReplaceRef.current(request);
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; }
const r = replaceIntent(intent, uid);
await actions.equip(r.fields.itemUid, r.fields.targetUid, r.fields.replaceUid);
return;
}
}
const f = intent.fields;
if (intent.t === 'g.move') await actions.move(f.uid, f.to);
else if (intent.t === 'g.equip') await actions.equip(f.itemUid, f.targetUid);
else if (intent.t === 'g.art') await actions.art(f.itemUid, f.row, f.col);
};
const refuse = (reason) => {
audio.sfx('error', { volume: 0.5 });
if (reason) toast(reason, 'warn');
};
const endDrag = () => {
moveOff?.(); moveOff = null;
setDrag(null);
view.highlightTiles(null, null);
};
/** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
const openFacing = (entry, t) => {
const piece = entry.piece;
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownShown(piece.id);
holdPiece(view, piece.uid, { row: t.row, col: t.col });
setSel(null);
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' });
audio.sfx('pick', { volume: 0.5 });
};
const offs = [
view.on('pieceDragStart', (e) => {
const L = live.current;
const entry = L.placeCtx?.pieces.get(e?.uid);
if (!entry || !L.editable) return;
audio.sfx('pick', { volume: 0.6 });
setSel(null);
// a new placement: a piece's detail card (right-click / long-press) would sit beside the wheel showing another unit
setDetail((d) => (d?.kind === 'piece' ? null : d));
setDrag({ uid: entry.piece.uid, kind: entry.piece.kind, id: entry.piece.id, area: entry.area });
const { legal } = boardTargets(L.placeCtx, entry.piece.uid);
view.highlightTiles(null, null);
view.highlightTiles(legal, 'legal');
ptr.released = false; ptr.tile = null;
// capture phase: marked before the drag controller (canvas listener) ends the drag
const onUp = () => { ptr.released = true; };
window.addEventListener('pointerup', onUp, { passive: true, capture: true });
moveOff = () => window.removeEventListener('pointerup', onUp, { capture: true });
}),
view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }),
view.on('pieceDrop', async (e) => {
endDrag();
const L = live.current;
if (!e || !L.editable) return;
const entry = L.placeCtx?.pieces.get(e.uid);
if (!entry) return;
const t = e.target || {};
if (t.area === 'outside') return; // no drag-to-sell (research 09 §5): the piece goes back
const res = canPlace(L.placeCtx, e.uid, t);
if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(entry, t); return; }
const intent = dropIntent(L.placeCtx, e.uid, t);
if (!intent) {
if (res.code && res.code !== 'ALREADY') refuse(res.reason);
return;
}
await runIntent(intent);
}),
view.on('pieceDragEnd', (e) => {
// a cancelled drag (no pieceDrop) must not leave the highlights behind; a release on a tile that takes nothing
// (the drag controller found no legal target there) says why
const released = ptr.released;
const tile = ptr.tile;
endDrag();
const L = live.current;
if (!e || e.dropped || !released || !L.editable) return;
const src = L.placeCtx?.pieces.get(e.uid);
if (!src) return;
const onOwnSlot = !!tile && ((src.area === 'board' && tile.area === 'board' && tile.row === src.row && tile.col === src.col)
|| (src.area !== 'board' && tile.area === src.area && (tile.idx ?? tile.col) === src.idx));
if (onOwnSlot) return; // put back where it was
const reason = dropFailureReason(L.placeCtx, e.uid, tile);
if (reason) refuse(reason);
}),
view.on('pieceClick', (e) => {
if (!e) return;
audio.sfx('click', { volume: 0.4 });
// an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card)
const penKey = previewEnemyKey(e);
if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; }
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid }); return; }
if (!Number.isInteger(e.uid)) return;
setDetail({ kind: 'piece', uid: e.uid });
// a tap selects an own piece (underframe + range); right-click / long-press only opens its detail card. A tap on
// the piece that was selected when the press started deselects it.
const L = live.current;
if (e.detail || e.button === 2 || !L.editable || L.facing) return;
const wasSel = pressSel.current === e.uid;
pressSel.current = null;
setSel(wasSel ? null : { uid: e.uid });
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
}),
// a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
// #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
// tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
// 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
// press keeps its other meanings (deselect, close).
view.on('tileClick', (t) => {
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
const L = live.current;
const [row, col] = L.terrainTile(t.row, t.col);
const device = L.deviceBattle
? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
: deviceInfo(L.terrainStage, row, col);
if (device) {
audio.sfx('click', { volume: 0.4 });
setSel(null);
setDetail({ kind: 'device', device });
return;
}
const info = terrainInfo(L.terrainStage, row, col);
if (!info) return;
audio.sfx('click', { volume: 0.4 });
setSel(null);
setDetail({ kind: 'terrain', terrain: info });
}),
];
return () => { moveOff?.(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } };
}, [view]);
// ---- equip-replace dialog (ui/equipReplace.js): one at a time; resolves the picked replaceUid or null (cancel) ----------
const replaceRef = useRef(null);
replaceRef.current = replace;
const closeReplace = useCallback((uid) => {
const cur = replaceRef.current;
if (!cur) return;
replaceRef.current = null;
setReplace(null);
try { cur.resolve(Number.isInteger(uid) ? uid : null); } catch { /* ignore */ }
}, []);
const openReplaceRef = useRef(null);
openReplaceRef.current = (request) => new Promise((resolve) => {
if (replaceRef.current) { try { replaceRef.current.resolve(null); } catch { /* ignore */ } }
const next = { request, resolve };
replaceRef.current = next;
setSel(null);
setReplace(next);
audio.sfx('click', { volume: 0.5 });
});
// the dialog closes (nothing sent) when editing stops or its operator / item / equipped items changed meanwhile
useEffect(() => {
if (!replace) return;
const r = replace.request;
const target = placeCtx.pieces.get(r.targetUid)?.piece;
const items = Array.isArray(target?.items) ? target.items : [];
const still = editable && !!target && !!placeCtx.pieces.get(r.item.uid) && r.options.every((o) => items.some((x) => x && x.uid === o.uid));
if (!still) closeReplace(null);
}, [replace, placeCtx, editable]);
useEffect(() => () => { const cur = replaceRef.current; replaceRef.current = null; try { cur?.resolve(null); } catch { /* ignore */ } }, []);
// ---- solo pause (g.pause; m.public.paused) ---------------------------------------------------------------------------
const togglePause = useCallback(async (on) => {
setPauseBusy(true);
await actions.pause(on);
setPauseBusy(false);
}, []);
const togglePauseRef = useRef(togglePause);
togglePauseRef.current = togglePause;
// a press on the field deselects (a tap on a piece selects it again at release — see pieceClick)
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
const onDown = () => {
const L = live.current;
pressSel.current = L.sel ? L.sel.uid : null;
if (L.sel) setSel(null);
// tapping elsewhere closes the selection and a card opened from the field — by a tap, a right-click or a long
// press (a press on a piece re-opens its card at release, pieceClick)
setDetail((d) => (closesOnFieldPress(d) ? null : d));
};
host.addEventListener('pointerdown', onDown, true);
return () => host.removeEventListener('pointerdown', onDown, true);
}, []);
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
const releaseHold = useCallback((uid) => {
heldRef.current.delete(uid);
holdPiece(view, uid, null);
setHoldSeq((n) => n + 1); // re-apply the prep state: the piece settles where the server says
}, [view]);
const cancelFacing = useCallback(() => {
const f = live.current.facing;
if (!f) return;
setFacing(null);
if (f.piece.kind !== 'item') setPieceDir(view, f.uid, pieceDir(f.piece));
releaseHold(f.uid);
audio.sfx('back', { volume: 0.5 });
}, [view, releaseHold]);
cancelFacingRef.current = cancelFacing;
const previewFacing = useCallback((dir) => {
const f = live.current.facing;
if (!f || f.piece.kind === 'item') return;
setPieceDir(view, f.uid, dir || pieceDir(f.piece));
}, [view]);
const commitFacing = useCallback(async (dir) => {
const f = live.current.facing;
if (!f) return;
setFacing(null);
const intent = facingIntent(f.piece, { row: f.row, col: f.col }, dir);
heldRef.current.set(f.uid, { row: f.row, col: f.col, t: Date.now() });
const ok = intent.t === 'g.art'
? await actions.art(intent.fields.itemUid, intent.fields.row, intent.fields.col, intent.fields.dir)
: await actions.move(intent.fields.uid, intent.fields.to, intent.fields.dir);
if (!ok) {
if (f.piece.kind !== 'item') setPieceDir(view, f.uid, pieceDir(f.piece));
releaseHold(f.uid);
return;
}
// accepted: the piece stays on the tile until m.private shows it there (or a short grace passes)
setTimeout(() => { if (heldRef.current.has(f.uid)) releaseHold(f.uid); }, 1500);
}, [view, releaseHold]);
// m.private caught up with a committed placement → stop holding it; a summon stack of several copies (凯瑟琳's 2
// devices, user playtest #6) stays in the hand and one copy — another piece — lands on the tile: release it too
useEffect(() => {
const splitLanded = (e, h) => e.piece.kind === 'token' && [...placeCtx.pieces.values()].some((x) => x.area === 'board' && x.piece.kind === 'token'
&& x.piece.id === e.piece.id && x.piece.uid !== e.piece.uid && x.row === h.row && x.col === h.col);
for (const [uid, h] of [...heldRef.current]) {
const e = placeCtx.pieces.get(uid);
if (!e || (e.area === 'board' && e.row === h.row && e.col === h.col) || (e.area !== 'board' && splitLanded(e, h))) releaseHold(uid);
}
}, [placeCtx]);
// the wheel closes when editing stops (ready, phase end, observing) or its piece is gone (merged, sold)
useEffect(() => {
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
}, [editable, placeCtx, facing]);
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
live.current.showPrep = showPrep;
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
// panel anyway (css z-index), this keeps it visible too
const ufTile = selEntry && editable && showPrep ? pieceTile(selEntry) : null;
const ufGeo = useTileScreen(view, ufTile ? ufTile.row : null, ufTile ? ufTile.col : null);
// the gold tile of an armed merge card (mergeAt): the detail card the first tap opened docks away from it (QA 6b — at
// 1920×1080 the left card covered half of a target tile in the leftmost legal column)
const mergeGeo = useTileScreen(view, mergeAt ? mergeAt.row : null, mergeAt ? mergeAt.col : null);
const retreatSel = useCallback(async () => {
const L = live.current;
const uid = L.sel?.uid;
const to = uid != null ? retreatSlot(L.placeCtx, uid) : null;
if (!to) { toast(t('整备区已满'), 'warn'); audio.sfx('error', { volume: 0.5 }); return; }
setSelBusy(true);
if (await actions.move(uid, to)) {
setSel(null);
setDetail((d) => (d?.kind === 'piece' && d.uid === uid ? null : d)); // its card would cover the bench
}
setSelBusy(false);
}, []);
const sellSel = useCallback(async () => {
const e = live.current.sel ? live.current.placeCtx.pieces.get(live.current.sel.uid) : null;
if (!e) return;
setSelBusy(true);
await sellPiece(e.piece);
setSelBusy(false);
}, []);
// unit clicks from the engine may only carry an id: resolve through m.field / spawn infos
const detailTarget = useMemo(() => {
if (detail?.kind !== 'unit' || detail.unit) return detail;
const u = (Array.isArray(field?.units) ? field.units : []).find((x) => x && x.id === detail.unitId);
return u ? { ...detail, unit: u } : detail;
}, [detail, field]);
const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces, { priv, backups: gd.backups }), [detailTarget, placeCtx, gd.ready, data.locale()]);
useEffect(() => { if (detail && !resolved && detail.kind === 'piece') setDetail(null); }, [resolved]);
const snapHp = (() => {
const id = resolved?.unitId;
const t = id != null ? snapUnitsRef.current.get(id) : null;
return t ? { hp: t[3], max: t[4] } : null;
})();
// a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
// is over — before the first snapshot of a battle nothing is judged)
useEffect(() => {
const id = detail?.kind === 'device' ? detail.device?.unitId : null;
const snap = snapUnitsRef.current;
if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
}, [hud, detail]);
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
// battle on screen: own, a teammate's, an enemy). Prep: an own board unit's stats at the start of its next battle,
// asked from the server (g.unitStats → m.unitStats; only the newest request's answer counts) whenever m.private or the
// phase changes while such a card is open — the last answer stays on show until the next one lands
const [unitStats, setUnitStats] = useState(null); // m.unitStats: { seq, round, units: Map }
const statsSeqRef = useRef(0);
useEffect(() => net.on('m.unitStats', (msg) => {
if (!msg || msg.seq !== statsSeqRef.current || !Array.isArray(msg.units)) return;
setUnitStats({ seq: msg.seq, round: msg.round, units: new Map(msg.units.filter((u) => u && Number.isInteger(u.uid)).map((u) => [u.uid, u])) });
}), []);
const prepStatsUid = (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && showPrep && alive
&& !!resolved?.piece && (resolved.type === 'chess' || resolved.type === 'token') && placeCtx.pieces.get(resolved.piece.uid)?.area === 'board'
? resolved.piece.uid : null;
useEffect(() => {
if (prepStatsUid == null) return undefined;
const t = setTimeout(() => {
statsSeqRef.current = (statsSeqRef.current % 1e9) + 1;
try { Promise.resolve(net.request('g.unitStats', { seq: statsSeqRef.current })).catch(() => {}); } catch { /* offline */ }
}, 120);
return () => clearTimeout(t);
}, [prepStatsUid, priv, phase]);
const liveStats = (() => {
if (prepStatsUid != null) {
const e = unitStats && unitStats.round === pub?.round ? unitStats.units.get(prepStatsUid) : null;
return e ? { ...e, src: 'prep' } : null;
}
// a battle unit's card, or an own board piece's card left open into the battle (its unit found by uid)
const id = resolved?.unitId;
const pieceUid = id == null && Number.isInteger(resolved?.piece?.uid) ? resolved.piece.uid : null;
if ((id == null && pieceUid == null) || !cc || !battleRunner || !field?.local || showPrep) return null;
const fid = field.fieldId;
return () => {
const uid = id ?? battleRunner.unitIdOf(pieceUid, myId, fid);
const e = uid != null ? battleRunner.unitStats(uid, fid) : null;
return e ? { ...e, src: 'battle' } : null;
};
})();
// ---- keyboard ---------------------------------------------------------------------------------------------
useEffect(() => {
const onKey = async (e) => {
const act = shortcutFor(e, settingsStore.get().keys); // the player's key map (设置 → 快捷键)
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
if (L.hasPersonalChoice) {
if (act !== 'escape') e.preventDefault();
if (act === 'ready') {
audio.sfx('error', { volume: 0.5 });
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
}
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
}
if (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false);
else if (L.bondOpen) setBondOpen(null);
else if (L.detail) setDetail(null);
else if (L.drawer) setDrawer(null);
else return;
e.preventDefault();
return;
}
// the ready key (Space unless rebound) pauses / resumes a solo battle (Space: the official battle key)
if (act === 'ready' && (L.canPause || L.paused)) {
e.preventDefault();
if (e.target instanceof HTMLElement && e.target.closest('button, [role="button"]')) e.target.blur();
togglePauseRef.current(!L.paused);
return;
}
if (L.pub?.phase !== PHASE.PREP || !L.priv) return;
e.preventDefault(); // a focused HUD button must not also activate (Space, or any key bound) — see shortcutFor
if (act === 'ready' && e.target instanceof HTMLElement && e.target.closest('button, [role="button"]')) e.target.blur();
if (act === 'ready') {
const refused = !L.priv.ready ? shopBlockReason('ready', { priv: L.priv, editable: true }) : null;
// the temp overflow row blocks it: say why (the button shows it too — user playtest #3 item 3)
if (refused) { audio.sfx('error', { volume: 0.5 }); toast(tempReadyReason(L.priv) || refused, 'warn'); return; }
toggleReady(!L.priv.ready);
return;
}
if (!L.editable) return;
if (act === 'retreat' || act === 'sell') {
if (!L.showPrep || L.drag || L.facing || L.selBusy || !L.sel) return;
const selected = L.placeCtx.pieces.get(L.sel.uid);
if (selected?.piece.kind !== 'chess') return;
const available = underframeActions(L.placeCtx, L.sel.uid);
if (act === 'retreat' && available?.retreat) await retreatSel();
else if (act === 'sell' && available?.sell != null) await sellSel();
return;
}
const reason = shopBlockReason(act, { priv: L.priv, editable: L.editable });
if (reason) { audio.sfx('error', { volume: 0.5 }); return; }
if (act === 'refresh') actions.refresh();
else if (act === 'freeze') actions.freeze();
else if (act === 'levelUp') actions.levelUp();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// ---- render ---------------------------------------------------------------------------------------------------
const readyCount = players.filter((p) => p.ready || p.status === 'ready').length;
const aliveCount = players.filter((p) => p.alive !== false && p.status !== 'left').length;
// the own battle is over: the server says so (status done) or — client-side combat — the local simulation just ended
const localDone = cc && !!battleState && battleState.own && !battleState.watch && battleState.done && battleState.fieldId === ownFieldId(myId);
// (client-side combat: only in 各自行动 — 联防 observers just watch the 联防 field, research 09 §3.1)
const myDone = combat && (cc ? phase === PHASE.COMBAT && (meP?.status === 'done' || localDone) : meP?.status === 'done');
live.current.localDone = localDone;
// the solo pause button: only while the own battle still runs (the server refuses it afterwards)
const canPause = pauseAvailable(pub, { solo, alive, done: meP?.status === 'done' || localDone });
live.current.canPause = canPause;
// client-side combat: observing a teammate's battle (research 09 §3.1) and the 联防 / 最终攻势 camera halves
// (an eliminated player auto-observes a teammate's normal field — research 09 "keep-watching" — without asking)
const watchedFid = watchingOther ? watching : (cc && combat && !alive && battleState && battleState.watch && battleState.kind === 'normal' ? battleState.fieldId : null);
// the ‹ › pill: on the 联防 / 最终攻势 field on screen — also one watched with 前往查看 (a leaker, an eliminated spectator)
const layers = cc && combat && field && field.local && (!watchingOther || field.fieldId === watching) ? cameraLayers(field, pub, myId) : [];
const progress = cc && phase === PHASE.COMBAT ? teammateProgress(pub, myId) : null;
// the bond strip follows the player on screen (DESIGN §20.15, ui/watchBonds.js): a teammate's board / battle (前往查看,
// an eliminated player's auto-observed field) → their bonds; a 联防 / 最终攻势 field → the player on the ‹ › half (全景:
// yours when you fight there, else the teammate picked with 前往查看 / the field's first player); in battle with the
// live layers of the battle on screen (the runner's bondLayers)
const settleMode = mode === 'settle';
const strip = screenStrip({
pub, priv, myId, combat, settle: settleMode, watchingOther, watching, home,
battleFieldId: cc ? (battleState?.fieldId || null) : (combat || settleMode ? lastFieldRef.current : null),
field, layers, layer, who: watchWho, bondLayers: battleState?.bondLayers || null,
});
const stripFid = strip.fieldId;
const liveLayers = (combat || settleMode) && battleState?.bondLayers ? battleState.bondLayers : null;
// the observing pill names the player whose bonds the strip shows (the same teammate as the strip's "👁 name" tag)
const observingName = cc && combat && watchedFid ? (!strip.self && stripFid === watchedFid ? strip.name : (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || t('队友'))) : null;
const watchedP = watchingOther ? (players.find((p) => p.playerId !== myId && (watching === ownFieldId(p.playerId) || (watching === p.fieldId && String(watching).startsWith('n:')))) || null) : null;
const watchedName = watchingOther ? (watchedP?.name || players.find((p) => watching === p.fieldId)?.name || t('队友')) : null;
const stripBonds = strip.bonds;
// a popup opened from the strip closes when the strip changes hands (another teammate scouted / a ‹ › half / back to
// the own bonds); one opened from a card's chip keeps its unit owner (it carries its own player either way)
const stripOwnerRef = useRef(strip.ownerId);
useEffect(() => {
if (stripOwnerRef.current === strip.ownerId) return;
stripOwnerRef.current = strip.ownerId;
setBondOpen((b) => (b && b.from === 'strip' ? null : b));
}, [strip.ownerId]);
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
const watchingNow = combat ? (watching || field?.fieldId || home) : watching;
const shopOpen = showShop && !collapsed;
const ufShown = !!(selEntry && editable && !facing && !drag && showPrep && ufGeo);
// the temp overflow row holds pieces (user playtest #3 item 3): framed and labelled on the own prep board
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
let rem = 100;
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* default */ }
let b = null;
try { b = hudElRef.current?.getBoundingClientRect() || null; } catch { b = null; }
const vp = b && b.width > 0 && b.height > 0 ? { left: b.left, top: b.top, width: b.width, height: b.height }
: { left: 0, top: 0, width: globalThis.innerWidth || 1920, height: globalThis.innerHeight || 1080 };
return { rem, vp };
};
const dSide = (() => {
if (ufShown) {
const { rem, vp } = panelFrame();
return panelSide(underframeRect(ufGeo, rem), panelSlots(vp, rem, { shopOpen }));
}
if (mergeGeo && resolved) {
const { rem, vp } = panelFrame();
const half = (mergeGeo.s > 0 ? mergeGeo.s : 64) * 1.05; // the tile diamond's box (underframeRect's)
return panelSide({ left: mergeGeo.x - half, right: mergeGeo.x + half, top: mergeGeo.y - half, bottom: mergeGeo.y + half }, panelSlots(vp, rem, { shopOpen }));
}
return 'left';
})();
// the bond popup: next to the card, never over the selected unit's underframe when a place is free (bondPopupPlace)
const bpPlace = (() => {
if (!bondOpen) return 'left';
const { rem, vp } = panelFrame();
return bondPopupPlace(ufShown ? underframeRect(ufGeo, rem) : null, vp, rem, resolved ? dSide : null);
})();
// the player's own operators (and the shop / bond member cards) show the loadout's skill / module (DESIGN §16);
// a teammate's unit (前往查看 in prep, 联防 / 最终攻势 fields, an observed battle) shows ITS owner's choice, carried by
// the unit (UnitInfo skillIndex / moduleId: the sim's, or Match.prepFieldMeta's) — never the viewer's loadout
const detailLoadout = (() => {
if (!resolved || resolved.type !== 'chess') return null;
if (detailTarget?.kind === 'unit') {
const u = detailTarget.unit;
const owner = u?.ownerId;
if (resolved.piece || (owner != null && owner === myId)) return priv?.loadout ?? null;
return unitLoadout(resolved.chess, u);
}
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
// draw their own
const cardRange = !drag && !facing && !pen ? inspectRange({
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
snapshot: snapUnitsRef.current.get(resolved?.unitId),
live: typeof liveStats === 'function' ? liveStats() : liveStats,
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
}) : null;
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
useEffect(() => {
if (!view) return undefined;
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, cardRangeKey, showPrep, field]);
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
// cards read the strip's (ui/watchBonds.js detailBondOwner)
const detailOwner = detailBondOwner(detailTarget, { pub, myId, stripOwnerId: strip.ownerId });
const detailBonds = detailOwner === strip.ownerId ? stripBonds : playerBonds({ pub, priv, myId, ownerId: detailOwner, live: liveLayers });
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId));
return html`