feedback7 R22-LATE: an open detail card draws its field unit's current attack range — own board pieces in prep, any ally operator / summon in battle or on a scouted board; golden: unchanged

GitHub PR #281 by @IceCodeNew (this commit is substantially the PR, ported onto 0.2.2-dev; the field-detail range
idea after @BBleae's fork commits c6437f1 / b451bad, credited here instead of the PR's Co-authored-by trailers). The
unofficial skill-range preview the PR first had is gone (the owner's reply of 2026-10-07: only official-like
behaviour); what remains is the range the unit fights with now.

screens/game/range.js inspectRange resolves the open card's unit: an own board piece in prep (its loadout or
stand-in record, its facing — this replaces the prep selection's range effect); in battle or on a scouted board an
ally op / token on the field, at the snapshot's tile, with the live stats' range when there is one; never an enemy,
a dead or undeployed unit, or a scouted bench unit (UnitInfo `area` hand / temp — the tag the server sends since the
#385 fix; renderInfo now keeps it). shared/protocol.js unitStatsEntry gains an optional `dir` (the unit's facing now:
the snapshot tuples carry none and UnitInfo.dir is the facing at send time). tokenVariantFor moves to
gameLogic/loadout.js. Drag, the wheel and the pen clear the overlay.

Added on top of the PR: a test from the server's own scout m.field (board operator ranged, hand / temp none), docs
(PLAYING.md, design/client.md, design/network.md), comments.
Tests: test/ui/inspect-range.test.js (6), test/ui/playtest6_summons.test.js, test/match/unitStats.test.js;
test/ui/inspect-range.e2e.test.js (SP_E2E + CHROME_PATH: run here, 1/1). Golden unchanged (display; m.unitStats only).
This commit is contained in:
IceCodeNew authored and sganggs committed 2026-10-08 21:27:33 +08:00
1 parent a1bc37ed90
commit 0ca2a9c21a
12 files changed
+274 -33

No files matched your search

+2
View File
@@ -41,6 +41,8 @@ export function renderInfo(u) {
// skill aura is drawn for it (render/fx/rings.js _aura)
ammoSkill: u.ammoSkill === true ? true : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
// Scouted hand and temp units have details but no field attack range.
area: typeof u.area === 'string' ? u.area : undefined,
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
// unit takes its items from the piece instead, so only other players' boards ever read this field
+22 -11
View File
@@ -117,6 +117,7 @@ import { useDocClass, FullscreenButton } from '../ui/device.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. */
@@ -477,6 +478,9 @@ function MatchScreen() {
}
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);
}
@@ -1045,10 +1049,6 @@ function MatchScreen() {
}, []);
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
const releaseHold = useCallback((uid) => {
heldRef.current.delete(uid);
@@ -1100,16 +1100,10 @@ function MatchScreen() {
useEffect(() => {
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
}, [editable, placeCtx, facing]);
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its 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]);
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
useEffect(() => {
if (!view || !selRangeKey) return undefined;
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, selRangeKey]);
// 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
@@ -1350,6 +1344,23 @@ function MatchScreen() {
}
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)
+32
View File
@@ -0,0 +1,32 @@
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
import { normDir } from '../../ui/facing.js';
/** Resolve the inspected field unit without granting placement or sale actions. */
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
let row, col, dir;
if (target?.kind === 'piece' && showPrep) {
const e = pieces?.get(target.uid);
if (!e || e.area !== 'board') return null;
row = e.row; col = e.col; dir = normDir(e.piece.dir);
} else if (target?.kind === 'unit' && !showPrep) {
const unit = target.unit;
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
if (unit.area != null && unit.area !== 'board') return null;
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(snapshot ? snapshot[2] : unit.y);
col = Math.round(snapshot ? snapshot[1] : unit.x);
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
} else return null;
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
return grid?.length ? { grid, row, col, dir } : null;
}
+1 -15
View File
@@ -46,6 +46,7 @@ import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge, fullTraitText } from './loadoutModel.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { audio } from '../audio.js';
import { tokenVariantFor } from './gameLogic/loadout.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -580,21 +581,6 @@ export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY)
: t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)');
}
/**
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
* @param {any} token tokens.json record
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
*/
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
function tokenOwnerId(piece, pieces) {
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
+9
View File
@@ -5,6 +5,15 @@ import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutR
import { isObj } from './shared.js';
import { t } from '../../../../shared/i18n.js';
/** The summon owner's variant: exact chess id, normal sibling, then the first available variant. */
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
// ---- operator loadout (DESIGN §16) ----------------------------------------------------------------------------------