Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
This commit is contained in:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
@@ -2,8 +2,8 @@
|
||||
// (public/dev/render-demo.html, real Spine models): frames fed to the field view like the client runner does (local
|
||||
// feed) with b.snap `down` / `elem` — a knocked-out operator stays on its tile in its held Die pose under a redeploy
|
||||
// ring counting down, then "DP", then comes back with its deploy clip (a press on its tile selects it meanwhile);
|
||||
// operators and enemies show the official element icon beside their bars, refilling over a 爆发冷却; a summon and an
|
||||
// enemy that die still vanish.
|
||||
// operators and enemies show the official element gauge under their bars (icon + white bar, user playtest #6), refilling
|
||||
// over a 爆发冷却; a summon and an enemy that die still vanish.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/downelem.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/downelem-*.png.
|
||||
@@ -44,7 +44,7 @@ function installFeed() {
|
||||
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,
|
||||
el: x._elRing?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
|
||||
el: x._elBar?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
|
||||
}
|
||||
return out;
|
||||
},
|
||||
@@ -88,8 +88,8 @@ describe('knocked-out operators and element gauges in headless Chrome', { skip }
|
||||
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
|
||||
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 3, 4);
|
||||
let st = await page.evaluate(() => window.__feed.state());
|
||||
assert.equal(st[2].el, 'burn', 'operator element icon');
|
||||
assert.equal(st[4].el, 'neural', 'enemy element icon (cooldown)');
|
||||
assert.equal(st[2].el, 'burn', 'operator element gauge');
|
||||
assert.equal(st[4].el, 'neural', 'enemy element gauge (cooldown)');
|
||||
assert.equal(st[1].el, null);
|
||||
// phase 2: texas (1) is knocked out, the wolf summon (3) and an enemy (5) die
|
||||
await page.evaluate(() => {
|
||||
@@ -128,7 +128,7 @@ describe('knocked-out operators and element gauges in headless Chrome', { skip }
|
||||
assert.equal(st[1].alive, true, 'back on its tile');
|
||||
assert.equal(st[1].down, null);
|
||||
assert.equal(st[1].ring, false);
|
||||
assert.equal(st[2].el, null, 'the gauge left the snapshot: icon hidden');
|
||||
assert.equal(st[2].el, null, 'the gauge left the snapshot: row hidden');
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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). User playtest #5
|
||||
// item 2: an operator entering 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
|
||||
// and draws the official element gauge — since user playtest #6 a row under the bars: the element icon and a white bar
|
||||
// of the remaining 元素值, refilling in the element's colour over a 爆发冷却 (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';
|
||||
@@ -141,55 +142,91 @@ describe('knocked-down operator (UnitView.setDown)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('element gauge ring', () => {
|
||||
describe('element gauge row (official: element icon + white bar of the remaining 元素值; user playtest #6)', () => {
|
||||
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ...o });
|
||||
/** The white bar's drawn share of its track (the track is 2 px wider: its dark outline). */
|
||||
const share = (r) => (r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0);
|
||||
|
||||
test('operators: the element disc with its glyph in a white ring of the remaining 元素值', () => {
|
||||
test('operators: the element disc with its glyph and a white bar of the remaining 元素值 (1 − fill)', () => {
|
||||
const v = view();
|
||||
v.sync(sample(), 5);
|
||||
frames(v, 2);
|
||||
assert.equal(v._elRing, null, 'nothing built without a gauge');
|
||||
assert.equal(v._elBar, null, 'nothing built without a gauge');
|
||||
v.sync(sample({ el: 'burn', elFill: 0.25 }), 5);
|
||||
frames(v, 1);
|
||||
const r = v._elRing;
|
||||
const r = v._elBar;
|
||||
assert.ok(r.root.visible);
|
||||
assert.equal(r.disc.texture, T.hudRings().disc.burn);
|
||||
assert.equal(r.arc.texture, T.ringArc(0.75), 'remaining EP = 1 − fill');
|
||||
assert.ok(Math.abs(share(r) - 0.75) < 1e-9, `remaining EP = 1 − fill (${share(r)})`);
|
||||
assert.equal(r.fill.tint, 0xffffff, 'a white bar');
|
||||
v.sync(sample({ el: 'neural', elFill: 0.6 }), 5);
|
||||
frames(v, 1);
|
||||
assert.equal(r.disc.texture, T.hudRings().disc.neural);
|
||||
assert.equal(r.arc.texture, T.ringArc(0.4));
|
||||
assert.ok(Math.abs(share(r) - 0.4) < 1e-9);
|
||||
assert.equal(v.elementLeft(), 0.4);
|
||||
const kids = r.root.children.length;
|
||||
frames(v, 30);
|
||||
assert.equal(r.root.children.length, kids, 'no per-frame allocation');
|
||||
v.sync(sample(), 6);
|
||||
frames(v, 1);
|
||||
assert.equal(r.root.visible, false, 'hidden once the gauges are empty');
|
||||
});
|
||||
|
||||
test('enemies: a smaller plain disc; during a 爆发冷却 the ring refills over the cooldown', () => {
|
||||
test('enemies: a smaller plain disc; during a 爆发冷却 the bar refills over the cooldown', () => {
|
||||
const e = view({ id: 3, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
|
||||
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 10);
|
||||
frames(e, 1);
|
||||
const r = e._elRing;
|
||||
const r = e._elBar;
|
||||
assert.equal(r.disc.texture, T.hudRings().discEnemy.apoptosis);
|
||||
assert.equal(r.arc.texture, T.ringArc(0), 'empty at the burst');
|
||||
assert.equal(share(r), 0, 'empty at the burst');
|
||||
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 17.5);
|
||||
frames(e, 1);
|
||||
assert.equal(r.arc.texture, T.ringArc(0.5), 'half refilled halfway through');
|
||||
assert.ok(Math.abs(share(r) - 0.5) < 1e-9, 'half refilled halfway through');
|
||||
const op = view({ id: 4 });
|
||||
op.sync(sample({ el: 'apoptosis', elFill: 0.5 }), 1);
|
||||
frames(op, 1);
|
||||
assert.ok(r.root.scale.x < op._elRing.root.scale.x, 'the enemy icon is smaller');
|
||||
assert.ok(r.disc.width < op._elBar.disc.width, 'the enemy icon is smaller');
|
||||
});
|
||||
|
||||
test('a burst lock status (爆发冷却) is not repeated in the status row while the element icon shows it', () => {
|
||||
test('the 爆发冷却 refill is not the white 元素值 bar: element colour, pulsing disc; white again after the cooldown', () => {
|
||||
const op = view({ id: 6 });
|
||||
op.sync(sample({ el: 'neural', elFill: 0.97 }), 30);
|
||||
frames(op, 1);
|
||||
const r = op._elBar;
|
||||
assert.equal(r.fill.tint, 0xffffff, 'the 元素值 left: white');
|
||||
assert.equal(r.disc.alpha, 1);
|
||||
// the burst: 神经 stun + 1000 true damage while the bar refills over the 10 s cooldown
|
||||
op.sync(sample({ el: 'neural', elFill: 1, elUntil: 40.5, elDur: 10 }), 34);
|
||||
const alphas = new Set();
|
||||
for (let i = 0; i < 40; i++) { op.update(1 / 60, cam(), 100 + i / 60); alphas.add(Math.round(r.disc.alpha * 100)); }
|
||||
assert.ok(op.elementCooling());
|
||||
assert.ok(Math.abs(share(r) - 0.35) < 1e-9, `refilled by the cooldown run (${share(r)})`);
|
||||
assert.equal(r.fill.tint, T.ELEMENT_RING.neural.tint, 'the refill is drawn in the element\'s colour');
|
||||
assert.notEqual(r.fill.tint, 0xffffff);
|
||||
assert.ok(alphas.size > 5 && Math.min(...alphas) >= 44 && Math.max(...alphas) <= 100, `the disc pulses (${[...alphas]})`);
|
||||
for (const el of ['erosion', 'burn', 'apoptosis']) {
|
||||
op.sync(sample({ el, elFill: 1, elUntil: 44, elDur: 10 }), 35);
|
||||
frames(op, 1);
|
||||
assert.equal(r.fill.tint, T.ELEMENT_RING[el].tint, el);
|
||||
}
|
||||
// the cooldown ends: every gauge resets; a new hit shows white again
|
||||
op.sync(sample({ el: 'burn', elFill: 0.1 }), 45);
|
||||
frames(op, 1);
|
||||
assert.equal(op.elementCooling(), false);
|
||||
assert.equal(r.fill.tint, 0xffffff);
|
||||
assert.equal(r.disc.alpha, 1);
|
||||
});
|
||||
|
||||
test('a burst lock status (爆发冷却) is not repeated in the status row while the element gauge shows it', () => {
|
||||
const e = view({ id: 5, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
|
||||
e.onStatus('burnBurst', true);
|
||||
e.onStatus('fragile', true);
|
||||
assert.deepEqual([...e._iconKeys()], ['burn', 'fragile'], 'no gauge in the feed: the status shows the burst');
|
||||
e.sync(sample({ el: 'burn', elFill: 1, elUntil: 20, elDur: 10 }), 12);
|
||||
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element icon carries the 爆发冷却');
|
||||
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element gauge carries the 爆发冷却');
|
||||
});
|
||||
|
||||
test('the atlas: one frame per step, every element in both styles', () => {
|
||||
test('the atlas: one ring frame per step (the redeploy ring), every element disc in both styles', () => {
|
||||
const R = T.hudRings();
|
||||
assert.equal(R.arcs.length, T.RING_STEPS + 1);
|
||||
for (const el of ['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']) { assert.ok(R.disc[el]); assert.ok(R.discEnemy[el]); }
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// test/render/elembar.browser.test.js — user playtest #6 report 11 in headless Chrome ("干员血条右边的元素损伤条积累速度
|
||||
// 不对,损伤条远远没扣完但是实际已经爆条"): a real battle (古米 against 底海滑动者) is simulated in Node, its frames are fed
|
||||
// to the real field view (public/dev/render-demo.html, real Spine models) the way the client runner does (local feed,
|
||||
// one tick per animation frame), and the drawn element gauge is read back every frame:
|
||||
// · the white bar is the sim's remaining 元素值 at the frame shown (within 1 %, never below it, never empty before the
|
||||
// burst), and the burst shows as the bar refilling over the 爆发冷却 in the element's colour (never white);
|
||||
// · a row of five operators with gauges (desktop and a 844×390 phone at DPR 3): each gauge row lies under its own
|
||||
// unit's bars and inside their span (the v2.3 ring sat on the next operator's tier chip and bars).
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/elembar.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/elembar-*.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 { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { ELEMENT_ORDER } from '../../server/sim/constants.js';
|
||||
import { ELEMENT_RING } from '../../public/js/render/textures.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)';
|
||||
const NEURAL_TINT = ELEMENT_RING.neural.tint;
|
||||
|
||||
/** Real frames: 古米 blocking 底海滑动者 (15 % ATK 神经 per hit) until past its first burst. */
|
||||
function recordBattle(seconds = 54) {
|
||||
const h = makeBattle({ units: [{ chessId: 'chess_char_1_10_a', row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
|
||||
h.step();
|
||||
h.spawn('enemy_1148_dssbr', { pos: [10, 5.6], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000 } });
|
||||
const u = h.allies()[0];
|
||||
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
|
||||
h.b.drainEvents();
|
||||
const frames = [];
|
||||
for (let i = 0; i < seconds * 30; i++) {
|
||||
h.step();
|
||||
if (u.alive) u.hp = u.s.maxHp;
|
||||
let bv = 0, el = null;
|
||||
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
|
||||
const { t, ...rest } = h.b.snapshot();
|
||||
frames.push({ snap: { ...rest, t: 'b.snap', gt: t }, ev: h.b.drainEvents(), gt: t, el, left: el ? 1 - bv / u.gaugeMax : null, lock: !!u.s.flags.burstLock });
|
||||
}
|
||||
return { meta, frames, opId: u.id };
|
||||
}
|
||||
|
||||
/** Page side: the drawn gauge of unit `id` (null: none) — element, the white bar's share, the render clock. */
|
||||
function readGauge(id) {
|
||||
const v = window.__demo.view;
|
||||
const x = v.debug.views.get(id);
|
||||
const r = x && x._elBar;
|
||||
if (!r || !r.root.visible) return { el: null, renderT: v.debug.interp.renderT };
|
||||
return { el: x.el, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, tint: r.fill.tint, renderT: v.debug.interp.renderT };
|
||||
}
|
||||
|
||||
describe('element gauge row in headless Chrome (user playtest #6)', { 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();
|
||||
});
|
||||
|
||||
const open = async (viewport) => {
|
||||
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()}`); });
|
||||
await page.setViewport(viewport);
|
||||
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 });
|
||||
return { page, problems };
|
||||
};
|
||||
|
||||
test('a real battle: the white bar drawn every frame is the sim\'s remaining 元素值; the burst refills it', async () => {
|
||||
const { meta, frames, opId } = recordBattle();
|
||||
const { page, problems } = await open({ width: 1280, height: 720 });
|
||||
try {
|
||||
await page.evaluate((meta) => {
|
||||
const v = window.__demo.view;
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
}, meta);
|
||||
const byT = new Map(frames.map((f) => [f.gt, f]));
|
||||
const rows = [];
|
||||
const CHUNK = 30;
|
||||
for (let i = 0; i < frames.length; i += CHUNK) {
|
||||
const got = await page.evaluate(async (chunk, id, readSrc) => {
|
||||
const read = new Function(`return (${readSrc})`)();
|
||||
const v = window.__demo.view, out = [];
|
||||
for (const f of chunk) {
|
||||
if (f.ev.length) v.pushEvents({ t: 'b.ev', fieldId: f.snap.fieldId, gt: f.gt, ev: f.ev });
|
||||
v.pushSnapshot(f.snap);
|
||||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||||
const g = read(id);
|
||||
const s = v.debug.interp.snaps;
|
||||
let shown = s[0]?.t;
|
||||
for (const x of s) if (x.t <= g.renderT) shown = x.t;
|
||||
out.push({ ...g, shownT: shown });
|
||||
}
|
||||
return out;
|
||||
}, frames.slice(i, i + CHUNK), opId, readGauge.toString());
|
||||
rows.push(...got);
|
||||
}
|
||||
let checked = 0, refills = 0, prevLock = false, lastBefore = null;
|
||||
for (const row of rows) {
|
||||
const f = byT.get(row.shownT);
|
||||
if (!f) continue;
|
||||
if (f.lock) {
|
||||
assert.equal(row.el, 'neural', 'the 爆发冷却 shows');
|
||||
if (!prevLock) { refills++; assert.ok(row.share < 0.03, `the refill starts empty (${row.share})`); }
|
||||
assert.equal(row.tint, NEURAL_TINT, 'the refill is drawn in the 神经 colour, not white');
|
||||
} else if (f.el) {
|
||||
checked++;
|
||||
assert.equal(row.el, f.el);
|
||||
assert.equal(row.tint, 0xffffff, 'the 元素值 left is white');
|
||||
assert.ok(row.share >= f.left - 1e-6 && row.share - f.left < 0.01 + 1e-6, `gt ${f.gt}: bar ${row.share} vs sim ${f.left}`);
|
||||
assert.ok(row.share > 0, 'never empty before the burst');
|
||||
lastBefore = row.share;
|
||||
} else assert.equal(row.el, null);
|
||||
prevLock = f.lock;
|
||||
}
|
||||
assert.ok(checked > 1000, `${checked} frames checked`);
|
||||
assert.equal(refills, 1, 'one 神经 burst');
|
||||
assert.ok(lastBefore <= 0.05, `the burst comes off a sliver (${lastBefore})`);
|
||||
await page.screenshot({ path: path.join(OUT, 'elembar-cooldown.png') });
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, viewport] of [['desktop', { width: 1280, height: 720 }], ['phone', { width: 844, height: 390, deviceScaleFactor: 3 }]]) {
|
||||
test(`a row of operators (${name}): every gauge row lies under its own unit's bars, inside their span`, async () => {
|
||||
const { page, problems } = await open(viewport);
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
const v = window.__demo.view;
|
||||
const U = (id, side, spine, x, y) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3 });
|
||||
const units = [3, 4, 5, 6, 7].map((c, i) => U(i + 1, 'ally', i % 2 ? 'char_1016_agoat2' : 'char_102_texas', c, 10))
|
||||
.concat([U(6, 'enemy', 'enemy_1000_gopro_2', 3, 11.4), U(7, 'enemy', 'enemy_1000_gopro_2', 6, 11.4)]);
|
||||
v.enterBattle({ fieldId: 'eb', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
|
||||
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
window.__units = units;
|
||||
});
|
||||
const feed = (from, to) => page.evaluate(async (from, to) => {
|
||||
const v = window.__demo.view, units = window.__units;
|
||||
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) {
|
||||
v.pushSnapshot({ t: 'b.snap', fieldId: 'eb', gt: +gt.toFixed(4), units: units.map((u) => [u.id, u.x, u.y, u.side === 'enemy' ? 700 : 1000, 1000, 5, 10, 0, 0]), dp: 5, killed: 0, total: 2,
|
||||
elem: [[1, 'neural', 0.1, 0, 0], [2, 'erosion', 0.5, 0, 0], [3, 'burn', 0.9, 0, 0], [4, 'apoptosis', 0.97, 0, 0], [5, 'neural', 1, 12, 10], [6, 'neural', 0.25, 0, 0], [7, 'erosion', 0.75, 0, 0]] });
|
||||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||||
}
|
||||
}, from, to);
|
||||
await feed(1, 2.5);
|
||||
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
|
||||
await feed(2.5, 7);
|
||||
const geo = await page.evaluate(() => {
|
||||
const out = {};
|
||||
for (const [id, x] of window.__demo.view.debug.views) {
|
||||
const r = x._elBar;
|
||||
if (!r || !r.root.visible) continue;
|
||||
const x0 = x.hpBg.position.x + 1, bw = x.hpBg.width - 2;
|
||||
const barsBottom = x.spFill.visible ? x.spFill.position.y + x.spFill.height / 2 : x.hpBg.position.y + x.hpBg.height / 2;
|
||||
out[id] = { x0, bw, barsBottom, discL: r.disc.position.x - r.disc.width * 0.336, discTop: r.disc.position.y - r.disc.height * 0.336,
|
||||
barR: r.bg.position.x + r.bg.width, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, el: x.el, tint: r.fill.tint };
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const want = { 1: 0.9, 2: 0.5, 3: 0.1, 4: 0.03, 6: 0.75, 7: 0.25 };
|
||||
for (const [id, left] of Object.entries(want)) {
|
||||
const g = geo[id];
|
||||
assert.ok(g, `unit ${id} shows its gauge`);
|
||||
assert.ok(Math.abs(g.share - left) < 1e-6, `unit ${id}: bar ${g.share} = remaining ${left}`);
|
||||
assert.ok(g.discL >= g.x0 - 1 && g.barR <= g.x0 + g.bw + 1.5, `unit ${id}: inside its bars' span`);
|
||||
assert.ok(g.discTop >= g.barsBottom, `unit ${id}: under its bars`);
|
||||
assert.equal(g.tint, 0xffffff, `unit ${id}: 元素值 left is white`);
|
||||
}
|
||||
assert.ok(geo[5].share > 0.4 && geo[5].share < 0.6, `the 爆发冷却 half refilled (${geo[5].share})`);
|
||||
assert.equal(geo[5].tint, NEURAL_TINT, 'the 爆发冷却 refill in the element\'s colour, unlike unit 1\'s white 神经 bar');
|
||||
await page.screenshot({ path: path.join(OUT, `elembar-row-${name}.png`) });
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// test/render/playtest6-enemies.browser.test.js — user playtest #6 items 9 and 13 in headless Chrome: the real sim runs
|
||||
// in the page (the client runner's loadBrowserSim) and feeds the render demo's field view with the real Spine models.
|
||||
// #9 威龙 (enemy_1005_yokai_3) is drawn at its official prefab scale: its body is ≈ 1.08× as wide as a 妖怪's on screen
|
||||
// (official 0.16 vs 0.20 on skeletons 689 vs 509 units wide), not 1.35× (enemies.json modelScale; render/units.js).
|
||||
// #13 a “萨科塔之翼” below half HP flutters at random inside its tile for 5 s: its model turns left and right.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/playtest6-enemies.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/playtest6-drones.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)';
|
||||
|
||||
// three slow flyers side by side on act2 m01's normal field: 妖怪, 威龙, “萨科塔之翼”
|
||||
const fly = (r) => ({ motion: 'FLY', start: [r, 9], end: [r, 2], checkpoints: [] });
|
||||
const SPEC = buildBattleSpec({
|
||||
battleId: 'e2e.pt6', fieldId: 'n:P1', kind: 'normal', seed: 9, modeId: 'mode_multi_hard', round: 6, stageId: 'act2autochess_m01', timeLimit: 400,
|
||||
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] }],
|
||||
spawns: [
|
||||
{ time: 0, enemyKey: 'enemy_1005_yokai', routeIndex: 0, count: 1, mods: { hpMul: 50, speedMul: 0.15 } },
|
||||
{ time: 0, enemyKey: 'enemy_1005_yokai_3', routeIndex: 1, count: 1, mods: { hpMul: 50, speedMul: 0.15 } },
|
||||
{ time: 0, enemyKey: 'enemy_10083_hlbird', routeIndex: 2, count: 1, mods: { hpMul: 50, speedMul: 1 } },
|
||||
],
|
||||
routes: [fly(12), fly(10.5), fly(9)], flags: {},
|
||||
});
|
||||
|
||||
describe('enemy model sizes and the 萨科塔 flutter (headless Chrome, real sim + real models)', { 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('威龙 ≈ 1.08× a 妖怪 on screen; the feared bird turns left and right inside its tile', 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 feed = async () => {
|
||||
b.step();
|
||||
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();
|
||||
};
|
||||
const byKey = (k) => b.enemies.find((x) => x.defId === k);
|
||||
for (let i = 0; i < 30; i++) await feed();
|
||||
await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
|
||||
for (let i = 0; i < 30; i++) await feed();
|
||||
const size = {};
|
||||
for (const k of ['enemy_1005_yokai', 'enemy_1005_yokai_3', 'enemy_10083_hlbird']) {
|
||||
const view = v.debug.views.get(byKey(k).id);
|
||||
const bd = view.actor.spine.getLocalBounds();
|
||||
size[k] = { spine: !!view.spineReady, modelK: view.modelK, widthTiles: (bd.width * Math.abs(view.actor.spine.scale.x)) / view.screen.s };
|
||||
}
|
||||
// #13: below half HP the bird fears itself
|
||||
const bird = byKey('enemy_10083_hlbird');
|
||||
const bv = v.debug.views.get(bird.id);
|
||||
b.dealDamage(null, bird, { amount: bird.s.maxHp * 0.6, type: 'true' });
|
||||
const tr = Math.round(bird.y), tc = Math.round(bird.x);
|
||||
const faces = [];
|
||||
let maxOff = 0;
|
||||
for (let i = 0; i < 30 * 4.5; i++) {
|
||||
await feed();
|
||||
if (i > 30) maxOff = Math.max(maxOff, Math.abs(bird.x - tc), Math.abs(bird.y - tr));
|
||||
if (i % 2 === 0) faces.push(bv.visFacing);
|
||||
}
|
||||
let flips = 0;
|
||||
for (let i = 1; i < faces.length; i++) if (faces[i] !== faces[i - 1]) flips++;
|
||||
return { size, flips, maxOff, feared: !!bird.s.flags.fear };
|
||||
}, SPEC);
|
||||
await page.screenshot({ path: path.join(OUT, 'playtest6-drones.png') });
|
||||
const s = log.size;
|
||||
assert.ok(s.enemy_1005_yokai.spine && s.enemy_1005_yokai_3.spine, 'models loaded');
|
||||
assert.ok(Math.abs(s.enemy_1005_yokai_3.modelK - 0.5926) < 1e-6 && Math.abs(s.enemy_1005_yokai.modelK - 0.7407) < 1e-6);
|
||||
const ratio = s.enemy_1005_yokai_3.widthTiles / s.enemy_1005_yokai.widthTiles;
|
||||
assert.ok(ratio > 0.9 && ratio < 1.25, `威龙 / 妖怪 drawn width ${ratio.toFixed(2)} (official ≈ 1.08; it was ≈ 1.35)`);
|
||||
assert.ok(s.enemy_1005_yokai_3.widthTiles < 1.8, `威龙 ${s.enemy_1005_yokai_3.widthTiles.toFixed(2)} tiles wide`);
|
||||
assert.ok(log.flips >= 2, `the bird's model turns (${log.flips} flips)`);
|
||||
assert.ok(log.maxOff <= 0.26, `inside its tile's 0.5 square (${log.maxOff.toFixed(3)})`);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// test/render/playtest6-enemies.test.js — user playtest #6, workstream WG, the renderer side (headless fake PIXI):
|
||||
// #9 the flying enemy 威龙 was drawn far too large. The official battle prefab scales every enemy's Spine model
|
||||
// (Graphic / FaceSwitcher / Spine transforms; SkeletonDataAsset 0.01 everywhere): 0.27 is the standard, 威龙 0.16,
|
||||
// 妖怪 0.20, 妖怪MKII 0.22 (tools/build-data.mjs MODEL_SCALES → enemies.json `modelScale` = scale / 0.27). The
|
||||
// renderer drew every skeleton at one UNIT.modelScale, so 威龙's 35 % wider skeleton came out 1.35× a 妖怪; now it
|
||||
// is drawn at its official 0.16 / 0.27 (1.08× a 妖怪, as in the game).
|
||||
// #13 a self-feared “萨科塔之翼” flutters around inside its tile: the real sim's positions → the snapshot buffer →
|
||||
// UnitView.sync — the model flips left / right as the random flight turns (render/units.js `visFacing`).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { UNIT } from '../../public/js/render/style.js';
|
||||
import { SnapshotBuffer } from '../../public/js/render/interp.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
|
||||
const A = JSON.parse(fs.readFileSync(new URL('../../data/assets.json', import.meta.url), 'utf8'));
|
||||
|
||||
let fake, U;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
U = 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 });
|
||||
const store = ({ bounds = true } = {}) => {
|
||||
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle' }, animations: { Idle: 1 }, ...(bounds ? { bounds: { x: -300, y: -150, width: 690, height: 390 } } : {}) };
|
||||
return {
|
||||
picture: () => null, image: async () => null,
|
||||
spineEntry: () => entry,
|
||||
spine: { acquire: async () => ({ animations: [{ name: 'Idle' }] }), release() {} },
|
||||
};
|
||||
};
|
||||
function view(info, opts) {
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(opts), cam, lookupDef: (i) => (i.side === 'enemy' ? E[i.defId] || null : null) });
|
||||
return new U.UnitView(ctx, { id: 1, side: 'enemy', kind: 'enemy', tier: 1, x: 6, y: 10, maxHp: 1000, motion: 'FLY', ...info });
|
||||
}
|
||||
const width = (key) => A.enemies[key].spine.bounds.width;
|
||||
|
||||
describe('#9 enemy models at their official prefab scale', () => {
|
||||
test('data: 威龙 0.16 / 0.27, 妖怪 0.20 / 0.27, 妖怪MKII 0.22 / 0.27; standard-size enemies carry none', () => {
|
||||
assert.equal(E.enemy_1005_yokai_3.modelScale, 0.5926);
|
||||
assert.equal(E.enemy_1005_yokai.modelScale, 0.7407);
|
||||
assert.equal(E.enemy_1005_yokai_2.modelScale, 0.8148);
|
||||
assert.equal(E.enemy_10083_hlbird.modelScale, undefined, '“萨科塔之翼”: 0.27, the standard');
|
||||
assert.equal(E.enemy_9013_acstmk.modelScale, undefined, '假想敌:胄: 0.27');
|
||||
for (const [k, e] of Object.entries(E)) if (e.modelScale != null) assert.ok(e.modelScale > 0.5 && e.modelScale < 2.3 && e.modelScale !== 1, `${k} ${e.modelScale}`);
|
||||
// drawn width 威龙 : 妖怪 — was 1.35 (skeletons alone), officially 689·0.16 : 509·0.20 ≈ 1.08
|
||||
const ratio = (width('enemy_1005_yokai_3') * E.enemy_1005_yokai_3.modelScale) / (width('enemy_1005_yokai') * E.enemy_1005_yokai.modelScale);
|
||||
assert.ok(Math.abs(ratio - (689.149 * 0.16) / (509 * 0.2)) < 0.01, `ratio ${ratio}`);
|
||||
assert.ok(width('enemy_1005_yokai_3') / width('enemy_1005_yokai') > 1.3, 'the skeleton alone is 35 % wider');
|
||||
});
|
||||
|
||||
test('UnitView: 威龙\'s skeleton and bar height follow modelScale; operators and standard enemies are unchanged', async () => {
|
||||
const drone = view({ defId: 'enemy_1005_yokai_3' });
|
||||
const bird = view({ defId: 'enemy_10083_hlbird' });
|
||||
const op = view({ side: 'ally', kind: 'chess', defId: 'chess_char_1_01_a', motion: undefined });
|
||||
await tick(); await tick();
|
||||
for (const v of [drone, bird, op]) { assert.ok(v.spineReady); v.update(1 / 60, cam(), 0); }
|
||||
const sc = (v) => Math.abs(v.actor.spine.scale.y) / v.screen.s;
|
||||
assert.ok(Math.abs(sc(drone) - UNIT.modelScale * 0.5926) < 1e-9, `威龙 ${sc(drone)}`);
|
||||
assert.ok(Math.abs(sc(bird) - UNIT.modelScale) < 1e-9);
|
||||
assert.ok(Math.abs(sc(op) - UNIT.modelScale) < 1e-9);
|
||||
assert.equal(U.enemyModelScale(E.enemy_1005_yokai_3), 0.5926);
|
||||
assert.equal(U.enemyModelScale(null), 1);
|
||||
assert.equal(U.enemyModelScale({ modelScale: 'x' }), 1);
|
||||
// the HP bar sits on its (smaller) model: 390 units × 1/320 × 0.5926 × 0.92, clamped at 0.55
|
||||
assert.ok(Math.abs(drone._headTiles - Math.max(0.55, 390 * UNIT.modelScale * 0.5926 * 0.92)) < 1e-9);
|
||||
assert.ok(bird._headTiles > drone._headTiles);
|
||||
});
|
||||
|
||||
test('UnitView without setup-pose bounds: the bar height is the chibi headroom × modelScale (bosses keep 2.2)', async () => {
|
||||
const sailor = view({ defId: 'enemy_1160_hvyslr', motion: 'WALK' }, { bounds: false }); // 码头水手 0.24 / 0.27
|
||||
const bird = view({ defId: 'enemy_10083_hlbird' }, { bounds: false });
|
||||
const boss = view({ defId: 'enemy_1160_hvyslr', motion: 'WALK', boss: true }, { bounds: false });
|
||||
await tick(); await tick();
|
||||
for (const v of [sailor, bird, boss]) { assert.ok(v.spineReady); v.update(1 / 60, cam(), 0); }
|
||||
assert.equal(E.enemy_1160_hvyslr.modelScale, 0.8889);
|
||||
assert.ok(Math.abs(sailor._headTiles - UNIT.headroom * 0.8889) < 1e-9, `码头水手 ${sailor._headTiles}`);
|
||||
assert.equal(bird._headTiles, UNIT.headroom, 'a standard-size model keeps the chibi headroom');
|
||||
assert.equal(boss._headTiles, 2.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#13 the self-feared “萨科塔之翼” turns left and right on screen', () => {
|
||||
test('real sim → snapshot buffer → UnitView: facing flips while it flutters, none while it flies its route', () => {
|
||||
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 120, units: [] });
|
||||
const e = h.spawn('enemy_10083_hlbird', { routeIndex: 2 }); // fly lane (9,10) → (9,2)
|
||||
const buf = new SnapshotBuffer({ delay: 0.1, rate: 2 });
|
||||
const v = view({ id: e.id, defId: 'enemy_10083_hlbird', x: e.x, y: e.y });
|
||||
let now = 0, flipsRoute = 0, flipsFear = 0, last = v.visFacing, fearFrom = null;
|
||||
for (let i = 0; i < 30 * 10; i++) {
|
||||
h.step();
|
||||
if (i === 30 * 3) { h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' }); fearFrom = h.b.time; }
|
||||
if (i % 3 !== 2) continue;
|
||||
now += 3 / 60; // 2× speed: 3 ticks = 1/20 real s
|
||||
buf.push(h.b.snapshot(), now);
|
||||
buf.update(now);
|
||||
const s = buf.sample().get(e.id);
|
||||
if (!s) continue;
|
||||
v.sync(s, buf.renderT);
|
||||
if (v.visFacing !== last) {
|
||||
const t = buf.renderT;
|
||||
if (fearFrom != null && t > fearFrom + 0.2 && t < fearFrom + 5) flipsFear++;
|
||||
else if (fearFrom == null) flipsRoute++;
|
||||
last = v.visFacing;
|
||||
}
|
||||
}
|
||||
assert.equal(flipsRoute, 0, 'flying its route (leftwards) it keeps facing left');
|
||||
assert.ok(flipsFear >= 2, `flips while fluttering (${flipsFear})`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// test/render/playtest6_elembar.test.js — user playtest #6 report 11, client side: "干员血条右边的元素损伤条积累速度不对,
|
||||
// 损伤条远远没扣完但是实际已经爆条受到伤害了". The whole display path in Node (headless fake PIXI, test/render/fakepixi.js):
|
||||
// a real battle (古米 against real 海嗣) → Battle.snapshot() every tick like the client runner → render/interp.js
|
||||
// SnapshotBuffer with the local-feed clock → sample() → render/units.js UnitView. Checks:
|
||||
// · the white bar drawn is the sim's remaining 元素值 at the render clock (within 1 %, never less than what is left,
|
||||
// never empty before the burst), the icon is the element with the least 元素值 left, and the burst shows as a
|
||||
// refill (PRTS 元素 "以白条显示剩余的元素值" / "元素条显示缓慢恢复至上限") drawn in the element's colour, never the
|
||||
// white of 元素值 left — a white bar growing while the burst's stun / damage hits read as "not used up yet, but it
|
||||
// burst";
|
||||
// · the render clock trails the sim by ≤ 3 ticks;
|
||||
// · the gauge row is the unit's own: under its HP / SP bars and inside their span — the v2.3 ring right of the bars
|
||||
// sat on the next operator's tier chip and bars (drawn under them) and read as that operator's gauge.
|
||||
|
||||
import { test, 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 } from '../../public/js/render/interp.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { ELEMENT_ORDER, TICK } from '../../server/sim/constants.js';
|
||||
|
||||
let fake, UnitView, EL_BAR, ELEMENT_RING;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView, EL_BAR } = await import('../../public/js/render/units.js'));
|
||||
({ ELEMENT_RING } = await import('../../public/js/render/textures.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
const OP = 'chess_char_1_10_a'; // 古米
|
||||
|
||||
/** Remaining 元素值 share of the element with the least left (the sim's truth), null without a gauge. */
|
||||
function simLeft(u) {
|
||||
let bv = 0, el = null;
|
||||
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
|
||||
return el ? { el, left: 1 - bv / u.gaugeMax } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a real battle and render the operator every tick through the client path; returns the per-frame rows
|
||||
* { t, renderT, sim (at the snapshot shown), el, share, lock } and the view.
|
||||
*/
|
||||
function renderRun(keys, { mods = {}, seconds = 70 } = {}) {
|
||||
const h = makeBattle({ units: [{ chessId: OP, row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
|
||||
h.step();
|
||||
const pos = [[10, 5.6], [10, 4.4]];
|
||||
keys.forEach((k, i) => h.spawn(k, { pos: pos[i], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000, ...mods } }));
|
||||
const u = h.allies()[0];
|
||||
const info = h.b.fieldMeta().units.find((x) => x.id === u.id);
|
||||
const v = new UnitView(fakeViewCtx(fake.P, { cam }), info);
|
||||
// the client runner's local feed (render/app.js setLocalFeed): ~2 frames of delay, the battle's speed
|
||||
const buf = new SnapshotBuffer({ delay: 0.034, rate: 2, maxRate: 8 });
|
||||
const truth = new Map(); // snapshot time → the sim's gauge then
|
||||
const out = new Map();
|
||||
const rows = [];
|
||||
for (let i = 0; i < seconds / TICK; i++) {
|
||||
h.step();
|
||||
if (u.alive) u.hp = u.s.maxHp; // keep the operator standing: only its gauge matters
|
||||
const snap = h.b.snapshot();
|
||||
truth.set(snap.t, { g: simLeft(u), lock: !!u.s.flags.burstLock });
|
||||
const now = (i + 1) / 60; // 60 ticks per real second at 2× (DESIGN §4)
|
||||
buf.push({ ...snap, gt: snap.t }, now);
|
||||
const rt = buf.update(now);
|
||||
buf.sample(rt, out);
|
||||
const s = out.get(u.id);
|
||||
if (!s) continue;
|
||||
v.sync(s, rt);
|
||||
v.update(1 / 60, cam(), now);
|
||||
const shownT = buf.snaps[Math.max(0, buf._indexAt(rt))].t;
|
||||
const r = v._elBar;
|
||||
rows.push({
|
||||
t: snap.t, renderT: rt, sim: truth.get(shownT), el: r && r.root.visible ? v.el : null,
|
||||
share: r && r.root.visible ? (r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0) : null,
|
||||
tint: r ? r.fill.tint : null, v,
|
||||
});
|
||||
}
|
||||
return { rows, v };
|
||||
}
|
||||
|
||||
function checkRows(rows) {
|
||||
let bursts = 0, prevLock = false, lastLeftBefore = null;
|
||||
for (const row of rows) {
|
||||
assert.ok(row.t - row.renderT <= 3 * TICK + 1e-9, `the render clock trails by ≤ 3 ticks (${row.t} vs ${row.renderT})`);
|
||||
const { g, lock } = row.sim;
|
||||
if (lock) {
|
||||
assert.ok(row.el, 'the 爆发冷却 shows');
|
||||
if (!prevLock) { bursts++; assert.ok(row.share < 0.02, `the refill starts empty (${row.share})`); }
|
||||
assert.equal(row.tint, ELEMENT_RING[row.el].tint, `t=${row.t}: the refill is drawn in the element's colour, not white`);
|
||||
} else if (!g) assert.equal(row.el, null, 'no gauge, no row');
|
||||
else {
|
||||
assert.equal(row.el, g.el, `t=${row.t}: the icon is the element with the least 元素值 left`);
|
||||
assert.equal(row.tint, 0xffffff, `t=${row.t}: 元素值 left is white`);
|
||||
assert.ok(row.share >= g.left - 1e-9, `t=${row.t}: bar ${row.share} never below the real remainder ${g.left}`);
|
||||
assert.ok(row.share - g.left < 0.01 + 1e-9, `t=${row.t}: bar ${row.share} within 1 % of ${g.left}`);
|
||||
assert.ok(row.share > 0, `t=${row.t}: never empty before the burst`);
|
||||
lastLeftBefore = row.share;
|
||||
}
|
||||
prevLock = lock;
|
||||
}
|
||||
return { bursts, lastLeftBefore };
|
||||
}
|
||||
|
||||
test('底海滑动者 on 古米: the white bar drawn is the sim\'s remaining 元素值 every frame; the burst comes off a sliver', () => {
|
||||
const { rows } = renderRun(['enemy_1148_dssbr'], { seconds: 62 });
|
||||
const { bursts } = checkRows(rows);
|
||||
assert.equal(bursts, 1, 'the 神经 burst at ≈ 49 game s');
|
||||
const before = rows[rows.findIndex((r) => r.sim.lock) - 1];
|
||||
assert.ok(before.share <= 0.05, `the last bar before the burst: ${before.share}`);
|
||||
// the refill runs over the 10 s 爆发冷却
|
||||
const cool = rows.filter((r) => r.sim.lock);
|
||||
const mid = cool[Math.floor(cool.length / 2)];
|
||||
assert.ok(Math.abs(mid.share - 0.5) < 0.05, `halfway through the cooldown the bar is half refilled (${mid.share})`);
|
||||
});
|
||||
|
||||
test('掠海漂移体 + 骨海漂流体 on 古米 (绝境 R11 ATK ×1.77): chunky 侵蚀 hits — the bar follows every chunk exactly', () => {
|
||||
const { rows } = renderRun(['enemy_2025_syufo', 'enemy_2021_syfish'], { mods: { atkMul: 1.771561 }, seconds: 40 });
|
||||
const { bursts } = checkRows(rows);
|
||||
assert.ok(bursts >= 1);
|
||||
});
|
||||
|
||||
test('the gauge row belongs to its unit: under its own HP / SP bars and inside their span', () => {
|
||||
const { rows, v } = renderRun(['enemy_1148_dssbr'], { seconds: 12 });
|
||||
assert.ok(rows.at(-1).el, 'a gauge shows');
|
||||
const r = v._elBar;
|
||||
const x0 = v.hpBg.position.x + 1, bw = v.hpBg.width - 2;
|
||||
const spBottom = v.spFill.visible ? v.spFill.position.y + v.spFill.height / 2 : v.hpFill.position.y + v.hpFill.height / 2;
|
||||
const discR = r.disc.width * 0.5 * (43 / 64); // the drawn disc (textures.js HUD_DISC of the cell)
|
||||
assert.ok(r.disc.position.x - discR >= x0 - 0.5, 'the icon starts at the bars\' left edge');
|
||||
assert.ok(r.bg.position.x + r.bg.width <= x0 + bw + 1.5, 'the bar ends at the bars\' right end (never beyond, over a neighbour)');
|
||||
assert.ok(r.fill.position.x + r.fill.width <= x0 + bw + 0.5);
|
||||
assert.ok(r.disc.position.y - discR >= spBottom + EL_BAR.gap - 0.01, 'the row lies under the SP bar');
|
||||
assert.ok(r.bg.position.y - r.bg.height / 2 > spBottom, 'the white bar too');
|
||||
});
|
||||
Reference in new issue
Block a user