fix: 准备阶段可查看外勤医疗详情与范围 (#315)
This commit is contained in:
1 parent
e85942bce1
commit
69e27ba03d
10 files changed
+109
-36
No files matched your search
@@ -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).
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
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
|
Preparation state carries read-only `prepMapChars` for 外勤医疗. The owner's entries can be clicked for a detail card
|
||||||
displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
|
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.
|
disables picking and the server checks again before consumption.
|
||||||
@@ -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
|
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.
|
守墓石像 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
|
`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;
|
`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
|
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
@@ -265,6 +265,7 @@ export async function createFieldView(host, options = {}) {
|
|||||||
let pendingView = null; // tile band/focus to apply when the camera transition ends
|
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)
|
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
|
||||||
let prepPieces = []; // { uid, piece, area, idx, row, col, key }
|
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
|
// 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)
|
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
|
||||||
let promoBase = [];
|
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);
|
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);
|
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 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece.
|
||||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
// Keep its original board-space record for the detail card/range; only the rendered view is transformed to the
|
||||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
// displayed boss half. The partner's units already use field coordinates and are not own prep map characters.
|
||||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
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 p = prepXf.toDisp(u.y, u.x);
|
||||||
const dir = prepXf.dirToDisp(u.dir);
|
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) : [];
|
}).filter(Boolean) : [];
|
||||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
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));
|
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
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)
|
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
|
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not movable prep pieces;
|
||||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
* only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes.
|
||||||
*/
|
*/
|
||||||
function syncReadOnlyPrepUnits(units) {
|
function syncReadOnlyPrepUnits(units) {
|
||||||
const keep = new Set();
|
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 };
|
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({
|
const drag = createDragController({
|
||||||
hitPiece: (x, y) => pieceAt(x, y),
|
hitPiece: (x, y) => pieceAt(x, y),
|
||||||
pickTile: (x, y) => pickBoardTile(x, y),
|
pickTile: (x, y) => pickBoardTile(x, y),
|
||||||
@@ -1218,6 +1231,12 @@ export async function createFieldView(host, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } 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 (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||||||
if (penViews.size && mode === 'prep') {
|
if (penViews.size && mode === 'prep') {
|
||||||
const pv = penUnitAt(ev.x, ev.y);
|
const pv = penUnitAt(ev.x, ev.y);
|
||||||
@@ -1299,6 +1318,7 @@ export async function createFieldView(host, options = {}) {
|
|||||||
clearViews();
|
clearViews();
|
||||||
if (prepPieces.length) promoBase = prepPieces;
|
if (prepPieces.length) promoBase = prepPieces;
|
||||||
prepPieces = [];
|
prepPieces = [];
|
||||||
|
prepMapChars = [];
|
||||||
infos.clear();
|
infos.clear();
|
||||||
gone.clear();
|
gone.clear();
|
||||||
interp.reset();
|
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); },
|
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,
|
promotions,
|
||||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
|
// 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
|
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
|
||||||
|
|||||||
@@ -998,7 +998,7 @@ function MatchScreen() {
|
|||||||
// an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card)
|
// an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card)
|
||||||
const penKey = previewEnemyKey(e);
|
const penKey = previewEnemyKey(e);
|
||||||
if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; }
|
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;
|
if (!Number.isInteger(e.uid)) return;
|
||||||
setDetail({ kind: 'piece', uid: e.uid });
|
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
|
// 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
|
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):
|
// 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
|
// 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
|
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
|
||||||
// draw their own
|
// draw their own
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field,
|
|||||||
const e = pieces?.get(target.uid);
|
const e = pieces?.get(target.uid);
|
||||||
if (!e || e.area !== 'board') return null;
|
if (!e || e.area !== 'board') return null;
|
||||||
row = e.row; col = e.col; dir = normDir(e.piece.dir);
|
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;
|
const unit = target.unit;
|
||||||
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
|
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
|
||||||
if (unit.area != null && unit.area !== 'board') 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.
|
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
|
||||||
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||||
row = Math.round(snapshot ? snapshot[2] : unit.y);
|
row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y);
|
||||||
col = Math.round(snapshot ? snapshot[1] : unit.x);
|
col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x);
|
||||||
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
dir = (!showPrep && normDir(live?.dir, null)) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
||||||
} else return null;
|
} else return null;
|
||||||
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
||||||
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
|
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
|
||||||
|
|||||||
@@ -337,7 +337,9 @@ export function createFallbackView(host, opts = {}) {
|
|||||||
const pos = tilePos(L, u.y, u.x);
|
const pos = tilePos(L, u.y, u.x);
|
||||||
const src = tokenAvatarUrl(m(), u.defId);
|
const src = tokenAvatarUrl(m(), u.defId);
|
||||||
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
|
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}>
|
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 class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
|
||||||
</div>`);
|
</div>`);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('render engine in headless Chrome', { skip }, () => {
|
|||||||
} finally { await page.close(); }
|
} 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 { page, problems } = await open('scene=prep&panel=0', 1600, 900);
|
||||||
const first = await page.evaluate(() => {
|
const first = await page.evaluate(() => {
|
||||||
const v = window.__demo.view;
|
const v = window.__demo.view;
|
||||||
@@ -108,8 +108,14 @@ describe('render engine in headless Chrome', { skip }, () => {
|
|||||||
v.setPrep(st, { editable: true });
|
v.setPrep(st, { editable: true });
|
||||||
const medic = v.debug.views.get('m:-1');
|
const medic = v.debug.views.get('m:-1');
|
||||||
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
|
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,
|
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;
|
return out;
|
||||||
});
|
});
|
||||||
await wait(500);
|
await wait(500);
|
||||||
@@ -118,7 +124,13 @@ describe('render engine in headless Chrome', { skip }, () => {
|
|||||||
const v = window.__demo.view;
|
const v = window.__demo.view;
|
||||||
v.setCamera('bossPrep', { side: 'R', instant: true });
|
v.setCamera('bossPrep', { side: 'R', instant: true });
|
||||||
const mirrored = v.debug.views.get('m:-1');
|
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 wait(500);
|
||||||
await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') });
|
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));
|
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
|
||||||
st.prepMapChars = [];
|
st.prepMapChars = [];
|
||||||
v.setPrep(st, { editable: true });
|
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 fallback = await page.evaluate(async () => {
|
||||||
const { createFallbackView } = await import('/js/ui/fallbackField.js');
|
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 });
|
ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true });
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
const el = host.querySelector('.ff-piece--map-char');
|
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,
|
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();
|
ff.destroy(); host.remove();
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
await page.close();
|
await page.close();
|
||||||
assert.deepEqual(problems, []);
|
assert.deepEqual(problems, []);
|
||||||
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
|
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
|
||||||
draggable: false });
|
draggable: false, clicked: { uid: -1, id: 'char_605_cmedic', row: 10, col: 2, prepMapChar: true } });
|
||||||
assert.deepEqual(boss, [18, 3, 'LEFT']);
|
assert.deepEqual(boss, { view: [18, 3, 'LEFT'], clicked: [10, 2, 'RIGHT', true] });
|
||||||
assert.equal(removed, 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 () => {
|
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
|
||||||
|
|||||||
@@ -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', () => {
|
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 } }));
|
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.equal(r.grid.length, 18);
|
||||||
|
|||||||
@@ -134,15 +134,19 @@ test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and sh
|
|||||||
['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2</>', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']],
|
['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2</>', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']],
|
||||||
['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%</>', '攻击提升', '攻击力+4%', '恢复友方单位生命']],
|
['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%</>', '攻击提升', '攻击力+4%', '恢复友方单位生命']],
|
||||||
]) {
|
]) {
|
||||||
// a battle click resolves the unit as its token record
|
for (const unit of [
|
||||||
const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map());
|
{ id: 91, side: 'ally', defId: id }, // battle click
|
||||||
assert.equal(detail.type, 'token');
|
{ id: -1, uid: -1, kind: 'token', side: 'ally', defId: id, area: 'board', x: 2, y: 10 }, // preparation preview click
|
||||||
const text = allText(TokenDetail(detail));
|
]) {
|
||||||
assert.match(text, /干员/);
|
const detail = resolveDetail({ kind: 'unit', unit, prepMapChar: unit.id === -1 }, new Map());
|
||||||
assert.match(text, /医疗/);
|
assert.equal(detail.type, 'token');
|
||||||
assert.doesNotMatch(text, /召唤物/);
|
const text = allText(TokenDetail(detail));
|
||||||
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
|
assert.match(text, /干员/);
|
||||||
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
|
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)
|
// 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, /不高于一半/);
|
assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/);
|
||||||
|
|||||||
Reference in new issue
Block a user