feedback3 WD: the boss rounds' prep shows the leader on its spawn tile with its HP bar, and its hit tiles in red beside an operator's range preview (community report #12)
This commit is contained in:
1 parent
2582367cea
commit
207a49103f
8 files changed
+282
-11
No files matched your search
+1
-1
@@ -440,7 +440,7 @@ S→C:
|
||||
deployCap, deployCount,
|
||||
bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc
|
||||
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
|
||||
nextEnemies: [ { enemyKey, count, tag } ], // preview of the upcoming round's wave (after waves.js generation)
|
||||
nextEnemies: [ { enemyKey, count, tag, start? } ], // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
|
||||
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
|
||||
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }
|
||||
```
|
||||
|
||||
+2
-1
@@ -686,7 +686,8 @@ overtime drain starts; `deadline` = the level's 120 s countdown), `unite { helpe
|
||||
PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others done · `left` / `dead` override.
|
||||
|
||||
`m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave
|
||||
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss`, + its bounties).
|
||||
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss` — the leader's entry with its
|
||||
spawn tile `start`, where the boss-field prep shows it —, + its bounties).
|
||||
|
||||
Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE,
|
||||
`m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains`
|
||||
|
||||
+86
-5
@@ -65,6 +65,12 @@
|
||||
// looked wrong — after 返回战场 and in every battle): the prep, battle, 联防 and boss cameras neither build its 3D
|
||||
// area nor draw its 2D rows (`boardArea` / `bandFor`), and its figures hide (`penShown`, kept for the way back); a
|
||||
// flight to / from the pen shows both while it lasts.
|
||||
// The round's leader (community report #12, owner's decision 2026-10-04; research 09 §2.2: in the official Final Assault
|
||||
// prep image the boss stands on the boss field with its HP bar): a nextEnemies entry with its spawn tile `start`
|
||||
// (render/prepfield.js leaderStand) stands there — idle, with its (full) HP bar, facing the player's half, a tap shows its
|
||||
// details like a pen enemy — instead of in the pen, shown by the boss-field prep camera only (`leaderShown`); while an
|
||||
// operator's range preview shows (a RANGE_GROUPS highlight: the direction wheel's 'facing', a selected piece's range) its
|
||||
// hit tiles (the sim's hit rectangle, render/pick.js hitTiles) are lit in red beside it (LEADER_HIT_STYLE). Display only.
|
||||
// Final Assault / Hidden Core prep (research 09 §1.2, render/prepfield.js): `view.setCamera('bossPrep', { side })`
|
||||
// (or 'prep' with a boss-row rect) shows the prep pieces on the player's half of the boss field — board rows 9–12 →
|
||||
// boss rows 2–5, bench 7 → 0, temp 8 → 1, side 'R' mirrored col c → 20 − c with RIGHT ↔ LEFT. Every public
|
||||
@@ -115,8 +121,8 @@ import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './boa
|
||||
import { BoardScene } from './board3d/scene.js';
|
||||
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
|
||||
import { pickOnTile, pickBattle, hitRectAt } from './pick.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp, leaderStand } from './prepfield.js';
|
||||
import { pickOnTile, pickBattle, hitRectAt, hitTiles } from './pick.js';
|
||||
import { promotionsOf } from './promote.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
@@ -131,6 +137,12 @@ const BOARD3D_RETRY_MS = [1200, 4000, 12000];
|
||||
const BOARD3D_STABLE_MS = 10000;
|
||||
/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */
|
||||
const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']);
|
||||
/**
|
||||
* The round leader's hit tiles, lit beside an operator's range preview in the Final Assault / Hidden Core prep (see the
|
||||
* header; community report #12 "boss受击范围可以像官方原版那样用红色"). [ASSUMED] the red and its strength: the players' request,
|
||||
* no source shows the official colour; the range preview stays orange.
|
||||
*/
|
||||
export const LEADER_HIT_STYLE = Object.freeze({ group: 'leaderHit', color: 0xff3b30, fill: 0.3, line: 0.95 });
|
||||
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
const DROP_PENDING_MS = 1300;
|
||||
@@ -241,6 +253,8 @@ export function fieldRows(kind) {
|
||||
|
||||
/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */
|
||||
export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen';
|
||||
/** Is the prep's leader on the boss field shown for a view kind (a flight shows it when either end is the boss-field prep)? */
|
||||
export const leaderShown = (vk, prevVk = null) => vk === 'bossPrep' || prevVk === 'bossPrep';
|
||||
|
||||
/**
|
||||
* 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of
|
||||
@@ -513,6 +527,8 @@ export async function createFieldView(host, options = {}) {
|
||||
let penList = null;
|
||||
let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen)
|
||||
let camBeforePen = null; // { kind, opts } the camera the pen returns to
|
||||
let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader)
|
||||
let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown)
|
||||
|
||||
const heightAt = (r, c) => (tiles ? tiles.heightAt(r, c) : 0);
|
||||
const ctx = {
|
||||
@@ -739,6 +755,7 @@ export async function createFieldView(host, options = {}) {
|
||||
cam = target; camFrom = camTo = null;
|
||||
tiles.setView(band, focus, field);
|
||||
setPenHidden(!penShown(vk));
|
||||
setLeaderHidden(!leaderShown(vk));
|
||||
pendingView = null;
|
||||
} else {
|
||||
camFrom = cam.clone();
|
||||
@@ -748,7 +765,8 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setView([Math.min(prevBand[0], band[0]), Math.max(prevBand[1], band[1])], focus, [Math.min(prevField[0], field[0]), Math.max(prevField[1], field[1])]);
|
||||
board3d?.setArea(unionAreas(boardArea(prevView), boardArea(vk)));
|
||||
setPenHidden(!penShown(vk, prevView));
|
||||
pendingView = { band, focus, field, area: boardArea(vk), pen: penShown(vk) };
|
||||
setLeaderHidden(!leaderShown(vk, prevView));
|
||||
pendingView = { band, focus, field, area: boardArea(vk), pen: penShown(vk), leader: leaderShown(vk) };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -768,6 +786,8 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && v && typeof v.setDir === 'function') v.setDir(prepXf.dirToDisp(pieceDirOf(e.piece) || 'RIGHT'));
|
||||
}
|
||||
for (const [group, req] of hlReq) drawHighlight(req.tiles, req.style, group);
|
||||
faceLeader();
|
||||
syncLeaderHits();
|
||||
}
|
||||
|
||||
function stepCamera(now) {
|
||||
@@ -782,6 +802,7 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setView(pendingView.band, pendingView.focus, pendingView.field);
|
||||
board3d?.setArea(pendingView.area);
|
||||
setPenHidden(!pendingView.pen);
|
||||
setLeaderHidden(!pendingView.leader);
|
||||
pendingView = null;
|
||||
tiles.project(cam, true);
|
||||
}
|
||||
@@ -1024,7 +1045,10 @@ export async function createFieldView(host, options = {}) {
|
||||
penSig = sig;
|
||||
penList = sig ? list : null;
|
||||
if (!sig) return;
|
||||
const pen = layoutPen(list, { stage: stageRec });
|
||||
// the leader with a spawn tile stands on the boss field (setLeader), not in the pen
|
||||
const stand = leaderStand(list, (k) => data.enemy(k)?.hitArea ?? null, hitTiles);
|
||||
setLeader(stand);
|
||||
const pen = layoutPen(stand ? list.filter((e) => e !== stand.entry) : list, { stage: stageRec });
|
||||
for (const f of pen.figures) {
|
||||
const rec = data.enemy(f.enemyKey);
|
||||
const rank = rec?.rank;
|
||||
@@ -1062,6 +1086,57 @@ export async function createFieldView(host, options = {}) {
|
||||
penViews.clear();
|
||||
penSig = null;
|
||||
penList = null;
|
||||
clearLeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* The round's leader standing on the boss field in the Final Assault / Hidden Core prep (`stand`: prepfield.js
|
||||
* leaderStand; see the header) or none. Kept while the same leader stands on the same tile.
|
||||
*/
|
||||
function setLeader(stand) {
|
||||
const key = stand ? `${stand.entry.enemyKey}@${stand.row},${stand.col}` : null;
|
||||
if ((leader ? leader.key : null) === key) return;
|
||||
clearLeader();
|
||||
if (!stand) return;
|
||||
const k = stand.entry.enemyKey;
|
||||
const rec = data.enemy(k);
|
||||
// not a prep view: a boss shows its HP bar (full: the round has not begun)
|
||||
const v = new UnitView(ctx, {
|
||||
id: 'leader:' + k, kind: 'enemy', side: 'enemy', defId: k, enemyKey: k, preview: true, name: rec?.name || k, tier: 3, golden: false,
|
||||
spine: rec?.spine || k, avatar: rec?.iconId || rec?.avatar || k, x: stand.col, y: stand.row, facing: -1, maxHp: 1, boss: true,
|
||||
});
|
||||
v.setWorld(stand.col, stand.row, heightAt(stand.row, stand.col));
|
||||
v.fadeIn = 0;
|
||||
leader = { key, view: v, stand, area: rec?.hitArea ?? null };
|
||||
faceLeader();
|
||||
if (leaderHidden) hidePenView(v, true);
|
||||
syncLeaderHits();
|
||||
}
|
||||
/** The leader faces the player's half of the boss field (left half → left) [ASSUMED look]. */
|
||||
function faceLeader() { if (leader) leader.view.visFacing = prepXf.side === 'R' ? 1 : -1; }
|
||||
function clearLeader() {
|
||||
if (!leader) return;
|
||||
try { leader.view.destroy(); } catch { /* ignore */ }
|
||||
leader = null;
|
||||
syncLeaderHits();
|
||||
}
|
||||
function setLeaderHidden(hidden) {
|
||||
leaderHidden = !!hidden;
|
||||
if (leader) hidePenView(leader.view, leaderHidden);
|
||||
syncLeaderHits();
|
||||
}
|
||||
/** The leader's hit tiles in red while an operator's range preview shows in the boss-field prep (LEADER_HIT_STYLE). */
|
||||
function syncLeaderHits() {
|
||||
if (!tiles) return;
|
||||
const on = !!leader && !leaderHidden && mode === 'prep' && prepXf.kind === 'bossPrep' && [...hlReq.keys()].some((g) => RANGE_GROUPS.has(g));
|
||||
if (on) tiles.setHighlights(leader.stand.tiles, LEADER_HIT_STYLE, LEADER_HIT_STYLE.group);
|
||||
else tiles.clearHighlights(LEADER_HIT_STYLE.group);
|
||||
}
|
||||
/** The leader under a canvas point (its drawn body or hit area, like a battle enemy), or null. */
|
||||
function leaderAt(x, y) {
|
||||
if (!leader || leaderHidden) return null;
|
||||
const u = pickUnitOf(leader.view, true, leader.area);
|
||||
return u && pickBattle([u], groundTile(x, y), x, y) ? leader.view : null;
|
||||
}
|
||||
|
||||
/** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
|
||||
@@ -1149,6 +1224,7 @@ export async function createFieldView(host, options = {}) {
|
||||
function clearHl(group) {
|
||||
if (group) hlReq.delete(group); else hlReq.clear();
|
||||
tiles.clearHighlights(group);
|
||||
syncLeaderHits();
|
||||
}
|
||||
|
||||
function legalTiles(piece) {
|
||||
@@ -1274,6 +1350,7 @@ export async function createFieldView(host, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } 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);
|
||||
if (pv) emitPenClick(pv, e);
|
||||
@@ -1699,6 +1776,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (v.remove) { dropView(key); if (mode === 'battle') gone.add(key); }
|
||||
}
|
||||
if (!penHidden) for (const v of penViews.values()) v.update(dt, cam, clock);
|
||||
if (leader && !leaderHidden) leader.view.update(dt, cam, clock);
|
||||
tiles.update(dt);
|
||||
fx.update(dt);
|
||||
impostors.flush();
|
||||
@@ -1750,6 +1828,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
leader?.view.retryAssets?.();
|
||||
}
|
||||
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
|
||||
const onVisible = () => {
|
||||
@@ -1757,6 +1836,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
leader?.view.retryAssets?.();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||||
|
||||
@@ -1790,6 +1870,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const key = hlKey(st);
|
||||
if (Array.isArray(tilesList) && tilesList.length) hlReq.set(key, { tiles: tilesList, style: st }); else hlReq.delete(key);
|
||||
drawHighlight(tilesList, st, key);
|
||||
syncLeaderHits();
|
||||
return true;
|
||||
},
|
||||
on(name, fn) {
|
||||
@@ -1914,7 +1995,7 @@ export async function createFieldView(host, options = {}) {
|
||||
get mode() { return mode; },
|
||||
/** Dev hooks (demo / tests). */
|
||||
debug: {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, 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,
|
||||
// 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 },
|
||||
|
||||
@@ -38,6 +38,21 @@ export function hitRectAt(x, y, a) {
|
||||
const cx = x + (Number(a.dx) || 0), cy = y + (Number(a.dy) || 0);
|
||||
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
|
||||
}
|
||||
/**
|
||||
* Board tiles ([[row, col]]) a unit at (x, y) is hit on — as server/sim/body.js bodyKeys: a huge unit (`hitArea`) every tile
|
||||
* its rectangle overlaps (open intervals: a tile only touched along an edge is not), a point unit the tile of its
|
||||
* position; clipped to the rows × cols grid (19 × 21). The Final Assault prep draws a leader's (render/app.js, report #12).
|
||||
*/
|
||||
export function hitTiles(x, y, a, rows = 19, cols = 21) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return [];
|
||||
const R = hitRectAt(x, y, a);
|
||||
if (!R) { const r = Math.round(y), c = Math.round(x); return r >= 0 && r < rows && c >= 0 && c < cols ? [[r, c]] : []; }
|
||||
const r0 = Math.max(0, Math.floor(R.y0 + 0.5 + EPS)), r1 = Math.min(rows - 1, Math.ceil(R.y1 - 0.5 - EPS));
|
||||
const c0 = Math.max(0, Math.floor(R.x0 + 0.5 + EPS)), c1 = Math.min(cols - 1, Math.ceil(R.x1 - 0.5 - EPS));
|
||||
const out = [];
|
||||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) out.push([r, c]);
|
||||
return out;
|
||||
}
|
||||
const inArea = (A, g) => !!A && !!g && g.x >= A.x0 && g.x <= A.x1 && g.y >= A.y0 && g.y <= A.y1;
|
||||
/** Screen point inside a huge body's upright box: `hw` tiles (its px per tile) either side of its x, feet up to head. */
|
||||
const inBox = (b, px, py) => !!b && b.hw > 0 && b.s > 0 && Math.abs(px - b.x) <= b.hw * b.s
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
//
|
||||
// The view keeps every public coordinate in BOARD space (the server's, g.move targets, canPlace, highlightTiles,
|
||||
// tileScreen, holdPiece, setPieceDir): only what is drawn and picked goes through this transform. Pure functions.
|
||||
//
|
||||
// The round's leader (community report #12, owner's decision 2026-10-04: research 09 §2.2's "the leader stands by its
|
||||
// spawn point" over research 08 §4.1's pen): `leaderStand` finds it in m.private.nextEnemies (an entry with `start`, the
|
||||
// leader's spawn tile on the boss field, server/match/waves.js previewOf) — the Final Assault prep shows it standing
|
||||
// there instead of in the pen, and lights its hit tiles in red beside an operator's orange range preview (render/app.js).
|
||||
|
||||
/** Board row → display (boss-field) row: row − 7 (server/match/board.js BOARD_ROWS_ABOVE_BOSS is +7, the inverse). */
|
||||
export const BOSS_ROW_SHIFT = -7;
|
||||
@@ -69,3 +74,26 @@ export function tilesToDisp(xf, tiles) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The leader standing on the boss field in its prep (see the header): the first `nextEnemies` entry flagged `boss` with
|
||||
* a spawn tile `start` ([row, col], boss-field rows 0–5) → { entry, row, col, tiles } — `tiles` its hit tiles there
|
||||
* (render/pick.js hitTiles: the sim's hit rectangle, data/enemies.json `hitArea` through `hitAreaOf(enemyKey)`; a point
|
||||
* leader its own tile) — or null.
|
||||
* @param {Array<object>|null} list m.private.nextEnemies
|
||||
* @param {(enemyKey: string) => any} hitAreaOf
|
||||
* @param {(x: number, y: number, a: any) => number[][]} hitTilesOf render/pick.js hitTiles
|
||||
*/
|
||||
export function leaderStand(list, hitAreaOf, hitTilesOf) {
|
||||
if (!Array.isArray(list)) return null;
|
||||
const entry = list.find((e) => e && e.boss && typeof e.enemyKey === 'string' && Array.isArray(e.start)
|
||||
&& Number.isInteger(e.start[0]) && Number.isInteger(e.start[1]) && e.start[0] >= 0 && e.start[0] <= BOSS_DISP_MAX_ROW
|
||||
&& e.start[1] >= 0 && e.start[1] <= MAX_COL);
|
||||
if (!entry) return null;
|
||||
const [row, col] = entry.start;
|
||||
let area = null;
|
||||
try { area = hitAreaOf ? hitAreaOf(entry.enemyKey) : null; } catch { area = null; }
|
||||
const tiles = (typeof hitTilesOf === 'function' ? hitTilesOf(col, row, area) : [[row, col]])
|
||||
.filter(([r, c]) => r >= 0 && r <= BOSS_DISP_MAX_ROW && c >= 0 && c <= MAX_COL);
|
||||
return { entry, row, col, tiles };
|
||||
}
|
||||
@@ -562,9 +562,11 @@ export function withBounties(gd, round, wave, bounties, playerId, { side = null
|
||||
|
||||
/**
|
||||
* nextEnemies preview (m.private.nextEnemies): one entry per spawn action (research 08 §4.2 input), in spawn order:
|
||||
* `{ enemyKey, count, gate: 'upper'|'lower', t, fly, elite, boss, source: 'wave'|'bounty', tag }` — `tag` 'boss' /
|
||||
* `{ enemyKey, count, gate: 'upper'|'lower', t, fly, elite, boss, source: 'wave'|'bounty', tag, start? }` — `tag` 'boss' /
|
||||
* 'bounty' / null kept for the drawer's grouping; Σ count = the enemies of the round (HUD). Boss parts are omitted.
|
||||
* The client places the models in the gate's pen zone (≤ 50 shown, elites and bosses always).
|
||||
* The client places the models in the gate's pen zone (≤ 50 shown, elites and bosses always) — except a leader: its
|
||||
* entry carries its spawn tile `start` ([row, col] on the boss field) and the Final Assault / Hidden Core prep shows it
|
||||
* standing there (render/app.js, community report #12; display only).
|
||||
*/
|
||||
export function previewOf(spawns) {
|
||||
const out = [];
|
||||
@@ -583,6 +585,7 @@ export function previewOf(spawns) {
|
||||
boss: tag === 'boss',
|
||||
source: tag === 'bounty' ? 'bounty' : 'wave',
|
||||
tag,
|
||||
...(tag === 'boss' && Array.isArray(pv.start) && pv.start.length >= 2 && pv.start.every(Number.isFinite) ? { start: [pv.start[0], pv.start[1]] } : {}),
|
||||
_seq: seq++,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// test/render/feedback3-leader-prep.test.js — community report #12 (owner's decision 2026-10-04): in the Final Assault /
|
||||
// Hidden Core prep the round's leader stands on the boss field (it used to wait in the off-screen pen: research 08's +13
|
||||
// preview row put it in the pen's upper zone), and while an operator's orange range preview shows, the leader's hit
|
||||
// tiles — the sim's hit rectangle — are lit in red [ASSUMED colour]. The match layer's preview carries the leader's spawn
|
||||
// tile (server/match/waves.js previewOf `start`); render/prepfield.js leaderStand + render/pick.js hitTiles (= the sim's
|
||||
// body.js bodyKeys) give the stand; render/app.js draws it (wiring: test/render/fieldview.browser.test.js). Display only.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { setupMatchWaves, buildBossWave, previewOf } from '../../server/match/waves.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { bodyKeys, normHitArea } from '../../server/sim/body.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
import { DATA } from '../match/harness.js';
|
||||
import { leaderStand } from '../../public/js/render/prepfield.js';
|
||||
import { hitTiles } from '../../public/js/render/pick.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
test('the preview of a boss round carries the leader\'s spawn tile; other entries never do', () => {
|
||||
const p = previewOf([
|
||||
{ enemyKey: 'L', count: 1, time: 0, tag: 'boss', preview: { gate: 'upper', boss: true, start: [3, 10] } },
|
||||
{ enemyKey: 'a', count: 2, time: 1, preview: { gate: 'lower', start: [3, 2] } },
|
||||
]);
|
||||
assert.deepEqual(p[0].start, [3, 10]);
|
||||
assert.equal('start' in p[1], false, 'escorts stay in the pen');
|
||||
const gd = new GameData(DATA, 'mode_multi_abyss');
|
||||
const setup = setupMatchWaves(gd, createRng(1));
|
||||
for (const bossId of Object.keys(gd.mode.bossWeights)) {
|
||||
for (const solo of [false, true]) {
|
||||
const w = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo });
|
||||
const boss = previewOf(w.spawns).filter((e) => e.boss);
|
||||
assert.ok(boss.length >= 1, `${w.templateId}: a leader`);
|
||||
const route = DATA.waves[w.templateId].routes[w.spawns.find((s) => s.tag === 'boss').routeIndex];
|
||||
for (const e of boss) assert.deepEqual(e.start, route.start.slice(0, 2), `${w.templateId}: the leader's route start`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('leaderStand: every leader of the data stands on its spawn tile with the sim\'s hit tiles (bodyKeys)', () => {
|
||||
const seen = new Set();
|
||||
for (const [id, tpl] of Object.entries(DATA.waves)) {
|
||||
if (!tpl || (tpl.kind !== 'boss' && tpl.kind !== 'hidden')) continue;
|
||||
for (const s of tpl.spawns.filter((x) => x.tag === 'boss')) {
|
||||
const start = tpl.routes[s.routeIndex].start.slice(0, 2);
|
||||
const st = leaderStand([{ enemyKey: 'escort', count: 3 }, { enemyKey: s.key, count: 1, boss: true, start }], (k) => DATA.enemies[k]?.hitArea ?? null, hitTiles);
|
||||
assert.ok(st, id);
|
||||
assert.deepEqual([st.row, st.col], start);
|
||||
const sim = bodyKeys({ x: start[1], y: start[0], hitArea: normHitArea(DATA.enemies[s.key].hitArea) }).map((k) => [Math.floor(k / COLS), k % COLS]);
|
||||
assert.deepEqual(st.tiles, sim, `${id} ${s.key}: the sim's hit tiles`);
|
||||
seen.add(`${s.key} ${st.tiles.length}`);
|
||||
}
|
||||
}
|
||||
// 胄 / 管 / 昆图斯 / 阿利斯泰尔 / 萨米的意志: 5 × 3 (4.95 × 2.95, up 1); 铳 / 卢西恩 are point units: their tile
|
||||
assert.ok(seen.has('enemy_9013_acstmk 15') && seen.has('enemy_9032_aclionk 15') && seen.has('enemy_9017_achunt 1'), [...seen].join(', '));
|
||||
const chou = leaderStand([{ enemyKey: 'enemy_9013_acstmk', boss: true, start: [3, 10] }], (k) => DATA.enemies[k].hitArea, hitTiles);
|
||||
assert.deepEqual([...new Set(chou.tiles.map(([r]) => r))], [3, 4, 5]);
|
||||
assert.deepEqual([...new Set(chou.tiles.map(([, c]) => c))], [8, 9, 10, 11, 12]);
|
||||
assert.equal(leaderStand([{ enemyKey: 'x', boss: true }], () => null, hitTiles), null, 'no spawn tile: it stays in the pen');
|
||||
assert.equal(leaderStand([{ enemyKey: 'x', boss: false, start: [3, 10] }], () => null, hitTiles), null);
|
||||
assert.equal(leaderStand(null), null);
|
||||
});
|
||||
|
||||
test('render/app.js: the leader leaves the pen for the boss field, red hit tiles beside a range preview (source checks)', async () => {
|
||||
const { LEADER_HIT_STYLE, leaderShown } = await import('../../public/js/render/app.js');
|
||||
assert.equal(LEADER_HIT_STYLE.group, 'leaderHit');
|
||||
assert.ok((LEADER_HIT_STYLE.color >> 16) > 0xd0 && ((LEADER_HIT_STYLE.color >> 8) & 0xff) < 0x60, 'red');
|
||||
assert.equal(leaderShown('bossPrep'), true);
|
||||
assert.equal(leaderShown('prep'), false);
|
||||
assert.equal(leaderShown('pen', 'bossPrep'), true, 'a flight from the boss-field prep');
|
||||
const app = read('public/js/render/app.js');
|
||||
assert.match(app, /const pen = layoutPen\(stand \? list\.filter\(\(e\) => e !== stand\.entry\) : list, \{ stage: stageRec \}\);/);
|
||||
assert.match(app, /\[\.\.\.hlReq\.keys\(\)\]\.some\(\(g\) => RANGE_GROUPS\.has\(g\)\)/);
|
||||
});
|
||||
|
||||
test('the leader\'s view (render/app.js setLeader: a boss, not a prep view) shows its full HP bar, like the official prep image', async () => {
|
||||
const { installFakePixi, fakeViewCtx } = await import('./fakepixi.js');
|
||||
const { presetCamera } = await import('../../public/js/render/projection.js');
|
||||
const fake = installFakePixi();
|
||||
try {
|
||||
const { UnitView } = await import('../../public/js/render/units.js');
|
||||
const cam = presetCamera('bossPrep', { width: 1600, height: 900 });
|
||||
const v = new UnitView(fakeViewCtx(fake.P, { cam: () => cam }), { id: 'leader:x', kind: 'enemy', side: 'enemy', defId: 'enemy_9013_acstmk', spine: 'enemy_9013_acstmk', tier: 3, x: 10, y: 3, facing: -1, maxHp: 1, boss: true });
|
||||
v.update(1 / 60, cam, 0);
|
||||
assert.equal(v.hpFill.visible, true, 'HP bar shown');
|
||||
assert.ok(Math.abs(v.hpFill.width - v.hpBg.width + 2) < 1e-6, 'full');
|
||||
assert.equal(v.spFill.visible, false);
|
||||
v.destroy();
|
||||
} finally { fake.restore(); }
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
// test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo
|
||||
// (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and
|
||||
// the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2:
|
||||
// drop targets / directions stay in board space, the right half mirrored), damage numbers that never touch, the
|
||||
// drop targets / directions stay in board space, the right half mirrored) with the round's leader on its spawn tile and
|
||||
// its red hit tiles beside a range preview (community report #12), damage numbers that never touch, the
|
||||
// automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU.
|
||||
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
|
||||
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, fa-leader-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/fieldview.browser.test.js
|
||||
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
|
||||
@@ -226,6 +227,53 @@ describe('field view features in headless Chrome', { skip }, () => {
|
||||
});
|
||||
}
|
||||
|
||||
for (const side of ['L', 'R']) {
|
||||
test(`Final Assault prep (${side}): the leader stands on its spawn tile, out of the pen; a range preview lights its hit tiles in red (report #12)`, async () => {
|
||||
const { page, problems } = await open(`scene=prep&fa=${side}&stage=act2autochess_m02`, 1920, 1080);
|
||||
await wait(1500);
|
||||
const r = await page.evaluate(async () => {
|
||||
const v = window.__demo.view, d = v.debug;
|
||||
// m.private as the server sends it in a boss round's prep: nextEnemies with the leader's spawn tile (waves.js previewOf)
|
||||
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
|
||||
st.nextEnemies = [
|
||||
{ enemyKey: 'enemy_9013_acstmk', count: 1, gate: 'upper', t: 0, fly: false, elite: true, boss: true, source: 'wave', tag: 'boss', start: [3, 10] },
|
||||
{ enemyKey: 'enemy_1007_slime', count: 3, gate: 'lower', t: 2, fly: false, elite: false, boss: false, source: 'wave', tag: null },
|
||||
];
|
||||
v.setPrep(st, { editable: true });
|
||||
await new Promise((res) => setTimeout(res, 2000));
|
||||
const L = d.leader;
|
||||
const hl = () => (d.tiles.highlights.get('leaderHit')?.tiles || []).map((t) => t.join(','));
|
||||
const out = { has: !!L, x: L?.view.x, y: L?.view.y, shown: !!L?.view.root.visible, facing: L?.view.visFacing, spine: !!L?.view.actor,
|
||||
pen: [...d.penViews.values()].map((p) => p.info.defId), before: hl() };
|
||||
v.highlightTiles([[10, 5], [10, 6], [10, 7]], { group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
|
||||
out.during = hl();
|
||||
out.range = (d.tiles.highlights.get('facing')?.tiles || []).length;
|
||||
return out;
|
||||
});
|
||||
await wait(300);
|
||||
await page.screenshot({ path: path.join(OUT, `fa-leader-${side}.png`) });
|
||||
const r2 = await page.evaluate(() => {
|
||||
const v = window.__demo.view, d = v.debug;
|
||||
v.highlightTiles([], { group: 'facing' });
|
||||
const after = (d.tiles.highlights.get('leaderHit')?.tiles || []).length;
|
||||
v.setCamera('prep', { instant: true });
|
||||
return { after, hiddenOnBoard: !d.leader.view.root.visible };
|
||||
});
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
assert.deepEqual([r.has, r.x, r.y, r.shown], [true, 10, 3, true], 'on its spawn tile (3, 10), shown by the boss-field prep camera');
|
||||
assert.equal(r.facing, side === 'R' ? 1 : -1, 'facing the player\'s half');
|
||||
assert.ok(r.spine, 'its Spine model');
|
||||
assert.ok(!r.pen.includes('enemy_9013_acstmk') && r.pen.length === 3, `not in the pen: ${r.pen}`);
|
||||
assert.deepEqual(r.before, [], 'no red tiles without a range preview');
|
||||
assert.equal(r.range, 3);
|
||||
assert.equal(r.during.length, 15, `the 5 × 3 hit tiles: ${r.during}`);
|
||||
assert.ok(['3,8', '3,12', '5,8', '5,12', '4,10'].every((t) => r.during.includes(t)));
|
||||
assert.equal(r2.after, 0, 'gone with the range preview');
|
||||
assert.equal(r2.hiddenOnBoard, true, 'the own-board camera does not show it');
|
||||
});
|
||||
}
|
||||
|
||||
test('damage numbers: a boss and a knot of minions under rapid mixed hits never show touching numbers', async () => {
|
||||
const { page, problems } = await open('scene=numbers', 1920, 1080);
|
||||
await wait(2500);
|
||||
|
||||
Reference in new issue
Block a user