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

+1 -1
View File
@@ -74,7 +74,7 @@
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
+1 -1
View File
@@ -46,7 +46,7 @@ Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立
A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
+1 -1
View File
@@ -33,7 +33,7 @@ S→C:
interpolate linearly; nothing is inferred from the attack animation.
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5)
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5)
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).
+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) ----------------------------------------------------------------------------------
+5 -3
View File
@@ -282,13 +282,14 @@ const statView = (x) => ({
* runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid
* (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included, not a kit's
* target-selection grid (the sim's `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌
* S3's 4-11 never reached the card).
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any } | null} u
* S3's 4-11 never reached the card). `dir`: the unit's facing now (UP / RIGHT / DOWN / LEFT; UnitInfo.dir is the facing
* at send time and the snapshot tuples carry none) — the detail card's range overlay rotates by it (GitHub PR #281).
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any, dir?: string } | null} u
* @param {any} [s] aggregated stats (missing ⇒ the base)
* @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number,
* def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number,
* base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number },
* range?: Array<[number, number]> }}
* range?: Array<[number, number]>, dir?: string }}
*/
export function unitStatsEntry(u, s = null) {
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
@@ -305,6 +306,7 @@ export function unitStatsEntry(u, s = null) {
...statView(cur),
base: statView(base),
...(range ? { range } : {}),
...(isDir(u?.dir) ? { dir: u.dir } : {}),
// the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced
silenced: !!(cur.flags && cur.flags.silence),
};
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { startServer } from '../../server/index.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { spawnMapChar } from '../../server/sim/content/tokens.js';
const chrome = process.env.CHROME_PATH;
const enabled = process.env.SP_E2E === '1' && chrome && existsSync(chrome);
test('user Given a real battle with strategy medics When clicking units and closing details Then attack tiles follow selection',
{ skip: !enabled && 'Set SP_E2E=1 and CHROME_PATH', timeout: 60000 }, async () => {
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const puppeteer = (await import('puppeteer-core')).default;
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'] });
try {
for (const medicId of ['char_613_acmedc', 'char_605_cmedic']) {
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], autoFinish: false });
h.step();
assert.ok(spawnMapChar(h.b, h.b.players[0].playerId, medicId));
h.step();
const field = h.b.fieldMeta();
const snapshot = h.b.snapshot();
assert.equal(field.units.length, 2);
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.port}/dev/game-mock.html?render=fallback&shot=1&phase=PREP`);
await page.waitForSelector('.ff-piece');
await page.evaluate(async ({ field, snapshot }) => {
const { store } = await import('/js/store.js');
const { net } = await import('/js/net.js');
const pub = store.get().match.public;
store.patch('match', { public: { ...pub, phase: 'COMBAT', fields: [{ fieldId: field.fieldId, players: [pub.players[0].playerId] }] }, field });
// The local runner emits the snapshot synchronously, before the screen's field-entry effect.
net._emit('b.snap', snapshot);
}, { field, snapshot });
await page.waitForSelector('.ff-unit');
for (let i = 0; i < field.units.length; i++) {
const units = await page.$$('.ff-unit');
await units[i].click();
await page.waitForSelector('.dpanel');
await page.waitForSelector('.ff-tile.is-range');
const lit = await page.$$eval('.ff-tile.is-range', (els) => els.map((e) => [Number(e.dataset.row), Number(e.dataset.col)]));
const u = field.units[i];
if (u.defId === medicId) {
assert.ok(lit.some(([r, c]) => r === 10 && c === 5), 'medic reaches three columns right from the stage tile');
assert.ok(!lit.some(([, c]) => c === 6), 'inactive skill does not extend the base range');
}
// A new wire snapshot must refresh the open overlay, not just the unit sprite.
await page.evaluate(async ({ snapshot, id }) => {
const { net } = await import('/js/net.js');
net._emit('b.snap', { ...snapshot, units: snapshot.units.map((v) => v[0] === id ? [v[0], v[1] + 1, ...v.slice(2)] : v) });
}, { snapshot, id: u.id });
await page.waitForFunction((before) => {
const now = [...document.querySelectorAll('.ff-tile.is-range')].map((e) => [Number(e.dataset.row), Number(e.dataset.col)]);
return now.length === before.length && now.every(([r, c]) => before.some(([br, bc]) => br === r && bc + 1 === c));
}, {}, lit);
await page.evaluate(async () => {
const { store } = await import('/js/store.js');
store.patch('match', { public: { ...store.get().match.public, phase: 'SETTLE' } });
// Allow Preact's after-paint phase effects to run before asserting retained UI.
await new Promise((resolve) => setTimeout(resolve, 250));
});
assert.ok(await page.$('.dpanel'), 'same-field settlement keeps the inspected card');
assert.ok(await page.$('.ff-tile.is-range'), 'same-field settlement keeps its range');
await page.click('.dpanel__close');
await page.waitForFunction(() => !document.querySelector('.ff-tile.is-range'));
await page.evaluate(async (snapshot) => {
const { net } = await import('/js/net.js');
net._emit('b.snap', snapshot);
}, snapshot);
}
await (await page.$$('.ff-unit'))[0].click();
await page.waitForSelector('.ff-tile.is-range');
await page.evaluate(async ({ field, snapshot }) => {
const { store } = await import('/js/store.js');
const { net } = await import('/js/net.js');
const fieldId = `${field.fieldId}:next`;
store.patch('match', { field: { ...field, fieldId } });
net._emit('b.snap', { ...snapshot, fieldId });
}, { field, snapshot });
await page.waitForFunction(() => !document.querySelector('.dpanel') && !document.querySelector('.ff-tile.is-range'));
assert.deepEqual(errors, []);
await page.close();
}
} finally {
await browser.close();
await server.close();
}
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { inspectRange } from '../../public/js/screens/game/range.js';
import { renderInfo } from '../../public/js/render/app/info.js';
import { rangeTiles } from '../../public/js/ui/facing.js';
import { unitStatsEntry } from '../../shared/protocol.js';
import { makeBattle } from '../helpers/battleHarness.js';
const read = (name) => JSON.parse(readFileSync(new URL(`../../data/${name}.json`, import.meta.url), 'utf8'));
const chess = read('chess'), tokens = read('tokens');
const getChess = (id) => chess[id];
const medic = (id = 'char_613_acmedc', extra = {}) => renderInfo({ id: 71, defId: id, kind: 'token', side: 'ally', x: 5, y: 9, dir: 'UP', ...extra });
const args = (unit, extra = {}) => ({ target: { kind: 'unit', unit }, detail: { type: 'token', token: tokens[unit.defId] },
getChess, field: { prep: true }, ...extra });
test('user Given Jessica inspected beside her shield When it expires Then her open range turns back with her', () => {
const h = makeBattle({ autoFinish: false, units: [
{ uid: 1, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_1034_jesca2', skillIndex: 0 }, row: 10, col: 4 },
{ uid: 2, kind: 'token', tokenId: 'token_10032_jesca2_jckshd', ownerUid: 1, row: 11, col: 4 },
] });
h.step();
const u = h.unit(1);
const unit = h.b.fieldMeta().units.find((v) => v.id === u.id);
const input = { target: { kind: 'unit', unit }, detail: { type: 'chess', chess: u.def.raw, diy: true }, getChess, field: {} };
const tiles = () => {
const r = inspectRange({ ...input, live: unitStatsEntry(u, u.s), snapshot: h.b.snapshot().units.find((v) => v[0] === u.id) });
return rangeTiles(r.grid, r.row, r.col, r.dir);
};
assert.deepEqual(tiles(), [[10, 4], [11, 4], [12, 4]]);
h.b.retreat(h.unit(2), { reason: 'expired', permanent: true });
h.run(0.5);
assert.deepEqual(tiles(), [[10, 4], [10, 5], [10, 6]]);
});
test('user Given strategy medics When inspecting them Then their healing tiles rotate on the field', () => {
for (const id of ['char_613_acmedc', 'char_605_cmedic']) {
const r = inspectRange(args(medic(id)));
assert.equal(r.grid.length, 12);
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
assert.equal(tiles.length, 12);
assert.ok(tiles.some(([row, col]) => row === 12 && col === 4));
assert.ok(!tiles.some(([row]) => row === 8));
}
});
test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => {
const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } }));
assert.equal(r.grid.length, 18);
assert.deepEqual([r.row, r.col, r.dir], [10, 8, 'UP']);
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
assert.ok(tiles.some(([row, col]) => row === 15 && col === 9));
assert.ok(!tiles.some(([, col]) => col === 5));
});
test('user Given an enemy, scouted hand or absent battle unit When inspecting Then no attack tiles appear', () => {
for (const extra of [{ side: 'enemy' }, { area: 'hand' }, { area: 'temp' }, { kind: 'device' }]) {
assert.equal(inspectRange(args(medic(undefined, extra))), null);
}
assert.equal(inspectRange(args(medic(), { field: { prep: false } })), null);
assert.equal(inspectRange(args(medic(), { field: {}, snapshot: [71, 5, 9, 0, 200] })), null);
assert.equal(inspectRange(args(medic(), { showPrep: true })), null);
assert.equal(inspectRange(args(medic(), { live: { range: [] } })), null);
});
test('user Given an own board operator When its details open Then its range uses the board direction and clears off board', () => {
const piece = { kind: 'chess', id: 'chess_char_1_01_a', uid: 5, dir: 'LEFT' };
const input = { target: { kind: 'piece', uid: 5 }, detail: { type: 'chess', chess: chess[piece.id], piece },
pieces: new Map([[5, { piece, area: 'board', row: 11, col: 2 }]]), showPrep: true, getChess };
const r = inspectRange(input);
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
assert.ok(tiles.every(([, col]) => col >= 0 && col <= 2));
assert.ok(tiles.some(([, col]) => col === 0));
assert.equal(inspectRange({ ...input, pieces: new Map() }), null);
assert.equal(inspectRange({ ...input, target: { kind: 'chess', id: piece.id } }), null);
assert.equal(inspectRange({ ...input, showPrep: false }), null);
});
test('a scouted prep board (the server\'s m.field): the board operator draws its range, the bench (area hand / temp) none', async () => {
const { makeMatch, give, giveItem, legalTileFor, chessOfTier } = await import('../match/harness.js');
const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true }).start();
h.toPrep(1);
const m = h.m;
const b = h.ps('p_1');
m.handle('p_1', { t: 'g.ready', ready: false });
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) if (p.kind === 'chess') b.returnCopies(p);
b.board.clear(); b.hand.fill(null); b.temp.fill(null); b.recompute();
const [id1, id2, id3] = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
const deployed = give(m, b, id1);
const [r, c] = legalTileFor(m, b, id1);
m.handle('p_1', { t: 'g.move', uid: deployed.uid, to: { area: 'board', row: r, col: c }, dir: 'UP' });
const held = give(m, b, id2);
while (b.hand.some((x) => x == null)) giveItem(m, b, 'chess_item_6_09_e_a'); // a full hand keeps the temp piece in temp
const waiting = give(m, b, id3, 'temp');
m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' });
const field = h.lastTo('p_0', 'm.field');
const range = (piece) => {
const unit = renderInfo(field.units.find((u) => u.uid === piece.uid));
return inspectRange({ target: { kind: 'unit', unit }, detail: { type: 'chess', chess: chess[piece.id] }, field, getChess });
};
const own = range(deployed);
assert.deepEqual([own.row, own.col, own.dir], [r, c, 'UP']);
assert.equal(range(held), null, 'a held operator (area hand) has no field range');
assert.equal(range(waiting), null, 'nor one in the 临时整备区 (area temp)');
m.dispose();
});
+2 -1
View File
@@ -23,7 +23,8 @@ globalThis.fetch = async (url) => {
}
};
const { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js');
const { TokenDetail, summonDeployHint, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { tokenVariantFor } = await import('../../public/js/ui/gameLogic/loadout.js');
const { indexPieces } = await import('../../public/js/ui/gameLogic.js');
const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js');
const simTokens = await import('../../server/sim/content/tokens.js');