feedback1d WN: 暴鸰 hovers through its cast and speeds up when it ends; 掠海漂移体's crawl in UnitInfo.form

- Official Boomb ability: the cast finishes once the bomb has landed and at
  least 0.667 s after the release (_fireAttackFinishWhenProjectileInvalid,
  _minPostDelayWhenProjectileInvalid), then buff bomb_s gives the speed
  (PRTS "技能结束后移速最终提升至200%"). kitBombd: BOMBD_POST_DELAY; the speed-up
  at the cast end instead of the landing; the drone holds still until then,
  like the engine's pause for an enemy attack clip.
- The bomb-less look still starts at the release: the view begins the *_2
  clips after the Attack clip (the official end clip Idle_2); at the cast end
  it would draw the bomb back for a frame.
- kitSyufo records e.form = 'crawl', so a field opened later draws it crawling.
This commit is contained in:
sganggs committed 2026-10-03 05:18:31 +08:00
1 parent 04634f5bf7
commit 47d4215cfe
5 files changed
+96 -36

No files matched your search

+8 -7
View File
@@ -213,12 +213,13 @@ never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
damage — the content's `attack` handler resolves it (帝国炮火先兆者's shells landing 3 s later, `content/enemies.js
kitShell`); `enemy.profile.shot` = the `'atk'` event's projectile kind (`'mortar'`: no projectile drawn). 暴鸰 (`kitBombd`,
no normal attack) drops its one bomb as a projectile: the cast (trigger: an ally within its range 2) releases it
`BOMBD_RELEASE` (0.267 s, the Attack clip's OnAttack) later — `'atk'` kind `'droneBomb'` and fx `'phase'` {kind
`'bombed'`}: the model flies on without it — and it hits on arrival (target 100 % ATK, the other allies of the 8 tiles
around its landing tile 100 % splash, camouflage ignored; a target gone mid-flight: it lands where it was), then the
drone's speed ×2 (feedback D4 after 0.1.0: the damage used to land in the trigger tick with the bomb still on the
drone). Crates (stage devices with
no normal attack) drops its one bomb as a projectile: the cast (trigger: an ally within its range 2; the drone hovers
through it) releases it `BOMBD_RELEASE` (0.267 s, the Attack clip's OnAttack) later — `'atk'` kind `'droneBomb'` — and
it hits on arrival (target 100 % ATK, the other allies of the 8 tiles around its landing tile 100 % splash, camouflage
ignored; a target gone mid-flight: it lands where it was). The cast ends once the bomb has landed and at least
`BOMBD_POST_DELAY` (0.667 s) after the release: fx `'phase'` {kind `'bombed'`} (the model flies on without it), the
drone's speed ×2, and it moves again (feedback D4 after 0.1.0: the damage used to land in the trigger tick with the
bomb still on the drone). Crates (stage devices with
role `crate`, 100 HP) are ground obstacles; an enemy forced through one is blocked by it and destroys it. A device is
present when data/stages.json says `active: true` (this wins over the level file's `hidden`: act1 m02's crates);
research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are
@@ -963,7 +964,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex?, form? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's mode recorded by content as `unit.form` — 暴鸰 `'bombed'` after its drop — for views built later)
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex?, form? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's mode recorded by content as `unit.form` — 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop — for views built later)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
+32 -10
View File
@@ -52,7 +52,8 @@
// fx kinds emitted (battle.fx(kind, {x, y, …})): 'explode' {r, kind} · 'zone' {r, dur, kind} · 'telegraph' {r, dur,
// kind, tiles?} (delayed strikes, charges, 'reborn') · 'beam' {from, to, kind} · 'summon' {id, key} · 'ember'/'revive'/
// 'stone' {id} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' {id} · 'liberate' {id}
// · 'phase' {id, kind} (form / barrier changes) · 'lpLoss' {value, reason} · 'steal' · 'ignite'.
// · 'phase' {id, kind} (form / barrier changes; a mode with its own clip set — render/units.js FORMS: 掠海漂移体
// 'crawl', 暴鸰 'bombed' — is also kept in `e.form` → UnitInfo.form) · 'lpLoss' {value, reason} · 'steal' · 'ignite'.
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS } from '../constants.js';
@@ -88,6 +89,11 @@ const BOMB_REACH = 1;
* animKey Attack, `_waitForAttackEvent`; the skeleton's OnAttack is at 0.267 s — data/assets.json hits.Attack). It then
* flies as projectile_bombd (`_speed` 5 = PROJECTILE_SPEEDS.droneBomb, homing, `_ignoreCamouflage`). */
export const BOMBD_RELEASE = 0.267;
/** 暴鸰 投弹: the cast ends once the bomb has landed and no sooner than this after the release (the Boomb ability:
* `_fireAttackFinishWhenProjectileInvalid` 1, `_minPostDelayWhenProjectileInvalid` 0.667, `_waitForAnimEndWhenProjectileInvalid`
* 0). Then its buff bomb_s (template switch_mode_restart_fsm: mode S1 + the move-speed modifier) — PRTS "技能结束后移速最终
* 提升至200%"; the cast's end clip is already the bomb-less Idle_2 (`_endAnimKey`). */
export const BOMBD_POST_DELAY = 0.667;
/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力
* 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2;
@@ -1082,6 +1088,7 @@ function kitSyufo(ab) {
setFloat(b, e, false);
e.profile.melee = true;
b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null });
e.form = 'crawl'; // UnitInfo.form: a view built later draws it crawling
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'crawl' });
},
}];
@@ -1473,12 +1480,17 @@ function kitRoar(ab) {
* projectile: the cast plays the Attack clip, the bomb leaves on its OnAttack event (BOMBD_RELEASE) and flies to the
* target (projectile_bombd), and the drone switches to its bomb-less mode (S1, buff bomb_s: the *_2 clips, no bottle).
* User feedback after 0.1.0 (D4 "炸弹无法正常投放"): the damage used to land in the tick of the trigger while the drone
* kept its bomb on screen. Now: at the release an 'atk' event of kind 'droneBomb' (the client winds the Attack clip up
* to it and flies the bomb) and fx 'phase' {kind: 'bombed'} (render FORMS: the *_2 clips); on arrival the target (the
* ranged target by engine priority) takes 100 % ATK and every other ally of the 8 tiles around where it lands 100 % ATK
* splash (camouflage ignored); then move speed ×boomb.move_speed. A target gone mid-flight: the bomb lands where it was.
* [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the speed-up when the bomb lands;
* the drone keeps flying during the cast.
* kept its bomb on screen. Now: the drone hovers through its cast; at the release an 'atk' event of kind 'droneBomb'
* (the client winds the Attack clip up to it and flies the bomb) and fx 'phase' {kind: 'bombed'} (render FORMS: the
* *_2 clips, which the view starts once the Attack clip is over — the official end clip Idle_2; `e.form` →
* UnitInfo.form); on arrival the target (the ranged target by engine priority) takes 100 % ATK and every other ally of
* the 8 tiles around where it lands 100 % ATK splash (camouflage ignored); a target gone mid-flight: the bomb lands
* where it was. The cast ends once the bomb has landed, at least BOMBD_POST_DELAY after the release: move speed
* ×boomb.move_speed, and it flies on.
* [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (the
* engine pauses an enemy for its attack clip, ATTACK_PAUSE; the drop is the drone's only attack-like cast); the
* bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick
* before the clip ends — the client would draw the bomb back for a frame).
*/
function kitBombd(ab) {
const s = ab.sk.boomb;
@@ -1489,7 +1501,12 @@ function kitBombd(ab) {
b.fx('explode', { x, y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box' });
if (t) hurt(b, e, t, atk, 'phys');
for (const u of alliesInTiles(b, r, c, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, atk, 'phys', { tags: ['splash'] });
if (ms > 0 && e.alive) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); // 移速最终提升至200%
};
// the end of the cast (bomb_s): 移速最终提升至200%; it flies on
const finish = (b, e) => {
if (!e.alive) return;
e.pauseUntil = b.time;
if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true });
};
return [
{ spawn(b, e) { e.profile.noAttack = true; } },
@@ -1498,14 +1515,19 @@ function kitBombd(ab) {
if (!t) return;
a.cd = Infinity; a.left = Infinity; // 仅能触发一次
e.skillAnimUntil = -1; // the cast is drawn through its 'atk' event: the client winds the Attack clip up to it
// hovers through the cast, until `finish` (bounded: the bomb lands within its projectile's maxAge, 10 s)
e.pauseUntil = Math.max(e.pauseUntil, b.time + BOMBD_RELEASE + 10 + BOMBD_POST_DELAY);
let landed = false, waited = false;
const done = () => { if (landed && waited) finish(b, e); };
b.after(BOMBD_RELEASE, () => {
if (!e.alive) return;
const atk = e.s.atk;
b._ev(['atk', e.id, t.id, 'droneBomb']);
e.form = 'bombed'; // UnitInfo.form: a view built later draws it bomb-less
e.form = 'bombed'; // UnitInfo.form: a view built later draws it bomb-less
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'bombed' });
b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.droneBomb, visual: 'droneBomb', source: e, hitDead: true,
onHit: (c) => land(b, e, atk, c.target, c.x, c.y) });
onHit: (c) => { land(b, e, atk, c.target, c.x, c.y); landed = true; done(); } });
b.after(BOMBD_POST_DELAY, () => { waited = true; done(); }, { owner: e });
}, { owner: e });
}, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }),
];
+3 -2
View File
@@ -719,12 +719,13 @@ test(`${nm('enemy_1042_frostd')}: operators within ${tb('enemy_1042_frostd', 'de
test(`${nm('enemy_1040_bombd')}: no normal attack; ONE bomb on the target + its 8 tiles, then move speed ×${skb('enemy_1040_bombd', 'boomb').bb.move_speed}`, () => {
// PRTS 暴鸰: "不进行普通攻击" · 投弹 "对目标及其周围八格的我方单位造成100%物理伤害 … 技能结束后移速最终提升至200% ※此技能仅能触发一次"
// (the bomb leaves on the Attack clip's OnAttack and flies 1 tile at 5 tiles/s — feedback D4, test/sim/feedback1d-bombd)
// (the bomb leaves on the Attack clip's OnAttack and flies 1 tile at 5 tiles/s; the speed-up when the cast ends —
// feedback D4, test/sim/feedback1d-bombd)
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }] });
h.step();
const e = put(h, 'enemy_1040_bombd', [10, 7], { route: 2 });
const s = skb('enemy_1040_bombd', 'boomb');
h.run(s.initCooldown + enemiesMod.BOMBD_RELEASE + 1 / 5 + 0.1);
h.run(s.initCooldown + enemiesMod.BOMBD_RELEASE + enemiesMod.BOMBD_POST_DELAY + 0.1);
const [w1, w2, w3] = ['t_wall', 't_wall2', 't_wall3'].map((id) => h.unit(id));
approx(w2.stats.taken, e.s.atk, 1e-6, 'the target (latest deployed in range): 100 % ATK');
approx(w1.stats.taken, e.s.atk, 1e-6, 'a tile next to it: splash 100 %');
+15 -8
View File
@@ -2,9 +2,11 @@
// 暴鸰's skeleton (enemy_1040_bombd) carries its bomb (slot Bottle) in Idle / Move_Loop / Die and has bomb-less twins
// (Idle_2 / Move_Begin_2 / Move_Loop_2 / Move_End_2 / Die_2: Bottle null) — the official battle prefab switches to them
// after the drop (mode S1, buff bomb_s, replaceAnimPairs Move→Move_2, Idle→Idle_2, Die→Die_2). The view kept the
// bottle under the drone for good and no bomb ever left it. Now: the sim's fx 'phase' { kind: 'bombed' } switches the
// view to those clips (render/units.js FORMS) with no fx of its own, and the drop's 'atk' kind 'droneBomb' flies a
// falling bomb (render/style.js PROJ.droneBomb, the sim's PROJECTILE_SPEEDS.droneBomb) from the drone to the target.
// bottle under the drone for good and no bomb ever left it. Now: the drop's 'atk' kind 'droneBomb' plays the Attack
// clip once (its OnAttack on the event) and flies a falling bomb (render/style.js PROJ.droneBomb, the sim's
// PROJECTILE_SPEEDS.droneBomb) from the drone to the target; the sim's fx 'phase' { kind: 'bombed' } on the same frame
// switches the view to those clips (render/units.js FORMS) with no fx of its own — they start once the Attack clip is
// over (the official cast's end clip Idle_2), never cutting the drop short.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -67,7 +69,7 @@ describe('D4 暴鸰: the drone lets go of its bomb on screen', () => {
assert.equal(w.actor.current, 'Move_Loop_2');
});
test('the drop plays the Attack clip once at its own speed (its OnAttack on the event), then the bomb-less flight', async () => {
test('the drop plays the Attack clip once at its own speed (its OnAttack on the event); the cast end → Idle_2, then the bomb-less flight', async () => {
// the client's lookupDef: the drone's data BAT 5 s (it never attacks normally; its attack rhythm used to drive the drop)
const ctx = fakeViewCtx(fake.P, { assets: store(KEY), cam, lookupDef: () => ({ stats: { bat: 5, aspd: 100 } }) });
const v = new UnitView(ctx, { id: 5, side: 'enemy', kind: 'enemy', defId: KEY, spine: KEY, tier: 2, x: 6, y: 10, maxHp: 4000, motion: 'FLY' });
@@ -81,10 +83,15 @@ describe('D4 暴鸰: the drone lets go of its bomb on screen', () => {
frames(12); // 0.2 s: the event is rendered
v.onAttack({ x: 5, y: 10 }, 1.2, 'droneBomb');
v.setForm('bombed');
v.sync(sample(ANIM.MOVE), 1.2);
assert.equal(v.actor.current, 'Attack', 'the release frame');
frames(50); // the rest of the 1 s clip at speed 1 (0.733 s) and a little
assert.equal(v.actor.current, 'Move_Loop_2', 'back to flying, without the bomb (not a 5 s attack rhythm from its BAT)');
v.sync(sample(ANIM.IDLE), 1.2); // the drone hovers through its cast
assert.equal(v.actor.current, 'Attack', 'the release frame: the mode change does not cut the drop short');
frames(40); // 0.667 s (BOMBD_POST_DELAY): the cast ends, 0.067 s of the clip left
assert.equal(v.actor.current, 'Attack');
frames(6);
assert.equal(v.actor.current, 'Idle_2', 'the cast\'s end clip (official _endAnimKey Idle_2), not a 5 s attack rhythm from its BAT');
v.sync(sample(ANIM.MOVE), 2.2);
frames(30);
assert.equal(v.actor.current, 'Move_Loop_2', 'flying on without the bomb');
assert.equal(v.atkInterval, 5, 'a one-off drop leaves the attack rhythm alone');
});
+38 -9
View File
@@ -4,9 +4,12 @@
// for the rest of its life: nothing ever left the drone. Official (PRTS 暴鸰; the client's battle prefab
// enemy_1040_bombd + projectile_bombd): the cast plays the Attack clip, the bomb leaves on its OnAttack event (0.267 s,
// `_waitForAttackEvent`), flies to the target as a projectile (`_speed` 5, homing, `_ignoreCamouflage`) and explodes
// there on the target and the 8 tiles around it; the drone switches to its bomb-less mode (S1 `bomb_s`: the *_2
// clips) and flies on at ×2. Now (content/enemies.js kitBombd): 'atk' kind 'droneBomb' + fx 'phase' { kind: 'bombed' }
// at the release, damage on arrival, the speed-up after the blast.
// there on the target and the 8 tiles around it; the cast ends once the bomb has landed, at least 0.667 s after the
// release (`_fireAttackFinishWhenProjectileInvalid`, `_minPostDelayWhenProjectileInvalid`), with the bomb-less Idle_2 as
// its end clip, and its buff bomb_s switches the drone to its bomb-less mode (S1: the *_2 clips) at ×2 speed
// ("技能结束后移速最终提升至200%"). Now (content/enemies.js kitBombd): the drone hovers through the cast; 'atk' kind
// 'droneBomb' + fx 'phase' { kind: 'bombed' } at the release (the view starts the *_2 clips after the Attack clip),
// damage on arrival, the speed-up when the cast ends. Also UnitInfo.form for 掠海漂移体's crawl.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -17,6 +20,7 @@ import { PROJECTILE_SPEEDS, TICK } from '../../server/sim/constants.js';
const KEY = 'enemy_1040_bombd';
const RELEASE = enemiesMod.BOMBD_RELEASE;
const POST = enemiesMod.BOMBD_POST_DELAY;
const ability = (e) => e.mem.ab.list.find((a) => a && a.fire);
/** A real round: solo 绝境 match, real stage and wave, seven real operators, the 悬赏·飞行II bounty (one 暴鸰). */
@@ -40,10 +44,10 @@ function realRound(seed = 3, round = 4) {
}
describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
test('real wave: trigger → release on the OnAttack frame (atk droneBomb + bomb-less mode) → damage on arrival → ×2 speed', () => {
test('real wave: trigger (hover) → release on the OnAttack frame (atk droneBomb, bomb-less mode) → damage on arrival → cast end: ×2 speed', () => {
const { m, b } = realRound();
const log = [];
let drone = null, castAt = null, hitAt = null, firstHit = null;
let drone = null, castAt = null, hitAt = null, firstHit = null, castPos = null, endPos = null, runAt = null, prev = null;
const hits = [];
b.on('damaged', (c) => {
if (c.source !== drone || !drone) return;
@@ -51,15 +55,18 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
if (hitAt == null) { hitAt = b.time; firstHit = c.target; }
});
while (!b.finished && b.time < 200) {
prev = drone ? { x: drone.x, y: drone.y } : null; // where it is when the next tick starts
b.step();
if (!drone) drone = b.enemies.find((x) => x.defId === KEY) || null;
if (drone && castAt == null && (ability(drone)?.casts ?? 0) > 0) {
castAt = b.time;
castPos = { x: drone.x, y: drone.y };
assert.equal(hits.length, 0, 'nothing is hit in the tick of the trigger');
assert.ok(!drone.findBuff('ab:bombRun'), 'no speed-up before the bomb went off');
}
for (const ev of b.drainEvents()) if (drone && ((ev[0] === 'atk' && ev[1] === drone.id) || (ev[0] === 'fx' && ev[4]?.id === drone.id))) log.push([b.time, ev]);
if (hitAt != null && b.time > hitAt + 1) break;
if (castAt != null && runAt == null && drone.findBuff('ab:bombRun')) { runAt = b.time; endPos = prev; }
if (runAt != null && b.time > runAt + 1) break;
if (castAt != null && b.time > castAt + 5) break;
}
assert.ok(drone, 'the drone spawned');
assert.ok(castAt != null, 'it triggered its bomb near the operators');
@@ -69,7 +76,7 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
assert.equal(relEv[3], 'droneBomb', 'drawn as the drone\'s bomb');
assert.ok(Math.abs(relT - castAt - RELEASE) <= TICK + 1e-9, `released ${(relT - castAt).toFixed(3)} s after the trigger (OnAttack ${RELEASE})`);
const phase = log.find(([, ev]) => ev[0] === 'fx' && ev[1] === 'phase');
assert.ok(phase && phase[1][4].kind === 'bombed' && Math.abs(phase[0] - relT) < 1e-9, 'the model drops to its bomb-less mode at the release');
assert.ok(phase && phase[1][4].kind === 'bombed' && Math.abs(phase[0] - relT) < 1e-9, 'the bomb has left the drone: its bomb-less mode from the release');
if (drone.alive) assert.equal(b.fieldMeta().units.find((u) => u.id === drone.id).form, 'bombed', 'a field opened later draws it bomb-less');
assert.ok(hitAt != null, 'the bomb lands');
assert.equal(firstHit.id, relEv[2], 'on the operator it was dropped on');
@@ -80,8 +87,13 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
for (const x of hits.filter((y) => y.splash)) {
assert.ok(Math.max(Math.abs(x.target.tileR - firstHit.tileR), Math.abs(x.target.tileC - firstHit.tileC)) <= 1, 'splash only on the 8 tiles around the target');
}
assert.ok(drone.alive, 'the drone outlives its cast in this round');
const run = drone.findBuff('ab:bombRun');
if (drone.alive) assert.equal(run?.mods?.moveMul, DATA.enemies[KEY].skills[0].bb.move_speed, '移速最终提升至200% after the blast');
assert.equal(run?.mods?.moveMul, DATA.enemies[KEY].skills[0].bb.move_speed, '技能结束后移速最终提升至200%');
const castEnd = Math.max(hitAt, relT + POST);
assert.ok(Math.abs(runAt - castEnd) <= TICK + 1e-9, `when the cast ends (${(runAt - castAt).toFixed(3)} s after the trigger; landed ${(hitAt - castAt).toFixed(3)})`);
assert.ok(Math.hypot(endPos.x - castPos.x, endPos.y - castPos.y) < 1e-9, 'hovering in place through the cast (its Attack clip)');
assert.ok(Math.hypot(drone.x - endPos.x, drone.y - endPos.y) > 0.5, 'then flies on');
assert.equal(drone.stats.attacks, 0, 'no normal attack');
m.dispose();
});
@@ -122,6 +134,7 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
h.b.kill(e, null);
h.run(1.5);
assert.ok(Math.abs(h.unit('t_a').stats.taken - e.s.atk) < 1e-6, 'hit for the ATK it had at the release');
assert.ok(!e.findBuff('ab:bombRun'), 'a dead drone gets no speed-up');
});
test('the drone killed before the release: no bomb, no mode change', () => {
@@ -136,3 +149,19 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => {
assert.ok(!h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'phase' && ev[4]?.id === e.id), 'no bomb-less mode');
});
});
describe('UnitInfo.form: every mode the renderer draws is recorded (a field opened later)', () => {
test('掠海漂移体 stunned → 爬行模式: UnitInfo.form \'crawl\' (render/units.js FORMS starts a later view on the *_02 clips)', () => {
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 60, captureNoisy: true,
defs: { chess: { t_a: chessRec({ id: 't_a', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
kits: { t_a: () => ({ trait: { noAttack: true } }) }, units: [{ chessId: 't_a', row: 10, col: 3 }] });
h.step();
const e = h.spawn('enemy_2025_syufo', { pos: [10, 6], routeIndex: 0, mods: { speedMul: 0, atkMul: 0 } });
const form = () => h.b.fieldMeta().units.find((u) => u.id === e.id)?.form;
assert.equal(form(), undefined, 'hovering');
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.2, source: null }));
h.step();
assert.ok(h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'phase' && ev[4]?.kind === 'crawl'), 'it dropped');
assert.equal(form(), 'crawl');
});
});