- #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
123 lines
7.0 KiB
JavaScript
123 lines
7.0 KiB
JavaScript
// 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();
|
||
}
|
||
});
|
||
});
|