feedback1 integrate: WP live skill range on the card; 烛煌 S3 target + 1.7 splash; 信仰搅拌机 S3 ignores 攻击距离

This commit is contained in:
sganggs committed 2026-10-03 07:00:41 +08:00
commit 313db2f55f
18 files changed
+917 -53

No files matched your search

+60 -9
View File
@@ -1,8 +1,12 @@
// shared/loadoutRecord.js — operator loadouts (DESIGN §16, DATA.md §2.2): a data/chess.json record as the selected
// skill / module make it. Pure ESM shared by the simulation (server/sim/simdata.js re-exports it: getChess(id, loadout)
// builds unit defs from it) and the client UI (the detail card shows the stats / 特性 / talents the unit fights with —
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait). One implementation, so
// the card and the battle never disagree. (Which choices a player may make: shared/protocol.js loadoutOptions.)
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait; attackRangeGrid = the range
// it is deployed with, also the board overlay and the deploy wheel — extendedGrid is the battle's own 攻击距离 growth,
// re-exported by server/sim/targeting.js). One implementation, so the card and the battle never disagree. (Which
// choices a player may make: shared/protocol.js loadoutOptions.)
import { GEO } from './constants.js';
/**
* Resolve a loadout against a chess record.
@@ -99,19 +103,66 @@ export function loadoutRecord(rec, lo) {
}
/**
* The attack range a (loadout-resolved) chess record fights with: an elite whose equipped module reads "攻击范围扩大"
* uses that module's own grid — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the
* centre tile [0,3] — as the kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`.
* The attack range a (loadout-resolved) chess record fights with from its deployment — the detail card without a live
* entry, the board's range overlay and the deploy wheel (DESIGN §16), the same tiles the battle unit starts with (prep
* m.unitStats `range`): the selected skill's grid when it reads "被动效果:攻击范围扩大" (引星棘刺 S3 3-9: her own range
* while she carries it, tier5 kit); else an elite whose equipped module reads "攻击范围扩大" uses that module's own grid
* — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the centre tile [0,3] — as the
* kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`. Then grown by the 特性's
* permanent 攻击距离 (traitRangeExtend: 信仰搅拌机 SPT-Y "攻击距离+1"). A running skill's range is the live entry's.
* @param {object|null} rec loadoutRecord(…) output (or a data/chess.json record: its default module)
* @returns {number[][]|null}
*/
export function attackRangeGrid(rec) {
if (!rec || typeof rec !== 'object') return null;
let g = Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null;
const sk = rec.skill;
const m = rec.module;
if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) {
if (sk && Array.isArray(sk.rangeGrid) && sk.rangeGrid.length && /被动效果:攻击范围扩大/.test(String(sk.desc ?? ''))) {
g = sk.rangeGrid;
} else if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) {
const mod = (Array.isArray(rec.modules) ? rec.modules : []).find((x) => x && x.uniEquipId === m.id);
const g = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
if (g) return g;
const mg = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
if (mg) g = mg;
}
return Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null;
const ext = traitRangeExtend(rec);
return g && ext ? extendedGrid(g, ext) : g;
}
/**
* The permanent 攻击距离 (ability_range_forward_extend) a record's 特性 grants — a module's, e.g. 信仰搅拌机 SPT-Y
* "攻击距离+1" (tier4 rangeUp: a persistent rangeExtend buff, s.baseRangeExtend); 0 for one that works "在集成战略中" only
* (空弦 ISW-A). The other 攻击距离 of the mode are skills' (their running range).
* @param {object|null} rec loadoutRecord(…) output
*/
export function traitRangeExtend(rec) {
const t = rec && typeof rec === 'object' ? rec.trait : null;
if (!t || typeof t !== 'object' || /集成战略/.test(String(t.moduleDesc ?? ''))) return 0;
const n = Math.floor(Number(t.bb?.ability_range_forward_extend) || 0);
return n > 0 ? n : 0;
}
/**
* A range grid (`[dRow, dCol]`, facing RIGHT) grown by `extend` (rangeExtend / 攻击距离, DESIGN §3): every row gains
* the whole tiles 1 … ⌊extend⌋ beyond its far (+dCol) end — the relative form of what server/sim/targeting.js
* absoluteRangeKeys builds, deduplicated, junk entries dropped. One implementation for the battle (re-exported by
* targeting.js: Battle._refreshRange keeps it as `unit.liveRangeGrid`, the card's live 攻击范围, when an extend applies)
* and the record's attackRangeGrid.
* @param {Array<[number, number]>|null|undefined} grid
* @param {number} [extend]
* @returns {Array<[number, number]>}
*/
export function extendedGrid(grid, extend = 0) {
const out = [];
const seen = new Set();
const add = (dr, dc) => { const k = `${dr},${dc}`; if (!seen.has(k)) { seen.add(k); out.push([dr, dc]); } };
if (!Array.isArray(grid)) return out;
const cells = grid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1]));
for (const [dr, dc] of cells) add(dr, dc);
if (extend > 0 && Number.isFinite(extend)) {
const maxByRow = new Map();
for (const [dr, dc] of cells) maxByRow.set(dr, Math.max(maxByRow.get(dr) ?? -Infinity, dc));
for (const [dr, mx] of maxByRow) for (let k = 1; k <= Math.min(extend, GEO.COLS); k++) add(dr, mx + k);
}
return out;
}
+11 -3
View File
@@ -193,16 +193,23 @@ const statView = (x) => ({
* or the last ones the sim computed) next to its own `unit.base` (no buffs) — max HP, ATK, DEF, RES, attack interval
* (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the
* `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser
* runner's live battle stats (public/js/battle/runner.js unitStats).
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any } | null} u
* 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
* @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 } }}
* base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number },
* range?: Array<[number, number]> }}
*/
export function unitStatsEntry(u, s = null) {
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
const cur = s && typeof s === 'object' ? s : base;
const range = u?.side !== 'enemy' && Array.isArray(u?.liveRangeGrid)
? u.liveRangeGrid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])).map((p) => [p[0], p[1]])
: null;
return {
id: Number.isInteger(u?.id) ? u.id : null,
uid: Number.isInteger(u?.uid) ? u.uid : null,
@@ -211,6 +218,7 @@ export function unitStatsEntry(u, s = null) {
alive: u?.alive !== false,
...statView(cur),
base: statView(base),
...(range ? { range } : {}),
};
}