Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
@@ -3,15 +3,19 @@
|
||||
// for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked
|
||||
// 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right =
|
||||
// detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择.
|
||||
// A timeout assigns 「华法琳」, or — when a teammate already holds it — the first free strategy (timeoutBand; the tip
|
||||
// under the order list names the one I would get). Solo: free pick, no timer.
|
||||
// One countdown (user playtest #4 item 4): every turn has the same clock (Match BAND_TURN_SECONDS, m.public.draft
|
||||
// turnSeconds) and the step header counts it down — m.public.deadline IS the turn's end, the same number as the
|
||||
// current picker's row. The highlighted band (the detail pane's) is what a turn that runs out takes: every change of it
|
||||
// is reported (g.bandFocus) and the server assigns it while it is free, else 「华法琳」, else the first free strategy
|
||||
// (timeoutBand). It starts on that default, so the tip under the order list always names what a timeout gives.
|
||||
// Solo, and a co-op match with a single human (the server's soloUntimed: draft.untimed): no clock at all.
|
||||
|
||||
import { useEffect, useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
|
||||
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
|
||||
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { normalizeDraft, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
|
||||
import { actions, act } from '../ui/gameActions.js';
|
||||
import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { audio } from '../audio.js';
|
||||
|
||||
@@ -64,19 +68,62 @@ export function teammateBands(picks, myId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The band the detail pane shows: the current one, else my pick, else the first band no teammate took. When it is my
|
||||
* turn and the shown band was taken meanwhile (队友已选), the first free one instead (confirm would be disabled).
|
||||
* The band the detail pane shows (= the highlighted band a turn that runs out takes): the current one, else my pick,
|
||||
* else the band a timeout would give me (timeoutBand: 「华法琳」 while free, else the first free one). When it is my
|
||||
* turn and the shown band was taken meanwhile (队友已选), that default instead (confirm would be disabled).
|
||||
* @param {string|null} sel
|
||||
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean }} o
|
||||
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean, defaultId?: string }} o
|
||||
*/
|
||||
export function draftSelection(sel, { bands, taken, myPick, myTurn }) {
|
||||
export function draftSelection(sel, { bands, taken, myPick, myTurn, defaultId = DEFAULT_TIMEOUT_BAND }) {
|
||||
if (!Array.isArray(bands) || !bands.length) return sel;
|
||||
const free = (bands.find((b) => !taken.has(b.bandId)) || bands[0]).bandId;
|
||||
const free = timeoutBand(bands, taken, defaultId) || bands[0].bandId;
|
||||
if (!sel) return myPick || free;
|
||||
if (!myPick && myTurn && taken.has(sel)) return free;
|
||||
return sel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy a turn that runs out assigns me (server Match.timeoutBand): the highlighted band while it is one of
|
||||
* the mode's and no teammate holds it, else timeoutBand. Null after my pick.
|
||||
* @param {string|null} sel the highlighted band
|
||||
* @param {{ bands: any[], taken: Map<string, any>, myPick?: string|null, defaultId?: string }} o
|
||||
*/
|
||||
export function autoPickBand(sel, { bands, taken, myPick = null, defaultId = DEFAULT_TIMEOUT_BAND }) {
|
||||
if (myPick) return null;
|
||||
const list = Array.isArray(bands) ? bands : [];
|
||||
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
|
||||
if (sel && !has(sel) && list.some((b) => b.bandId === sel)) return sel;
|
||||
return timeoutBand(list, taken, defaultId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tip under the co-op draft order: the one skip, the turn clock and what a turn that runs out assigns me (the
|
||||
* highlighted band while free — autoPickBand). Untimed drafts (a single human) name no clock.
|
||||
* @param {{ timed: boolean, turnSeconds?: number|null, autoName?: string|null, selected?: boolean }} o
|
||||
* selected: the auto pick is the highlighted band (not the default standing in for a band a teammate holds)
|
||||
*/
|
||||
export function draftTip({ timed, turnSeconds = null, autoName = null, selected = true }) {
|
||||
const skip = '联合模拟在选择策略时可以进行一次跳过';
|
||||
if (!timed) return `${skip};本局不限时`;
|
||||
const clock = Number(turnSeconds) > 0 ? `每位博士有 ${Math.round(turnSeconds)} 秒` : '每位博士限时决策';
|
||||
if (!autoName) return `${skip};${clock}`;
|
||||
return `${skip};${clock},超时将自动选择${selected ? '当前选中的' : ''}「${autoName}」`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step header's countdown during the draft: the current turn's (m.public.deadline = draft.turnDeadline) with a
|
||||
* turn's length as the gauge total — null when the draft is untimed.
|
||||
* @param {any} pub m.public
|
||||
* @returns {{ deadline: number, total: number|null } | null}
|
||||
*/
|
||||
export function draftClock(pub) {
|
||||
const d = pub && typeof pub.draft === 'object' ? pub.draft : null;
|
||||
if (!d || d.untimed) return null;
|
||||
const deadline = Number(pub.deadline) > 0 ? Number(pub.deadline) : Number(d.turnDeadline) || 0;
|
||||
if (!(deadline > 0)) return null;
|
||||
return { deadline, total: Number(d.turnSeconds) > 0 ? Number(d.turnSeconds) : null };
|
||||
}
|
||||
|
||||
/** BAND_DRAFT screen. */
|
||||
export function BandDraftScreen() {
|
||||
const pub = useStore((s) => s.match.public);
|
||||
@@ -106,21 +153,31 @@ export function BandDraftScreen() {
|
||||
pickers.get(bid).push(p || { playerId: pid, name: '?' });
|
||||
}
|
||||
|
||||
// default selection: my pick, else the first band nobody else took; when my turn comes while the selected band has
|
||||
// been taken by a teammate meanwhile (队友已选), move the selection to the first free one
|
||||
// default selection: my pick, else what a timeout gives me (华法琳 while free); when my turn comes while the selected
|
||||
// band has been taken by a teammate meanwhile (队友已选), move the selection back to that default
|
||||
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
|
||||
const takenKey = [...taken.keys()].sort().join(',');
|
||||
useEffect(() => {
|
||||
const next = draftSelection(sel, { bands, taken, myPick, myTurn });
|
||||
const next = draftSelection(sel, { bands, taken, myPick, myTurn, defaultId });
|
||||
if (next !== sel) setSel(next);
|
||||
}, [bands.length, myPick, myTurn, takenKey]);
|
||||
// "your turn" cue
|
||||
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
|
||||
|
||||
// what a timeout gives me (the server never assigns a strategy a teammate holds — Match.js defaultBand)
|
||||
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
|
||||
const autoId = myPick ? null : timeoutBand(bands, taken, defaultId);
|
||||
const defaultName = gd.band(defaultId)?.name || '华法琳';
|
||||
const autoName = (autoId && gd.band(autoId)?.name) || defaultName;
|
||||
// one countdown (user playtest #4 item 4): the current turn's — m.public.deadline, the same clock as the picker's row
|
||||
const clock = solo ? null : draftClock(pub);
|
||||
// the highlighted band is what a turn that runs out takes (Match.timeoutBand): report every change before my pick
|
||||
const timed = !solo && !!pub?.draft && !pub.draft.untimed;
|
||||
const focusSent = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!timed || myPick || !sel || focusSent.current === sel) return;
|
||||
focusSent.current = sel;
|
||||
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
|
||||
}, [sel, timed, myPick]);
|
||||
|
||||
// what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — Match.js timeoutBand)
|
||||
const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId });
|
||||
const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳';
|
||||
|
||||
const band = sel ? gd.band(sel) : null;
|
||||
const selTaken = !!band && taken.has(band.bandId);
|
||||
@@ -137,14 +194,15 @@ export function BandDraftScreen() {
|
||||
setBusy(null);
|
||||
};
|
||||
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
|
||||
const turnDeadline = Number(pub?.draft?.turnDeadline) || 0;
|
||||
useTicker(!solo && turnDeadline > 0 ? 250 : 0);
|
||||
const turnSecs = !solo && turnDeadline > 0 ? secondsLeft(turnDeadline) : null;
|
||||
useTicker(clock ? 250 : 0);
|
||||
// the picker's row shows the step header's number (both read the one turn deadline)
|
||||
const turnSecs = clock ? secondsLeft(clock.deadline) : null;
|
||||
const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null;
|
||||
|
||||
return html`<div class="screen draft">
|
||||
<div class="brief__bg" aria-hidden="true"></div>
|
||||
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${pub}
|
||||
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
|
||||
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }}
|
||||
total=${clock ? clock.total : null} onExit=${() => setExit(true)} />
|
||||
<main class="draft__main">
|
||||
<aside class="draft-order">
|
||||
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
|
||||
@@ -168,7 +226,7 @@ export function BandDraftScreen() {
|
||||
</span>
|
||||
</div>`;
|
||||
})}
|
||||
${!solo ? html`<p class="draft-order__tip">联合模拟在选择策略时可以进行一次跳过;超时将自动选择「${autoName}」${autoId && autoId !== defaultId ? `(「${defaultName}」已被队友选择)` : ''}</p>` : null}
|
||||
${!solo ? html`<p class="draft-order__tip" data-testid="draft-tip">${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}</p>` : null}
|
||||
</aside>
|
||||
|
||||
<section class="draft-grid" role="listbox" aria-label="策略">
|
||||
|
||||
+56
-79
@@ -39,6 +39,9 @@
|
||||
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); 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).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
@@ -70,7 +73,7 @@ import { pieceTile } from '../render/drag.js';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, pickPieceAt, equipRetarget, dropFailureReason,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
@@ -312,8 +315,6 @@ function MatchScreen() {
|
||||
}, [view, ownView, ownStage, baseStage]);
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
// drop target adjustment while dragging (equipment released over an operator's sprite → that operator)
|
||||
const retargetRef = useRef((uid, target) => target);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
@@ -331,7 +332,7 @@ function MatchScreen() {
|
||||
setHud(null);
|
||||
}
|
||||
if (priv) {
|
||||
view.setPrep(priv, { editable, canPlace: (uid, target) => canPlace(live.current.placeCtx, uid, retargetRef.current(uid, target)).ok });
|
||||
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);
|
||||
}
|
||||
@@ -590,46 +591,10 @@ function MatchScreen() {
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
let moveOff = null;
|
||||
// `over`: the render engine's own pick of the unit under the dragged item (pieceDragOver / pieceDrop `over`, the
|
||||
// shared rule of render/pick.js at the plate) — undefined while the view does not report it (DOM fallback)
|
||||
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null, over: undefined };
|
||||
const insideBar = (x, y) => {
|
||||
const el = barRef.current;
|
||||
if (!el || !Number.isFinite(x)) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
};
|
||||
// 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: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
|
||||
// own operator / summon under the dragged item (not the dragged piece): the engine's report, else the pieces'
|
||||
// screen shapes at the pointer (ui/gameLogic.js pickPieceAt — the same rule; the tile under the pointer decides
|
||||
// where no face / torso is hit)
|
||||
const unitUnderPointer = (uid) => {
|
||||
if (ptr.over !== undefined) return ptr.over === uid ? null : ptr.over;
|
||||
const L = live.current;
|
||||
if (typeof view.pieceScreenRect !== 'function' || !L.placeCtx || !Number.isFinite(ptr.x)) return null;
|
||||
const rects = [];
|
||||
for (const en of L.placeCtx.pieces.values()) {
|
||||
if (en.piece.uid === uid || (en.area !== 'board' && en.area !== 'hand') || (en.piece.kind !== 'chess' && en.piece.kind !== 'token')) continue;
|
||||
let r = null;
|
||||
try { r = view.pieceScreenRect(en.piece.uid); } catch { r = null; }
|
||||
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom, shape: r.shape || null, tile: pieceTile(en) });
|
||||
}
|
||||
const at = ptr.tile && Number.isInteger(ptr.tile.row) ? { row: ptr.tile.row, col: ptr.tile.col } : null;
|
||||
return pickPieceAt(rects, ptr.x, ptr.y, at);
|
||||
};
|
||||
const retarget = (uid, target) => {
|
||||
const L = live.current;
|
||||
if (L.placeCtx?.pieces.get(uid)?.piece.kind !== 'item' || insideBar(ptr.x, ptr.y)) return target;
|
||||
return equipRetarget(L.placeCtx, uid, target, unitUnderPointer(uid));
|
||||
};
|
||||
retargetRef.current = retarget;
|
||||
/** Light the tile of the operator a dragged item would equip (the drop target highlight follows the plate). */
|
||||
const markItemTarget = (uid) => {
|
||||
const t = retarget(uid, null);
|
||||
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
|
||||
const key = JSON.stringify(unit);
|
||||
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
|
||||
};
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
@@ -654,7 +619,6 @@ function MatchScreen() {
|
||||
};
|
||||
const endDrag = () => {
|
||||
moveOff?.(); moveOff = null;
|
||||
ptr.over = undefined; // (a drop / drag end hands its own `over` in again)
|
||||
setDrag(null);
|
||||
view.highlightTiles(null, null);
|
||||
};
|
||||
@@ -680,36 +644,20 @@ function MatchScreen() {
|
||||
const { legal } = boardTargets(L.placeCtx, entry.piece.uid);
|
||||
view.highlightTiles(null, null);
|
||||
view.highlightTiles(legal, 'legal');
|
||||
ptr.released = false; ptr.tile = null; ptr.unit = null; ptr.over = undefined;
|
||||
const isItem = entry.piece.kind === 'item';
|
||||
// capture phase: the pointer position is current before the drag controller (canvas listener) asks canPlace
|
||||
const onMove = (ev) => {
|
||||
ptr.x = ev.clientX; ptr.y = ev.clientY;
|
||||
if (isItem && ptr.over === undefined) markItemTarget(entry.piece.uid); // the engine reports via pieceDragOver
|
||||
};
|
||||
const onUp = (ev) => { ptr.x = ev.clientX; ptr.y = ev.clientY; ptr.released = true; };
|
||||
window.addEventListener('pointermove', onMove, { passive: true, capture: true });
|
||||
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('pointermove', onMove, { capture: true });
|
||||
window.removeEventListener('pointerup', onUp, { capture: true });
|
||||
};
|
||||
moveOff = () => window.removeEventListener('pointerup', onUp, { capture: true });
|
||||
}),
|
||||
view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }),
|
||||
// the render engine's pick of the unit under a dragged item (sent before the drag asks canPlace)
|
||||
view.on('pieceDragOver', (e) => {
|
||||
if (!e || !live.current.placeCtx?.pieces.get(e.uid)) return;
|
||||
ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
markItemTarget(e.uid);
|
||||
}),
|
||||
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;
|
||||
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
const t = retarget(e.uid, e.target || {});
|
||||
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; }
|
||||
@@ -720,8 +668,9 @@ function MatchScreen() {
|
||||
}
|
||||
await runIntent(intent);
|
||||
}),
|
||||
view.on('pieceDragEnd', async (e) => {
|
||||
// a cancelled drag (no pieceDrop) must not leave the sell zone / highlights behind
|
||||
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();
|
||||
@@ -732,17 +681,6 @@ function MatchScreen() {
|
||||
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
|
||||
// released where the drag controller found no target: equipment over an operator's sprite still equips it,
|
||||
// anything else says why it was refused
|
||||
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
|
||||
const t = retarget(e.uid, null);
|
||||
if (t) {
|
||||
const res = canPlace(L.placeCtx, e.uid, t);
|
||||
if (needsFacing(L.placeCtx, e.uid, t, res)) { openFacing(src, t); return; }
|
||||
const intent = dropIntent(L.placeCtx, e.uid, t);
|
||||
if (intent) { await runIntent(intent); return; }
|
||||
if (res.code && res.code !== 'ALREADY') { refuse(res.reason); return; }
|
||||
}
|
||||
const reason = dropFailureReason(L.placeCtx, e.uid, tile);
|
||||
if (reason) refuse(reason);
|
||||
}),
|
||||
@@ -923,6 +861,45 @@ function MatchScreen() {
|
||||
return t ? { hp: t[3], max: t[4] } : null;
|
||||
})();
|
||||
|
||||
// ---- 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<uid, entry> }
|
||||
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) => {
|
||||
@@ -1102,7 +1079,7 @@ function MatchScreen() {
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id })} />` : null}
|
||||
|
||||
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
||||
bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen}
|
||||
bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
|
||||
onBond=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />` : null}
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
|
||||
Reference in new issue
Block a user