fix: 准备阶段可查看外勤医疗详情与范围 (#315)

This commit is contained in:
YinqiMargatroid committed 2026-10-11 09:16:00 +08:00
1 parent e85942bce1
commit 69e27ba03d
10 files changed
+109 -36

No files matched your search

+6
View File
@@ -50,6 +50,12 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads
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 — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); 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).
The owner's `prepMapChars` (外勤医疗's 预备干员-医疗, or another displayed map-character variant) are read-only
preparation views: tapping one opens its existing operator-style detail card and highlights its attack range from
its board-space position and facing, including on a mirrored boss half. They cannot be dragged, sold or retreated
from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates
the selected variant at battle start (§29.15).
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.
---
+3 -2
View File
@@ -137,6 +137,7 @@ codes are UI metadata only, never authentication. No new network message or nick
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
Preparation state carries read-only `prepMapChars` for 外勤医疗. Boss-mate and scouting metadata include them in
displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
Preparation state carries read-only `prepMapChars` for 外勤医疗. The owner's entries can be clicked for a detail card
and board-space range preview without adding a placement intent or a combat entity. Boss-mate and scouting metadata
include them in displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
disables picking and the server checks again before consumption.
+7
View File
@@ -292,6 +292,13 @@ hooks and scheduled effects. [ASSUMED] If several players have prepared slots, t
existing player order. A map character spawned ad hoc after battle start keeps normal summon order. The real flying
守墓石像 tests check both variants at target-lock time, a right-side 联防 Touch, and the higher-taunt exception.
The owner's further [official preparation screenshot](https://b23.tv/4JXAp12) at 0:17 shows 预备干员-医疗 selected,
with its detail card and attack range visible before combat. This proves an inspection affordance, but does not
establish that the official game already simulates the medic as a combat entity. The client now lets the existing
read-only preparation view open its operator-style card and board-space range by tap, including on the mirrored boss
half and in the flat fallback renderer. It remains immovable and the battle creation point is unchanged. The same
click path accepts another map-character variant if one is ever included in `prepMapChars`.
`npm run golden:update` recomputed all 287 scenarios. Only `coop2-NORMAL-14-standins` changed: its human seat uses
`band_amedic`, and combat differences first appear in round 11. The match remains a round-14 victory with team LP 31;
that seat's final kills move 272 → 273 and leaks 44 → 43. Its boss damage moves 193,091 → 189,876, while the partner's
+29 -9
View File
@@ -265,6 +265,7 @@ export async function createFieldView(host, options = {}) {
let pendingView = null; // tile band/focus to apply when the camera transition ends
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
let prepPieces = []; // { uid, piece, area, idx, row, col, key }
let prepMapChars = []; // { unit (board space), display } — read-only, but inspectable
// the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
let promoBase = [];
@@ -804,16 +805,17 @@ export async function createFieldView(host, options = {}) {
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
}
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
// The 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece.
// Keep its original board-space record for the detail card/range; only the rendered view is transformed to the
// displayed boss half. The partner's units already use field coordinates and are not own prep map characters.
prepMapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
if (!u || !Number.isInteger(u.uid) || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
const p = prepXf.toDisp(u.y, u.x);
const dir = prepXf.dirToDisp(u.dir);
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
return { unit: u, display: { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 } };
}).filter(Boolean) : [];
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
syncReadOnlyPrepUnits([...mapChars, ...mate]);
syncReadOnlyPrepUnits([...prepMapChars.map((e) => e.display), ...mate]);
prepPieces = list.filter((e) => e.key && views.has(e.key));
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
@@ -821,8 +823,8 @@ export async function createFieldView(host, options = {}) {
}
/**
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not movable prep pieces;
* only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes.
*/
function syncReadOnlyPrepUnits(units) {
const keep = new Set();
@@ -1016,6 +1018,17 @@ export async function createFieldView(host, options = {}) {
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
}
/** The owner's read-only preparation map character under a canvas point (never a drag target). */
function mapCharAt(x, y) {
if (mode !== 'prep') return null;
const units = [];
for (const e of prepMapChars) {
const u = pickUnitOf(views.get(`m:${e.unit.uid}`));
if (u) { u.unit = e.unit; units.push(u); }
}
return pickOnTile(units, groundTile(x, y))?.unit || null;
}
const drag = createDragController({
hitPiece: (x, y) => pieceAt(x, y),
pickTile: (x, y) => pickBoardTile(x, y),
@@ -1218,6 +1231,12 @@ export async function createFieldView(host, options = {}) {
return;
}
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
const mapChar = mapCharAt(ev.x, ev.y);
if (mapChar) {
emit('pieceClick', { unit: mapChar, unitId: null, uid: mapChar.uid, prepMapChar: true,
button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY });
return;
}
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
if (penViews.size && mode === 'prep') {
const pv = penUnitAt(ev.x, ev.y);
@@ -1299,6 +1318,7 @@ export async function createFieldView(host, options = {}) {
clearViews();
if (prepPieces.length) promoBase = prepPieces;
prepPieces = [];
prepMapChars = [];
infos.clear();
gone.clear();
interp.reset();
@@ -1937,7 +1957,7 @@ export async function createFieldView(host, options = {}) {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
promotions,
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile },
},
};
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
+2 -2
View File
@@ -998,7 +998,7 @@ function MatchScreen() {
// 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 (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid, prepMapChar: e.prepMapChar === true }); 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
@@ -1398,7 +1398,7 @@ 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
// an own board piece or read-only prepared map character in prep; 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
+8 -5
View File
@@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field,
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) {
} else if (target?.kind === 'unit' && (!showPrep || target.prepMapChar === true)) {
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;
// A prepared map character is a read-only view, not a simulated unit yet. Its board-space coordinates come from
// m.private.prepMapChars and must not be read from a previous battle's snapshot or transformed twice on boss R.
if (showPrep && (unit.kind !== 'token' || detail.type !== 'token' || detail.token?.kind !== 'mapChar')) 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');
if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y);
col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x);
dir = (!showPrep && 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) }
+3 -1
View File
@@ -337,7 +337,9 @@ export function createFallbackView(host, opts = {}) {
const pos = tilePos(L, u.y, u.x);
const src = tokenAvatarUrl(m(), u.defId);
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}
onPointerDown=${(e) => { if (e.button === 0) emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY }); }}
title=${u.name || u.defId}>
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
</div>`);
+26 -8
View File
@@ -96,7 +96,7 @@ describe('render engine in headless Chrome', { skip }, () => {
} finally { await page.close(); }
});
test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => {
test('外勤医疗 reserve medic opens its detail from normal and right boss preparation without being draggable', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
const first = await page.evaluate(() => {
const v = window.__demo.view;
@@ -108,8 +108,14 @@ describe('render engine in headless Chrome', { skip }, () => {
v.setPrep(st, { editable: true });
const medic = v.debug.views.get('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
let clicked = null;
const off = v.on('pieceClick', (e) => { clicked = e; });
v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 61, clientX: p.x, clientY: p.y }));
off();
const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y,
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) };
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top),
clicked: clicked ? { uid: clicked.uid, id: clicked.unit?.defId, row: clicked.unit?.y, col: clicked.unit?.x,
prepMapChar: clicked.prepMapChar } : null };
return out;
});
await wait(500);
@@ -118,7 +124,13 @@ describe('render engine in headless Chrome', { skip }, () => {
const v = window.__demo.view;
v.setCamera('bossPrep', { side: 'R', instant: true });
const mirrored = v.debug.views.get('m:-1');
return [mirrored?.x, mirrored?.y, mirrored?.dir];
const p = v.tileScreen(10, 2);
let clicked = null;
const off = v.on('pieceClick', (e) => { clicked = e; });
v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 62, clientX: p.x, clientY: p.y }));
off();
return { view: [mirrored?.x, mirrored?.y, mirrored?.dir],
clicked: clicked ? [clicked.unit?.y, clicked.unit?.x, clicked.unit?.dir, clicked.prepMapChar] : null };
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') });
@@ -127,7 +139,8 @@ describe('render engine in headless Chrome', { skip }, () => {
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [];
v.setPrep(st, { editable: true });
return !v.debug.views.has('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
return !v.debug.views.has('m:-1') && !v.debug.pick.mapCharAt(p.x - rect.left, p.y - rect.top);
});
const fallback = await page.evaluate(async () => {
const { createFallbackView } = await import('/js/ui/fallbackField.js');
@@ -138,18 +151,23 @@ describe('render engine in headless Chrome', { skip }, () => {
ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const el = host.querySelector('.ff-piece--map-char');
let clicked = null;
const off = ff.on('pieceClick', (e) => { clicked = e; });
el?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 63 }));
off();
const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null,
movableId: el?.dataset.uid ?? null };
movableId: el?.dataset.uid ?? null, clicked: clicked ? [clicked.unit?.defId, clicked.prepMapChar] : null };
ff.destroy(); host.remove();
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
draggable: false });
assert.deepEqual(boss, [18, 3, 'LEFT']);
draggable: false, clicked: { uid: -1, id: 'char_605_cmedic', row: 10, col: 2, prepMapChar: true } });
assert.deepEqual(boss, { view: [18, 3, 'LEFT'], clicked: [10, 2, 'RIGHT', true] });
assert.equal(removed, true);
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null });
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'auto', movableId: null,
clicked: ['char_605_cmedic', true] });
});
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
+12
View File
@@ -44,6 +44,18 @@ test('user Given strategy medics When inspecting them Then their healing tiles r
}
});
test('user Given a prepared map medic When its card opens Then its board-space range is visible before battle', () => {
for (const id of ['char_605_cmedic', 'char_613_acmedc']) {
const unit = medic(id, { id: -1, uid: -1, area: 'board', x: 2, y: 10, dir: 'RIGHT' });
const r = inspectRange({ ...args(unit), target: { kind: 'unit', unit, prepMapChar: true }, showPrep: true,
field: { prep: false }, snapshot: [99, 18, 3, 1202, 1202] });
assert.deepEqual([r.row, r.col, r.dir], [10, 2, 'RIGHT'], 'use the original board coordinates even on the mirrored boss half');
assert.equal(rangeTiles(r.grid, r.row, r.col, r.dir).length, 12);
assert.equal(inspectRange({ ...args(unit), target: { kind: 'unit', unit }, showPrep: true }), null,
'other unit cards do not become prep-range previews');
}
});
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);
+13 -9
View File
@@ -134,15 +134,19 @@ test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and sh
['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2</>', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']],
['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%</>', '攻击提升', '攻击力+4%', '恢复友方单位生命']],
]) {
// a battle click resolves the unit as its token record
const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map());
assert.equal(detail.type, 'token');
const text = allText(TokenDetail(detail));
assert.match(text, /干员/);
assert.match(text, /医疗/);
assert.doesNotMatch(text, /召唤物/);
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
for (const unit of [
{ id: 91, side: 'ally', defId: id }, // battle click
{ id: -1, uid: -1, kind: 'token', side: 'ally', defId: id, area: 'board', x: 2, y: 10 }, // preparation preview click
]) {
const detail = resolveDetail({ kind: 'unit', unit, prepMapChar: unit.id === -1 }, new Map());
assert.equal(detail.type, 'token');
const text = allText(TokenDetail(detail));
assert.match(text, /干员/);
assert.match(text, /医疗/);
assert.doesNotMatch(text, /召唤物/);
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
}
}
// the record keeps the official sentence; the line under it is what touchGospel does (strictly below hp_ratio)
assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/);