Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price

- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-09-30 19:12:36 +08:00
1 parent d4f868cd19
commit 15291c2293
104 files changed
+5539 -662

No files matched your search

+5 -4
View File
@@ -134,9 +134,10 @@ describe('3D board in headless Chrome', { skip }, () => {
assert.deepEqual(problems, []);
assert.equal(keys[0], keys[3], 'back to the normal area');
// user playtest #2 item 6: the prep board no longer builds the enemy pen block (only the pen camera does), so the
// union while flying is the prep field + the boss field
assert.equal(keys[0], '6,12,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,12,0,10', `union while flying: ${keys[1]}`);
// union while flying is the prep field + the boss field; the field takes its separator row 13 (user playtest #5
// item 6: act2 m01's blowers stand on it)
assert.equal(keys[0], '6,13,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,13,0,10', `union while flying: ${keys[1]}`);
assert.equal(keys[2], '0,6,0,20');
});
@@ -178,7 +179,7 @@ describe('3D board in headless Chrome', { skip }, () => {
assert.equal(r.faArea, '0,6,0,20', 'FA prep builds the boss field');
assert.deepEqual(r.faBand, [0, 13]);
assert.ok(r.faCamTx > 10, `right-hand boss prep camera (tx ${r.faCamTx})`);
assert.equal(r.prepArea, '6,12,0,10', 'prep board without the pen block (user playtest #2 item 6)');
assert.equal(r.prepArea, '6,13,0,10', 'prep board without the pen block (user playtest #2 item 6), with the row-13 wall');
assert.equal(r.bossArea, '0,6,0,20');
assert.equal(r.devices, 2);
assert.equal(r.attached, 2, 'both crate meshes still in the scene after the flight');
+89
View File
@@ -0,0 +1,89 @@
// test/render/bossbody.browser.test.js — user playtest #5 item 10 in headless Chrome through the render demo (real
// Spine models, the official boss camera): a huge boss (假想敌:胄, data/enemies.json `hitArea` 4.95 × 2.95 up 1 — the
// sim's hit rectangle, server/sim/body.js) is picked by a press anywhere on its hit area — the 5 × 3 tiles under its
// drawn body — while an operator standing on a tile inside that area is still picked on its tile, and a press beside
// the area picks nothing (render/pick.js AREA_PICK).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/bossbody.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/bossbody.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
/** Page side: the boss field of 源石流发生装置 with 假想敌:胄 at (3,10), an operator on the fence (4,8), a regular enemy. */
function installBoss() {
const v = window.__demo.view;
const rect = { r0: 0, r1: 5, c0: 0, c1: 20 };
const units = [
{ id: 1, kind: 'op', side: 'ally', defId: 'char_102_texas', spine: 'char_102_texas', avatar: 'char_102_texas', x: 8, y: 4, maxHp: 1000, facing: 1, dir: 'RIGHT', tier: 3 },
{ id: 2, kind: 'enemy', side: 'enemy', defId: 'enemy_9013_acstmk', spine: 'enemy_9013_acstmk', avatar: 'enemy_9013_acstmk', x: 10, y: 3, maxHp: 600000, facing: -1, boss: true },
{ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1000_gopro_2', spine: 'enemy_1000_gopro_2', avatar: 'enemy_1000_gopro_2', x: 15, y: 2, maxHp: 1000, facing: -1 },
];
v.enterBattle({ fieldId: 'bb', kind: 'boss', rect, stageId: 'act2autochess_m01', units });
v.setCamera('boss', { rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const tup = (u) => [u.id, u.x, u.y, u.maxHp, u.maxHp, 0, 0, 0, 0];
window.__boss = {
step(gt) { v.pushSnapshot({ t: 'b.snap', fieldId: 'bb', gt, units: units.map(tup), dp: 5, killed: 0, total: 3 }); },
/** The unit id picked by a press on a spot of tile (row, col) (u, v ∈ [−0.5, 0.5] across / along it). */
pickAt(row, col) {
const t = v.tileScreen(row, col), r = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - r.left, t.y - r.top)?.id ?? null;
},
};
}
describe('a huge boss is picked anywhere on its hit area (headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('the 5 × 3 body picks the boss; the operator on the fence tile inside it and the tiles beside it do not', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installBoss);
const feed = (from, to) => page.evaluate(async (from, to) => {
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) { window.__boss.step(+gt.toFixed(4)); await new Promise((r) => requestAnimationFrame(() => r())); }
}, from, to);
await feed(1, 2);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await feed(2, 3);
await page.screenshot({ path: path.join(OUT, 'bossbody.png') });
const pick = (r, c) => page.evaluate((r, c) => window.__boss.pickAt(r, c), r, c);
for (const [r, c] of [[3, 10], [4, 9], [5, 11], [5, 12], [3, 12], [4, 11], [3, 8], [5, 8]]) assert.equal(await pick(r, c), 2, `(${r},${c}) on the boss body`);
assert.equal(await pick(4, 8), 1, 'the operator on its fence tile inside the area');
for (const [r, c] of [[3, 14], [2, 7], [4, 6]]) assert.equal(await pick(r, c), null, `(${r},${c}) beside the body`);
assert.equal(await pick(2, 15), 3, 'a regular enemy on its tile');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+25 -1
View File
@@ -1,13 +1,15 @@
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js).
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js). User playtest #5
// item 2: an operator entering 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot, TUPLE } from '../../public/js/render/interp.js';
import { FORCED_EXIT as SIM_FORCED_EXIT } from '../../server/sim/constants.js';
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
before(async () => {
@@ -102,6 +104,28 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.equal(v._downRing.text.text, '18');
});
test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => {
const { FORCED_EXIT, showsDeathFx } = await import('../../public/js/render/app.js');
assert.equal(FORCED_EXIT, SIM_FORCED_EXIT, 'the renderer mirrors the sim reason');
const info = { side: 'ally', kind: 'chess' };
assert.equal(showsDeathFx(info, false, FORCED_EXIT), false, 'no death particles');
assert.equal(showsDeathFx(info, false, 'killed'), true, 'a knock-out keeps them');
const v = view();
frames(v, 2);
v.die(true); // b.ev ['die', id, 'forcedExit'] (render/app.js handleEvent)
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30, 'no fall: the end of the Die clip');
v.setDown([1, 70, 70, DOWN_STATE.COUNTING], 0.03); // b.snap down, same frame
frames(v, 240, 1 / 60, () => 2);
assert.equal(v.remove, false, 'stays on its tile');
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '68');
const w = view({ id: 2 });
frames(w, 2);
w.die();
assert.ok(w.dieT < 1, 'an ordinary death still plays the clip from its start');
});
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
+102
View File
@@ -0,0 +1,102 @@
// test/render/forms.browser.test.js — user playtest #5 item 1 in headless Chrome: a 掠海漂移体 hovering down act2 m01's
// lower lane is stunned, drops to 爬行模式 for good (sim content/enemies.js kitSyufo: a ground unit from then on, blocked
// and hit by 山), and its view follows — the real sim runs in the page (the client runner's loadBrowserSim) and feeds the
// render demo's field view: before the drop the hover clips (*_01), on the drop 'Change', then the crawl clips (*_02)
// while 山 blocks it (render/app.js fx 'phase' → render/units.js setForm / FORMS). It used to keep hovering.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/forms.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/forms-syufo-crawl.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// 山 on (9,8) facing the gate; one tanky, harmless 掠海漂移体 walking the lower lane (9,10) → (9,2)
const SPEC = buildBattleSpec({
battleId: 'e2e.forms', fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 12, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_5_17_a', row: 9, col: 8, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
spawns: [{ time: 0, enemyKey: 'enemy_2025_syufo', routeIndex: 0, count: 1, mods: { hpMul: 50, atkMul: 0.01 } }],
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
});
describe('掠海漂移体 drops to 爬行模式: its model crawls (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('hover clips before the stun, \'Change\' on the drop, then the crawl clips while 山 blocks and hits it', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const log = await page.evaluate(async (spec) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const out = [];
let stunAt = null;
for (let i = 0; i < 30 * 14; i++) {
b.step();
const e = b.enemies.find((x) => x.defId === 'enemy_2025_syufo');
if (stunAt == null && e && b.time >= 6) { stunAt = b.time; b.applyStatus(e, 'stun', { duration: 1, source: null }); }
if (i % 3 === 2) {
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
if (i === 30 * 3) await new Promise((r) => setTimeout(r, 2000)); // the Spine models load
const view = e && v.debug.views.get(e.id);
if (e && i % 6 === 5) out.push({ t: +b.time.toFixed(2), flying: e.isFlying, blocked: !!e.blockedBy, clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady });
}
}
return { out, stunAt };
}, SPEC);
await page.screenshot({ path: path.join(OUT, 'forms-syufo-crawl.png') });
const before = log.out.filter((x) => x.spine && x.t < log.stunAt);
assert.ok(before.length > 0, 'the model loaded before the stun');
assert.ok(before.every((x) => x.flying && /_01$/.test(x.clip)), `hovering: the *_01 clips (${[...new Set(before.map((x) => x.clip))]})`);
const after = log.out.filter((x) => x.t > log.stunAt);
assert.ok(after.some((x) => x.clip === 'Change'), `the drop plays 'Change' (${[...new Set(after.map((x) => x.clip))]})`);
const late = after.filter((x) => x.t > log.stunAt + 3);
assert.ok(late.length > 0 && late.every((x) => !x.flying && x.form === 'crawl' && /_02$/.test(x.clip)), `crawling: the *_02 clips (${[...new Set(late.map((x) => x.clip))]})`);
assert.ok(late.some((x) => x.blocked), '山 blocks it once it crawls');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+122
View File
@@ -0,0 +1,122 @@
// test/render/forms.test.js — user playtest #5 item 1, client side: 掠海漂移体 drops from 近地悬浮 to 爬行模式 for good
// when stunned / frozen / put to sleep (sim content/enemies.js kitSyufo, fx 'phase' { id, kind: 'crawl' }); from then on
// melee operators block and hit it, so its model must stop hovering: render/units.js FORMS switches the view to the
// skeleton's crawl clips (*_02) after its 'Change' clip (render/spine.js SpineActor.setForm), also for a view built
// after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js).
import { test, describe, before, after } 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 { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const SYUFO = 'enemy_2025_syufo';
let fake, UnitView, FORMS;
before(async () => {
fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
/** Asset store with the real manifest entry of the skeleton and its animation names. */
function store(id) {
const entry = assets.enemies[id].spine;
const names = Object.keys(entry.animations || {});
return {
picture: () => null,
image: async () => null,
spineEntry: () => entry,
spine: { acquire: async () => ({ animations: names.map((name) => ({ name })) }), release() {} },
};
}
async function syufo(info = {}) {
const ctx = fakeViewCtx(fake.P, { assets: store(SYUFO), cam });
const v = new UnitView(ctx, { id: 7, side: 'enemy', kind: 'enemy', defId: SYUFO, spine: SYUFO, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
await tick(); await tick();
assert.ok(v.actor, 'Spine model built');
return v;
}
const clip = (v) => v.actor.current;
const sample = (flags = 0, anim = 0) => ({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags, anim, vx: 0 });
const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); };
describe('掠海漂移体 爬行模式 (FORMS)', () => {
test('the crawl set exists in the skeleton the manifest lists', () => {
const f = FORMS[SYUFO].crawl;
const anims = assets.enemies[SYUFO].spine.animations;
for (const name of [f.change, f.roles.idle, f.roles.die, f.roles.move.loop, f.roles.attack.loop]) assert.ok(name in anims, name);
assert.equal(assets.enemies[SYUFO].spine.anims.idle, 'Idle_01', 'the manifest keeps the hover set as the default');
});
test('hovering: the hover clips; on the change: \'Change\' once, then the crawl clips (idle, move, attack, die)', async () => {
const v = await syufo();
v.sync(sample(), 1);
assert.equal(clip(v), 'Idle_01');
v.setForm('crawl');
assert.equal(clip(v), 'Change', 'the transition first');
frames(v, 50); // Change is 0.667 s
assert.equal(clip(v), 'Idle_02', 'crawling idle');
v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), 'Move_02');
v.onAttack?.(null, 2.1);
assert.equal(clip(v), 'Attack_02');
v.die();
assert.equal(clip(v), 'Die_02');
});
test('stunned when it drops (the usual case): the change plays out, then the stun holds, then it crawls', async () => {
const v = await syufo();
v.sync(sample(UF.STUNNED), 1);
assert.equal(v.actor.mode, 'stun');
v.setForm('crawl');
assert.equal(clip(v), 'Change');
frames(v, 10);
v.sync(sample(UF.STUNNED), 1.2); // still stunned: the change is not cut short
assert.equal(v.actor.mode, 'change');
frames(v, 40);
assert.equal(v.actor.mode, 'stun', 'then the stun holds (its pose frozen)');
v.sync(sample(0), 3);
assert.equal(clip(v), 'Idle_02', 'the stun over: crawling');
});
test('a view built after the change (culled, rejoined) starts on the crawl set without replaying \'Change\'', async () => {
const v = await syufo({ form: 'crawl' });
assert.equal(v.form, 'crawl');
v.sync(sample(), 1);
assert.equal(clip(v), 'Idle_02');
const w = await syufo({ form: 'crawl' });
w.die();
assert.equal(clip(w), 'Die_02');
});
test('modes without a clip set change nothing; the same mode twice is a no-op', async () => {
const v = await syufo();
v.setForm('grounded');
assert.equal(clip(v), 'Idle_01');
v.setForm('crawl');
frames(v, 50);
v.setForm('crawl');
assert.equal(clip(v), 'Idle_02', 'no second Change');
const ctx = fakeViewCtx(fake.P, { assets: store('enemy_10045_parrot'), cam });
const p = new UnitView(ctx, { id: 8, side: 'enemy', kind: 'enemy', defId: 'enemy_10045_parrot', spine: 'enemy_10045_parrot', tier: 1, x: 8, y: 9, maxHp: 1000 });
await tick(); await tick();
p.setForm('grounded');
p.setForm('float');
assert.equal(clip(p), 'Idle_A', '吉兆飞鳞 keeps its own clips (its 晕眩模式 is the Stun clip)');
});
});
test('render/app.js hands the sim\'s fx \'phase\' to the view and keeps the mode on the unit info', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /e\[1\] === 'phase'[\s\S]{0,300}inf\.form = [\s\S]{0,200}setForm\?\.\(e\[4\]\.kind\)/);
});
+2 -1
View File
@@ -451,7 +451,8 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
const slot = await tileSpot(page, 7, 5);
// the first point below the slot's centre that the shop bar covers (it overlaps the lower half of bench slots 4–9)
// the first point below the slot's centre that the shop bar covers (it starts right below the bench — the prep
// camera keeps the bench clear of it, user playtest #5 item 9; before, it overlapped the lower half of slots 4–9)
const bar = await page.evaluate((p) => {
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
+29 -1
View File
@@ -8,7 +8,8 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { presetCamera, pickTile } from '../../public/js/render/projection.js';
import { pickOnTile, pickBattle, ENEMY_REACH } from '../../public/js/render/pick.js';
import { pickOnTile, pickBattle, ENEMY_REACH, AREA_PICK, hitRectAt } from '../../public/js/render/pick.js';
import { hitRect } from '../../server/sim/body.js';
import { unitDepthKey } from '../../public/js/render/units.js';
import { TILE_H } from '../../public/js/render/style.js';
import { GEO } from '../../shared/constants.js';
@@ -152,6 +153,33 @@ describe('pickBattle: allies by their tile, walking enemies by their ground posi
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
});
test('a huge boss (user playtest #5 item 10): a press anywhere on its hit area picks it; allies on their tile and nearer enemies win', () => {
// 假想敌:胄 at (3,10): 4.95 × 2.95 up 1 → rows 3–5 × cols 8–12 (the sim's rectangle, server/sim/body.js)
const HA = { w: 4.95, h: 2.95, dx: 0, dy: 1 };
const area = hitRectAt(10, 3, HA);
assert.deepEqual(area, hitRect({ x: 10, y: 3, hitArea: HA }), 'the same rectangle as the sim');
assert.equal(hitRectAt(10, 3, null), null);
const boss = { key: 'boss', tile: null, x: 10, y: 3, area, body: { x: 600, top: 600 - 3 * 90, feet: 600, s: 90 } };
const far = [2000, 2000]; // pointer far from its drawn body line on screen: only the ground area counts
for (const [x, y] of [[8, 3], [12.4, 5.4], [7.6, 4], [10, 5]]) assert.equal(pickBattle([boss], at(x, y), ...far)?.key, 'boss', `(${x},${y}) on the area`);
for (const [x, y] of [[7.4, 4], [10, 5.6], [9, 2.3], [12.6, 3]]) assert.equal(pickBattle([boss], at(x, y), ...far), null, `(${x},${y}) off the area`);
const A = ally('A', 4, 8), e = foe('e', 11, 4.2);
assert.equal(pickBattle([boss, A], at(8.3, 4.3), ...far).key, 'A', 'an ally on the fence tile inside the area');
assert.equal(pickBattle([boss, A], at(9.2, 4.3), ...far).key, 'boss', 'the next tile: the boss');
assert.equal(pickBattle([boss, e], at(11.2, 4.1), ...far).key, 'e', 'a regular enemy near the pointer');
assert.ok(AREA_PICK > Math.SQRT1_2 && AREA_PICK > ENEMY_REACH);
// a flying unit never uses a ground area
assert.equal(pickBattle([{ ...boss, fly: true }], at(9, 4), ...far), null);
// the drawn body: an upright box from its feet to its head, hw tiles either side (its px per tile) — off the grid
// too
const big = { ...boss, body: { ...boss.body, hw: 2.475 } };
assert.equal(pickBattle([big], null, 600 + 2.2 * 90, 600 - 2.8 * 90)?.key, 'boss', 'its shoulder, far from the centre line');
assert.equal(pickBattle([boss], null, 600 + 2.2 * 90, 600 - 2.8 * 90), null, 'without hw: the centre line only');
assert.equal(pickBattle([big], null, 600 + 2.6 * 90, 600 - 1 * 90), null, 'beside the box');
assert.equal(pickBattle([big], null, 600 + 1.5 * 90, 600 - 3.8 * 90), null, 'above its head (beyond the line reach too)');
assert.equal(pickBattle([big, A], at(8.1, 4.1), 600 - 2 * 90, 600 - 1 * 90).key, 'A', 'the ally on its tile inside the box');
});
test('bad input: nothing', () => {
assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
+144
View File
@@ -0,0 +1,144 @@
// User playtest #5 item 6 — "有两个吹风的风道确实做出来了,但是吹风机的贴图丢失了,反而在关底吹风机的贴图又出现了":
// act2autochess_m01 (战场#05(下半) 源石流发生装置) has 8 blowers (trap_013_blower, dir DOWN): the normal field's on the
// separator row 13 (cols 5 / 9, the partner's 13 / 17) blowing into rows 12–10, the boss field's on row 6 (cols 5 / 9 /
// 11 / 15) blowing into rows 5–3. The 3D board's normal / 联防 area stopped at row 12, so the normal field's blowers
// were never built (their airflow streaks — Pixi — still showed), while the boss field's were (the Final Assault view
// and — facing away, under the bench on the row-6 wall — the normal view).
// Now: the normal / 联防 areas take the separator row 13, and every active device standing on a built tile is drawn
// (render/board3d/layout.js stageDevices; the 2D board render/tiles.js _stageDevices follows the same rule) — except
// the boss field's (rows ≤ 6, its top wall under the bench included), drawn with the boss field only: the official
// normal cameras never show that wall (under the shop bar in prep, off-screen in battle), and the remake's prep view
// showed the boss round's machines under the bench ("反而在关底吹风机的贴图又出现了", QA residual of the integration).
// Audit: for every stage and view the 3D board and the 2D board draw the same devices, and every active device is
// drawn exactly in the views whose area holds its tile (the boss field's only in the boss views).
import { test, describe } 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 { buildBoard, stageDevices, classifyStage, AREAS, BOSS_WALL_ROW } from '../../public/js/render/board3d/layout.js';
import { boardArea, bandFor, fieldRows, viewKind } from '../../public/js/render/app.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const M01 = stages.act2autochess_m01;
const devicesIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.map((d) => `${d.kind}@${d.r},${d.c}`).sort();
const blowersIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
/**
* Inside the 3D area of a view (the 2D board also shows the partner's half of rows 9–12 as a display in the own views,
* with its devices; the 3D board builds cols 0–10 there — plus the pen block, whose front wall holds the partner's
* blower (13,13) in the pen view).
*/
const inArea = (vk) => (k) => {
const [r, c] = k.split('@').pop().split(',').map(Number);
return boardArea(vk).some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
};
/** The 2D board's device list for a view kind, within that view's 3D area (external mode: no Pixi meshes needed). */
async function devicesIn2d(st, vk) {
const { installFakePixi } = await import('./fakepixi.js');
const fake = installFakePixi();
try {
const P = globalThis.PIXI;
P.BLEND_MODES = { ADD: 1, NORMAL: 0 };
const { TileField } = await import('../../public/js/render/tiles.js');
const layers = { ground: new P.Container(), overlay: new P.Container(), props: new P.Container(), anim: new P.Container() };
for (const l of Object.values(layers)) l.addChild = function (...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } return kids[0]; };
const f = new TileField(layers);
f.setExternal(true);
f.setView(bandFor(vk), null, fieldRows(vk));
f.setStage(st);
const out = f._stageDevices().map((d) => `${d.role}@${d.pos[0]},${d.pos[1]}`).filter(inArea(vk)).sort();
const flow = f.blowers.map((b) => `${b.row},${b.col}`).filter(inArea(vk)).sort();
f.destroy();
return { devices: out, flow };
} finally {
fake.restore();
}
}
const VIEWS = ['prep', 'normal', 'unite', 'bossPrep', 'boss', 'pen'];
describe('#6 act2 m01 blowers in every view', () => {
test('repro: the normal / prep view builds the own field\'s row-13 blowers (cols 5, 9) above the wind lanes — and not the boss field\'s row-6 machines under the bench', () => {
for (const vk of ['prep', 'normal']) assert.deepEqual(blowersIn3d(M01, vk), ['13,5', '13,9'], vk);
});
test('联防 builds both halves\' row-13 blowers; the Final Assault builds the four row-6 blowers', () => {
assert.deepEqual(blowersIn3d(M01, 'unite'), ['13,13', '13,17', '13,5', '13,9']);
for (const vk of ['boss', 'bossPrep', viewKind('hidden'), viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })]) {
assert.deepEqual(blowersIn3d(M01, vk), ['6,11', '6,15', '6,5', '6,9'], vk);
}
});
test('the blower machines stand on the separator blocks; the row-13 ones blow onto the field below them', () => {
const b = buildBoard(M01, { area: boardArea('prep') });
for (const d of b.devices.filter((x) => x.kind === 'blower')) {
const t = b.grid[d.r][d.c];
assert.equal(t.glyph, 'X');
assert.ok(t.drawn && d.z0 === t.h && t.h > 0, 'on top of the drawn separator block');
assert.equal(d.dir, 'DOWN');
// row 13: onto the built field's lanes (rows 12–10)
assert.equal(d.r, 13, `${d.r},${d.c}: only the field's own machines`);
const onto = d.rangeTiles.slice(1).map(([r, c]) => !!b.grid[r][c].content);
assert.deepEqual(onto, [true, true, true], `${d.r},${d.c}`);
}
// the separator row is built along the field's top edge (the pen rows 14–18 are still not)
const rows = new Set(b.grid.flat().filter((t) => t.drawn).map((t) => t.r));
assert.ok(rows.has(13) && !rows.has(14) && !rows.has(5), [...rows].join());
});
test('the 2D board draws the same blowers and airflow per view', async () => {
for (const vk of VIEWS) {
const d2 = await devicesIn2d(M01, vk);
assert.deepEqual(d2.flow, blowersIn3d(M01, vk), `${vk}: airflow streaks = built blowers`);
}
});
});
describe('#6 audit: every device of every stage is drawn in the views of the field it belongs to', () => {
test('3D board = 2D board for every stage and view', async () => {
for (const [id, st] of Object.entries(stages)) {
for (const vk of VIEWS) {
const d3 = devicesIn3d(st, vk);
const d2 = (await devicesIn2d(st, vk)).devices;
assert.deepEqual(d3, d2, `${id} ${vk}`);
}
}
});
test('every active device standing on the built island of a view is drawn — every field device and every device acting on the field among them', () => {
const KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
const inRect = (r, c, R) => r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1;
// the fields inside their separator walls (normal / 联防: rows 7–12; boss: rows 0–5); the boss field's devices
// (rows ≤ 6: its top wall under the bench too) are drawn in the boss views only
const INNER = { prep: { r0: 7, r1: 12, c0: 0, c1: 10 }, unite: { r0: 7, r1: 12, c0: 0, c1: 20 }, boss: { r0: 0, r1: 5, c0: 0, c1: 20 } };
for (const [id, st] of Object.entries(stages)) {
for (const [vk, F] of Object.entries(INNER)) {
const b = buildBoard(st, { area: boardArea(vk) });
const built = new Set(b.devices.map((d) => `${d.kind}@${d.r},${d.c}`));
for (const d of st.devices || []) {
if (!KINDS.has(d.role) || !(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
const [r, c] = d.pos;
const key = `${d.role}@${r},${c}`;
const onIsland = boardArea(vk).some((a) => inRect(r, c, a)) && !!b.grid[r]?.[c]?.drawn && (vk === 'boss' || r > BOSS_WALL_ROW);
const acts = (d.rangeTiles || []).some(([rr, cc]) => (rr !== r || cc !== c) && inRect(rr, cc, F) && b.grid[rr]?.[cc]?.content);
assert.equal(built.has(key), onIsland, `${id} ${vk}: ${key} (${d.alias || d.key}) drawn iff on the built island`);
if (inRect(r, c, F) || acts) assert.ok(built.has(key), `${id} ${vk}: ${key} of this field drawn`);
}
}
}
});
test('stageDevices keeps every active device on a drawn tile — the boss field\'s only when the area builds the boss field', () => {
const blowers = (area) => stageDevices(M01, classifyStage(M01, area), area).filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
assert.deepEqual(blowers(AREAS.boss), ['6,11', '6,15', '6,5', '6,9']);
assert.deepEqual(blowers(AREAS.normal), ['13,13', '13,5', '13,9'], 'the pen block\'s front wall holds the partner\'s (13,13); the row-6 wall under the bench none');
assert.deepEqual(blowers([...AREAS.boss, ...AREAS.normal]), ['13,13', '13,5', '13,9', '6,11', '6,15', '6,5', '6,9'], 'a camera flight between the fields: both');
assert.equal(blowers(AREAS.all).length, 8, 'the whole map: all 8');
assert.equal(stageDevices(M01, classifyStage(M01, null)).filter((d) => d.kind === 'blower').length, 8, 'no area: the whole map');
});
});
+135 -1
View File
@@ -7,7 +7,7 @@ import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
Camera, fitCamera, presetCamera, lerpCamera, pickTile, tileQuad, normRect, CAMERA_PRESETS, easeInOutCubic,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera, clearHud,
} from '../../public/js/render/projection.js';
import * as THREE from 'three';
@@ -247,6 +247,140 @@ describe('pickTile / tileQuad', () => {
});
});
describe('HUD clearance of the prep views (user playtest #5 item 9)', () => {
// the in-match HUD in rem (ui/fieldHost.js HUD_REM): the bond strip ends 2.16rem below the top, the shop bar starts
// 2.64rem + 3 px above the bottom; the root font size is clamp(40px, min(W / 19.2, H / 10.8), 240px) (css/theme.css)
const hudAt = (w, h) => {
const rem = Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
return { top: 2.16 * rem, bottom: 2.64 * rem + 3 };
};
const band = (cam, kind) => {
const k = CAMERA_PRESETS[kind].keep;
return { near: cam.project(cam.tx, k.near, k.zNear).y, far: cam.project(cam.tx, k.far, k.zFar).y };
};
// phones in landscape (CSS px): iPhone 12–15 (19.5:9), iPhone Pro Max, Galaxy S20+ / 2400×1080 (20:9), 800×360,
// Xperia 21:9, 924×424, 740×360, and the user's Android: its screenshot is 2772×1272 px with the page drawn right of
// a 141 px black cutout band, i.e. 2631×1272 px at DPR ≈ 3.48 → 756×366 (its exit and 准备就绪 buttons match
// to ±1 px there), and 798×366 if the page also covered the cutout
const PHONES = [[844, 390], [932, 430], [915, 412], [914, 411], [800, 360], [960, 411], [756, 366], [798, 366], [924, 424], [740, 360]];
const DESKTOPS = [[1920, 1080], [1680, 1050], [1280, 720], [1366, 768], [1440, 900], [1536, 864], [1600, 900], [2560, 1440],
[3840, 2160], [2560, 1080], [3440, 1440], [1600, 1200], [1180, 820], [1024, 768], [1920, 1200]];
const cams = (kind, w, h, hud) => (kind === 'bossPrep' ? ['L', 'R'] : ['L']).map((side) => [side, presetCamera(kind, { width: w, height: h }, { side, hud })]);
test('reproduction: the official shop camera puts the bench under the shop bar on phones (844×390: ~11 px, 800×360: ~19 px, 756×366 as on the user\'s Android: ~17 px)', () => {
for (const [w, h] of [[844, 390], [800, 360], [756, 366]]) {
for (const kind of ['prep', 'bossPrep']) {
const b = band(presetCamera(kind, { width: w, height: h }), kind);
assert.ok(b.near > h - hudAt(w, h).bottom + 8, `${kind} ${w}×${h}: bench edge ${b.near} vs shop bar ${h - hudAt(w, h).bottom}`);
}
}
});
test('with the HUD bands the bench clears the shop bar and the back row the bond strip, as large as fits', () => {
for (const [w, h] of PHONES) {
const hud = hudAt(w, h);
const top = hud.top, bottom = h - hud.bottom;
for (const kind of ['prep', 'bossPrep']) {
for (const [side, cam] of cams(kind, w, h, hud)) {
const off = presetCamera(kind, { width: w, height: h }, { side });
const b = band(cam, kind), b0 = band(off, kind);
const tag = `${kind}/${side} ${w}×${h}`;
assert.ok(b.near <= bottom + 1e-6, `${tag}: bench edge ${b.near} above the shop bar ${bottom}`);
assert.ok(b.far >= top - 1e-6, `${tag}: back row ${b.far} below the bond strip ${top}`);
// the official perspective (a 2D pan / zoom of the image): same pinhole, pitch and target
for (const k of ['tx', 'ty', 'tz', 'tilt', 'dist']) assert.equal(cam[k], off[k], `${tag}: ${k}`);
const f = cam.scale / off.scale;
assert.ok(f <= 1 + 1e-12 && f > 0.8, `${tag}: zoom ${f}`);
// zoomed out only as far as needed: the band then fills the space between the HUD bands less 1 px each end
if (f < 1 - 1e-9) assert.ok(near(b.near - b.far, bottom - top - 2, 1e-6), `${tag}: fills the free space`);
else assert.ok(near(b.near - b.far, b0.near - b0.far, 1e-6), `${tag}: pan only`);
}
}
}
});
test('desktop viewports keep the official prep camera (16:9 is flush: bench on the shop bar, back row under the strip)', () => {
for (const [w, h] of DESKTOPS) {
const hud = hudAt(w, h);
assert.deepEqual(presetCamera('prep', { width: w, height: h }, { hud }).params(), presetCamera('prep', { width: w, height: h }).params(), `prep ${w}×${h}`);
// the Final Assault bench sits up to 1.2 px lower (under the bar's top edge at 16:9): nudged up < 3 px, no zoom
for (const [side, cam] of cams('bossPrep', w, h, hud)) {
const off = presetCamera('bossPrep', { width: w, height: h }, { side });
assert.equal(cam.scale, off.scale, `bossPrep/${side} ${w}×${h}: no zoom`);
assert.ok(cam.cy <= off.cy && off.cy - cam.cy < 3, `bossPrep/${side} ${w}×${h}: nudge ${off.cy - cam.cy}`);
}
}
// other camera kinds ignore the HUD bands; the fitted (portrait) prep camera too
for (const kind of ['normal', 'unite', 'boss', 'pen']) {
assert.deepEqual(presetCamera(kind, { width: 844, height: 390 }, { hud: hudAt(844, 390) }).params(), presetCamera(kind, { width: 844, height: 390 }).params(), kind);
}
assert.deepEqual(presetCamera('prep', { width: 390, height: 844 }, { hud: hudAt(390, 844) }).params(), presetCamera('prep', { width: 390, height: 844 }).params(), 'portrait');
});
test('clearHud: unchanged when clear, pan when the band fits, zoom about the centre when it does not; garbage-safe', () => {
const vp = { width: 1000, height: 500 };
const keep = { near: 6.5, zNear: 0.16, far: 12.5, zFar: 0.42 };
const cam = presetCamera('prep', vp);
const b0 = band(cam, 'prep');
const bandH = b0.near - b0.far;
assert.equal(clearHud(cam, { top: b0.far - 10, bottom: vp.height - b0.near - 10 }, keep, vp), cam, 'clear → same object');
assert.equal(clearHud(cam, { top: b0.far + 0.4, bottom: vp.height - b0.near + 0.4 }, keep, vp), cam, '≤ 0.5 px overlap is clear');
// the bottom band reaches 30 px over the bench, 60 px free above: pan up by 31 px (1 px gap), no zoom
const pan = clearHud(cam, { top: b0.far - 60, bottom: vp.height - b0.near + 30 }, keep, vp);
assert.equal(pan.scale, cam.scale);
assert.equal(pan.cx, cam.cx);
assert.ok(near(pan.cy - cam.cy, -31, 1e-9), `pan ${pan.cy - cam.cy}`);
// 5 px over the bench and only 6 px above the back row: the gap shrinks to half the spare pixel
const tight = clearHud(cam, { top: b0.far - 6, bottom: vp.height - b0.near + 5 }, keep, vp);
assert.equal(tight.scale, cam.scale);
assert.ok(near(tight.cy - cam.cy, -5.5, 1e-9), `tight ${tight.cy - cam.cy}`);
// top band over the back row: pan down
const down = clearHud(cam, { top: b0.far + 20, bottom: 0 }, keep, vp);
assert.ok(near(down.cy - cam.cy, 21, 1e-9));
// both: zoom out about the centre column to fill [top + 1, H − bottom − 1]
const hud = { top: b0.far + 20, bottom: vp.height - b0.near + 20 };
const z = clearHud(cam, hud, keep, vp);
const f = (bandH - 42) / bandH;
assert.ok(near(z.scale / cam.scale, f, 1e-9), `zoom ${z.scale / cam.scale}`);
const bz = band(z, 'prep');
assert.ok(near(bz.far, hud.top + 1, 1e-6) && near(bz.near, vp.height - hud.bottom - 1, 1e-6));
const c0 = cam.project(0, 9, 0), c1 = z.project(0, 9, 0);
assert.ok(near(c1.x - vp.width / 2, f * (c0.x - vp.width / 2), 1e-6), 'x shrinks towards the centre');
assert.ok(near(c1.s, f * c0.s, 1e-9), 'px per tile scales by f');
// no usable space / bad input: the official camera
for (const bad of [null, 'x', { top: 300, bottom: 300 }, { top: NaN, bottom: -5 }]) {
const out = clearHud(cam, bad, keep, vp);
assert.ok(out === cam || out.params().scale === cam.scale, JSON.stringify(bad));
for (const v of Object.values(out.params())) assert.ok(Number.isFinite(v));
}
assert.equal(clearHud(cam, hud, null, vp), cam);
});
test('an adjusted camera picks, projects and lerps like any other (bench, raised and low tiles; three.js too)', () => {
const heights = (r, c) => (r === 7 ? 0.16 : r === 12 && c === 4 ? 0.42 : 0);
for (const [w, h] of [[844, 390], [800, 360]]) {
const cam = presetCamera('prep', { width: w, height: h }, { hud: hudAt(w, h) });
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
const p = cam.project(c, r, heights(r, c));
const t = pickTile(cam, p.x, p.y, heights, [0.42, 0.16, 0]);
assert.deepEqual([t.row, t.col], [r, c], `${w}×${h} pick ${r},${c}`);
}
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), w, h);
const v = new THREE.Vector3();
let worst = 0;
for (let r = 0; r < 19; r++) for (let c = 0; c < 21; c++) {
if (cam.depthOf(c, r, 0) < 0.5) continue;
const p = cam.project(c, r, 0);
v.set(c, r, 0).project(three);
worst = Math.max(worst, Math.abs((v.x + 1) / 2 * w - p.x), Math.abs((1 - v.y) / 2 * h - p.y));
}
assert.ok(worst < 1e-6, `${w}×${h} three.js: ${worst} px`);
const m = lerpCamera(presetCamera('normal', { width: w, height: h }), cam, 1).params();
for (const [k, x] of Object.entries(cam.params())) assert.ok(near(m[k], x, 1e-9), k);
}
});
});
describe('three.js PerspectiveCamera equivalence (the 3D board layer)', () => {
const kinds = ['prep', 'normal', 'unite', 'boss', 'pen', 'bossPrep'];
+138
View File
@@ -0,0 +1,138 @@
// test/render/unitedown.browser.test.js — user playtest #5 item 2 in headless Chrome: a 联防 helper's operator knocked
// out in its own combat enters the 联防 field down. The real sim runs in the page (the /sim/ modules and /data/*.json,
// like the client runner's loadBrowserSim) on a 联防 BattleSpec whose operator carries `carryState: { down: true }`,
// and feeds the render demo's field view frame by frame (local feed, the runner's b.snap / b.ev frames): from the first
// frame the operator lies on its tile in its held Die pose (no fall) under the redeploy ring counting its full timer
// while its teammate stands, then it comes back on its tile.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/unitedown.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/unitedown-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// the right-hand helper of a two-helper 联防 (colOffset +8): 德克萨斯 knocked out in its own combat,
// with 艾雅法拉 standing at half HP; the only enemy spawns long after the check (it keeps the battle running)
const SPEC = buildBattleSpec({
battleId: 'e2e.1.1.u', fieldId: 'u', kind: 'unite', seed: 77, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01',
rect: { ...GEO.UNITE_RECT }, timeLimit: 400, flags: { layerGainsEnabled: false },
spawns: [{ time: 390, enemyKey: 'enemy_1000_gopro_2', routeIndex: 0 }], routes: [{ motion: 'WALK', start: [12, 18], end: [9, 2], checkpoints: [] }],
players: [{
playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, bonds: {}, playerEffects: [],
units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_1_08_a', row: 10, col: 4, dir: 'RIGHT', items: [], carryState: { down: true } },
{ uid: 2, kind: 'chess', chessId: 'chess_char_6_20_a', row: 11, col: 3, dir: 'RIGHT', items: [], carryState: { hpPct: 0.5, sp: 0, skillActive: false } },
],
}],
});
/** Page side: the sim in the browser + a feed into the demo view. `advance(ticks)` steps and pushes one frame. */
async function installSim(spec) {
const [S, simdata, support] = await Promise.all([import('/sim/spec.js'), import('/sim/simdata.js'), import('/sim/content/support/index.js')]);
const names = ['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects'];
const raw = {};
for (const n of names) raw[n] = await fetch(`/data/${n}.json`).then((r) => r.json());
simdata.setSimData(raw);
if (typeof support.setGameData === 'function') support.setGameData(null);
const battle = S.createBattleFromSpec(spec, new simdata.DataSource(raw, null));
const v = window.__demo.view;
// what the runner's show() publishes: the field meta before the first step (nothing deployed yet)
v.enterBattle({ ...battle.fieldMeta(), fieldId: 'u', kind: 'unite', rect: spec.rect, stageId: spec.stageId });
v.setCamera('unite', { rect: spec.rect, side: 'R', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const frame = () => {
const ev = battle.drainEvents();
const gt = battle.time;
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: 'u', gt, ev });
const { t, ...rest } = battle.snapshot();
v.pushSnapshot({ ...rest, t: 'b.snap', fieldId: 'u', gt: t });
};
frame();
window.__sim = {
battle,
advance(n) { for (let i = 0; i < n; i++) battle.step(); frame(); },
unit(uid) { return battle.allyUnits.find((u) => u.uid === uid); },
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
dieClip: x.actor?.current ?? null, dieT: x.dieT ?? null, alpha: +(x.alpha ?? 0).toFixed(2), x: x.x, y: x.y, spine: !!x.spineReady };
}
return out;
},
};
}
describe('联防: an operator knocked out in its own combat enters down (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('down on its tile from the first frame, ring counting its full timer, then redeployed', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installSim, SPEC);
// real speed for 2 game s (2 ticks per frame at 60 fps), then the Spine models load
const run = (frames, ticks) => page.evaluate(async (frames, ticks) => {
for (let i = 0; i < frames; i++) { window.__sim.advance(ticks); await new Promise((r) => requestAnimationFrame(() => r())); }
}, frames, ticks);
await run(60, 2);
await new Promise((r) => setTimeout(r, 1500));
await run(30, 2);
const texas = await page.evaluate(() => window.__sim.unit(1).id);
const eyja = await page.evaluate(() => window.__sim.unit(2).id);
let st = await page.evaluate(() => window.__sim.state());
await page.screenshot({ path: path.join(OUT, 'unitedown-start.png') });
assert.ok(st[texas], 'the knocked-out operator has a view on the 联防 field');
assert.equal(st[texas].alive, false);
assert.equal(st[texas].down, 0, 'down, its timer counting');
assert.ok(st[texas].ring, 'redeploy ring');
const gt = await page.evaluate(() => window.__sim.battle.time);
assert.ok(gt > 5.9 && ['64', '65', '66'].includes(st[texas].label), `its full 70 s timer (${st[texas].label} s left at ${gt.toFixed(2)} s)`);
assert.equal(st[texas].dieClip, 'Die', 'held Die pose');
assert.ok(st[texas].alpha > 0.8, `drawn (alpha ${st[texas].alpha})`);
assert.deepEqual([Math.round(st[texas].x), Math.round(st[texas].y)], [12, 10], 'on its own tile (board col 4 + 8)');
assert.equal(st[eyja].alive, true, 'the teammate stands');
// fast-forward to the redeploy (DP 10 + 1/s ≥ cost 13 long before the 70 s timer ends)
await run(40, 60);
await run(30, 2);
st = await page.evaluate(() => window.__sim.state());
await page.screenshot({ path: path.join(OUT, 'unitedown-redeployed.png') });
assert.equal(st[texas].alive, true, 'back on its tile');
assert.equal(st[texas].down, null);
assert.equal(st[texas].ring, false);
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});