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