feedback1d WN: 暴鸰 drops its bomb as a projectile and flies on without it (D4)

The drone triggered its one bomb, but the damage and the blast landed in the trigger tick
while the model kept the bomb hanging under it for good. Official (PRTS 暴鸰; the client's
battle prefab enemy_1040_bombd and projectile_bombd): the cast plays the Attack clip, the bomb
leaves on its OnAttack event (0.267 s), flies to the target at 5 tiles/s and explodes on it
and the 8 tiles around; the drone switches to its bomb-less clips and speeds up.

Sim (kitBombd): release after BOMBD_RELEASE with an 'atk' event of kind 'droneBomb' and fx
'phase' {kind: 'bombed'}, a homing projectile (PROJECTILE_SPEEDS.droneBomb 5) whose arrival
deals the damage, then the speed buff. Render: FORMS.enemy_1040_bombd.bombed (*_2 clips),
PROJ.droneBomb (a falling shell), a one-off cast plays the attack clip once at its own
speed instead of the drone's 5 s attack rhythm, no puff for the silent mode change.
This commit is contained in:
sganggs committed 2026-10-03 04:21:19 +08:00
1 parent 46ccfd55f6
commit 901d5b9613
13 files changed
+339 -45

No files matched your search

+9 -3
View File
@@ -212,7 +212,13 @@ and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` s
never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Content can take over an
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). Crates (stage devices with
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
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
@@ -886,7 +892,7 @@ PRTS 选择器, is not in the data): "地面敌人" / "不可对空" ⇒ `!e.isF
风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts — “耀阳”,
沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体 for good. Ranged attacks fly as projectiles
(`constants.js PROJECTILE_SPEEDS`: arrow 14, bolt 11, bomb/lob 8, orb 10, drone 16, enemy 10 tiles/s; boomerang 15 out,
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃); melee/`none` hits are instant. Kit-settable profile flags beyond the
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are instant. Kit-settable profile flags beyond the
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays
`'boomerang'` whatever the data's generic ranged projectile says) (see the header of professions.js).
@@ -951,7 +957,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3).
`fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3).
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]`
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|chain|chainHeal`; a boomerang's way back has no event — the
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
+2 -2
View File
@@ -1349,7 +1349,7 @@ export async function createFieldView(host, options = {}) {
if (e[0] !== 'atk' || woundUp.has(e) || CHAIN_KINDS.has(e[3])) return;
const v = views.get(e[1]);
if (!v || !v.windUp) return;
if (v.windUp(t - upcomingT)) woundUp.add(e);
if (v.windUp(t - upcomingT, e[3])) woundUp.add(e);
}
const EVS = [];
@@ -1381,7 +1381,7 @@ export async function createFieldView(host, options = {}) {
const src = views.get(e[1]) || battleView(e[1]);
const tgt = views.get(e[2]) || battleView(e[2]);
// chain / chainHeal bounces: the "source" is the previous target of the bounce, not an attacker
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now);
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now, e[3]);
if (e[3] === 'none' || !e[3]) { if (tgt && src) meleePending.set(tgt.id, { src, t: now }); }
fx.attack(src, tgt, e[3]);
break;
+7
View File
@@ -237,9 +237,16 @@ const FX_GUESS = [
[/stealth|hide|vanish|cloak/i, 'vanish'], [/pull|push|knock|dash|leap/i, 'move'], [/pulse|wave|ring|sonic/i, 'wave'],
];
/**
* 'phase' (an enemy's mode change, render/units.js FORMS) kinds the model shows on its own: 暴鸰 'bombed' — its bomb is
* the 'droneBomb' projectile of the same moment, a puff on the drone would read as something else (feedback D4).
*/
const SILENT_PHASES = new Set(['bombed']);
/** Visual spec of an fx kind (see FX_KINDS); `extra.kind` / `extra.element` may pick a better colour. */
export function fxSpec(kind, extra = {}) {
const k = typeof kind === 'string' ? kind : '';
if (k === 'phase' && SILENT_PHASES.has(extra && extra.kind)) return { a: 'none', c: 0xffffff };
let spec = FX_KINDS[k];
if (!spec) {
const g = FX_GUESS.find(([re]) => re.test(k));
+24 -14
View File
@@ -6,15 +6,18 @@
//
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
// attack(interval) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
// clip plays once at its own speed, then base
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
// deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards)
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast: to the end of its clip), then the
// end clip (if any) and base.
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@@ -211,42 +214,49 @@ export class SpineActor {
* clip from its wind-up so the strike frame lands when the attack event is rendered. Returns true once started;
* false when it is still too early (call again next frame) or there is nothing to wind up.
*/
windUp(interval, lead) {
windUp(interval, lead, once = false) {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return false;
const clip = this._attackClip();
if (!clip) return false;
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
const iv = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const loopDur = this.dur(clip.loop);
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const hit = this._hitTime(clip.loop, loopDur);
const plan = windUpPlan(loopDur, hit, iv, lead);
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
this.interval = iv;
if (!once) this.interval = iv;
this.mode = 'attack';
this.attackUntil = this.clock + lead + Math.max(0.45, iv * 1.4);
this._play(clip.loop, true, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.attackUntil = this.clock + lead + (once ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
this._play(clip.loop, !once, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.windTs = plan.ts;
this.windUntil = this.clock + lead;
return true;
}
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
attack(interval) {
/**
* An attack happened now. `interval` = seconds between attacks (game time already scaled to real); `once` = a one-off
* cast (no rhythm): the clip plays once at its own speed from its strike frame, then the resting state.
*/
attack(interval, once = false) {
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
if (!once) this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const clip = this._attackClip();
if (!clip) return;
const loopDur = this.dur(clip.loop);
const ts = clampN(loopDur / this.interval, 0.35, 4);
const ts = once ? 1 : clampN(loopDur / this.interval, 0.35, 4);
const hit = this._hitTime(clip.loop, loopDur);
const wasAttacking = this.mode === 'attack' && this.current === clip.loop;
this.mode = 'attack';
this.attackUntil = this.clock + Math.max(0.45, this.interval * 1.4);
this.attackUntil = this.clock + (once ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
this.windUntil = null;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, true, { timeScale: ts, start: hit, mix: 0.06 });
this._play(clip.loop, !once, { timeScale: ts, start: hit, mix: 0.06 });
} else if (once) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
} else {
const e = this.spine.state.tracks[0];
if (e) {
+6 -1
View File
@@ -89,7 +89,7 @@ export function dmgStyleKey(type) {
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/**
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang | droneBomb;
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
@@ -101,6 +101,8 @@ export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0x
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
* once a one-off cast, not an attack rhythm: the shooter's attack clip plays once at its own speed (render/spine.js
* SpineActor.attack / windUp) — 暴鸰's bomb drop
*/
export const PROJ = Object.freeze({
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
@@ -111,6 +113,9 @@ export const PROJ = Object.freeze({
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
// 暴鸰's bomb (sim content/enemies.js kitBombd; official projectile_bombd, speed 5): dropped from the drone, it falls
// onto its target with a low arc and bursts where the sim's 'explode' blast goes off
droneBomb: { look: 'shell', speed: 5, once: true, tint: 0xffe2c8, glow: 0xff5a3a, len: 0.6, width: 0.3, head: 0.5, trail: 0xff7a4a, arc: 0.35, smoke: 0x2e2824, hit: 'boom' },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
+29 -13
View File
@@ -25,7 +25,8 @@
// An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit')
// goes down with `die(true)`: straight to the held end of the clip, no fall.
// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1)
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode.
// plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its bomb (*_2) after the drop
// (feedback D4) — FORMS; a view built later keeps the mode.
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row
// right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its
@@ -53,7 +54,7 @@
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@@ -101,10 +102,20 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
/**
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's
* roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'; 暴鸰 flies on
* its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2, Die→Die_2;
* user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its Attack clip).
* The mode's roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
*/
export const FORMS = Object.freeze({
enemy_1040_bombd: Object.freeze({
bombed: Object.freeze({
roles: Object.freeze({
idle: 'Idle_2', deploy: 'Idle_2', die: 'Die_2',
move: Object.freeze({ begin: 'Move_Begin_2', loop: 'Move_Loop_2', end: 'Move_End_2' }),
}),
}),
}),
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({
change: 'Change',
@@ -514,13 +525,17 @@ export class UnitView {
}
/** An attack was made (b.ev 'atk'). `target` = view or null. */
onAttack(target, now) {
onAttack(target, now, kind) {
if (!this.alive) return;
if (this.lastAtk >= 0) {
const d = now - this.lastAtk;
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
const once = !!PROJ[kind]?.once;
if (!once) {
if (this.lastAtk >= 0) {
const d = now - this.lastAtk;
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
}
this.lastAtk = now;
}
this.lastAtk = now;
if (target && !this.isEnemy && this.info.kind !== 'device') {
// operators keep their deploy direction (research 09 §1.2); enemies may turn towards their target
} else if (target && this.isEnemy) {
@@ -532,17 +547,18 @@ export class UnitView {
this.lungeDir.x = dx / len; this.lungeDir.y = dy / len;
}
this.lunge = 1;
if (this.actor) this.actor.attack(this.atkInterval); // game seconds: the actor's clock runs in game time
if (this.actor) this.actor.attack(this.atkInterval, once); // game seconds: the actor's clock runs in game time
if (this.imp) this.imp.dirty = true;
}
/**
* An attack by this unit is `lead` game seconds ahead in the snapshot buffer: start the Spine attack wind-up now
* so the strike frame lines up with the attack. True once started (then stop calling for that attack).
* so the strike frame lines up with the attack. True once started (then stop calling for that attack). `kind` = the
* 'atk' projKind (a one-off cast winds up at the clip's own speed).
*/
windUp(lead) {
windUp(lead, kind) {
if (!this.alive || !this.actor || !this.spineReady) return false;
const ok = this.actor.windUp(this.atkInterval, lead);
const ok = this.actor.windUp(this.atkInterval, lead, !!PROJ[kind]?.once);
if (ok && this.imp) this.imp.dirty = true;
return ok;
}
+3 -2
View File
@@ -36,9 +36,10 @@ export const PROJECTILE_SPEED = 12;
/**
* Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. `boomerang` (回环射手 跃跃) is the OUTBOUND
* flight to the target — PRTS 跃跃 特性 note "投射物飞行速度15,返回时飞行速度3.75"; the way back is
* BOOMERANG_RETURN_SPEED (ai.js throwBoomerang).
* BOOMERANG_RETURN_SPEED (ai.js throwBoomerang). `droneBomb` = 暴鸰's bomb (the official projectile_bombd `_speed` 5;
* content/enemies.js kitBombd).
*/
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10, boomerang: 15 });
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10, boomerang: 15, droneBomb: 5 });
/** 回环射手: speed (tiles/s) of a boomerang flying back from its hit point to its thrower (PRTS "返回时飞行速度3.75"). */
export const BOOMERANG_RETURN_SPEED = 3.75;
+31 -6
View File
@@ -55,7 +55,7 @@
// · 'phase' {id, kind} (form / barrier changes) · '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 } from '../constants.js';
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS } from '../constants.js';
import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js';
import { mitigate } from '../damage.js';
@@ -84,6 +84,10 @@ const ACPUPP_AURA_RADIUS = 2;
const CROSS_REACH = 6;
/** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */
const BOMB_REACH = 1;
/** 暴鸰 投弹: the bomb leaves on the OnAttack event of the cast's Attack clip (the official battle prefab's Boomb ability:
* 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;
/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力
* 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */
const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2;
@@ -1465,22 +1469,43 @@ function kitRoar(ab) {
/**
* 暴鸰 (PRTS): "不进行普通攻击"; 投弹 (boomb, cooldown / initCooldown 1) "仅攻击范围内存在我方单位时可触发:对目标及其周围八格
* 的我方单位造成100%物理伤害(对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,伤害无视迷彩)技能结束后移速最终提升至200%
* ※此技能仅能触发一次,不可沉默" — one bomb on the ranged target (engine priority), then move speed ×boomb.move_speed.
* ※此技能仅能触发一次,不可沉默". The official battle prefab (enemy_1040_bombd, read from the client) drops the bomb as a
* 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.
*/
function kitBombd(ab) {
const s = ab.sk.boomb;
const ms = (s && s.bb.move_speed) || 0;
const reach = (e) => e.base.rangeRadius || 2;
const land = (b, e, atk, t, x, y) => {
const r = t ? t.tileR : Math.round(y), c = t ? t.tileC : Math.round(x);
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%
};
return [
{ spawn(b, e) { e.profile.noAttack = true; } },
skill(s, (b, e, a) => {
const t = byPriority(e, targetsNear(b, e, reach(e)))[0];
if (!t) return;
a.cd = Infinity; a.left = Infinity; // 仅能触发一次
b.fx('explode', { x: t.x, y: t.y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box', id: e.id });
hurt(b, e, t, e.s.atk, 'phys');
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, e.s.atk, 'phys', { tags: ['splash'] });
if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); // 移速最终提升至200%
e.skillAnimUntil = -1; // the cast is drawn through its 'atk' event: the client winds the Attack clip up to it
b.after(BOMBD_RELEASE, () => {
if (!e.alive) return;
const atk = e.s.atk;
b._ev(['atk', e.id, t.id, 'droneBomb']);
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) });
}, { owner: e });
}, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }),
];
}
+2 -1
View File
@@ -719,11 +719,12 @@ 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)
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 + 0.2);
h.run(s.initCooldown + enemiesMod.BOMBD_RELEASE + 1 / 5 + 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 %');
+1 -1
View File
@@ -232,7 +232,7 @@ test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4
assert.equal(typeof SUB.loopshooter.canAttack, 'function', 'attacks only while holding the boomerang');
assert.match(SIM, /boomerang 15 out,\s*3\.75 back/);
assert.ok(!/next attack waits for the boomerang \(2 × distance \/ 10 s\)/.test(SIM), 'SIM §8: the old lob rule is gone');
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|chain\|chainHeal`/);
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|droneBomb\|chain\|chainHeal`/);
assert.match(DESIGN, /BOOMERANG_RETURN_SPEED/);
// 蕾缪安: one shell every 0.3 s after the skill (PRTS), fx 'bombardShell' then 'bombard' — the kit's constants
const kit = readFileSync(join(ROOT, 'server/sim/content/kits/tier6.js'), 'utf8');
+98
View File
@@ -0,0 +1,98 @@
// test/render/feedback1d-bombd.test.js — community report D4 after 0.1.0 ("敌方无人机暴鸰的炸弹无法正常投放"), client side:
// 暴鸰'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.
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 { ANIM } from '../../shared/constants.js';
import { PROJ } from '../../public/js/render/style.js';
import { PROJECTILE_SPEEDS } from '../../server/sim/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 KEY = 'enemy_1040_bombd';
let fake, UnitView, FORMS, FX;
before(async () => {
fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
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() {} } };
}
const sample = (anim = 0) => ({ x: 6, y: 10, hp: 4000, maxHp: 4000, sp: 0, spMax: 0, flags: 0, anim, vx: -0.3 });
describe('D4 暴鸰: the drone lets go of its bomb on screen', () => {
test('the bomb-less clip set exists in the skeleton the manifest lists', () => {
const f = FORMS[KEY]?.bombed;
assert.ok(f, 'FORMS.enemy_1040_bombd.bombed');
const anims = assets.enemies[KEY].spine.animations;
for (const name of [f.roles.idle, f.roles.deploy, f.roles.die, f.roles.move.begin, f.roles.move.loop, f.roles.move.end]) assert.ok(name in anims, name);
assert.ok(!f.change, 'no change clip: the drop itself is the Attack clip (its OnAttack releases the bomb)');
assert.equal(assets.enemies[KEY].spine.anims.move.loop, 'Move_Loop', 'carrying the bomb until then');
});
test('after the drop the drone flies and dies on the *_2 clips; a view built later starts bomb-less', async () => {
const ctx = fakeViewCtx(fake.P, { assets: store(KEY), cam });
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' });
await tick(); await tick();
v.sync(sample(ANIM.MOVE), 1);
assert.equal(v.actor.current, 'Move_Loop', 'the bomb under the drone');
v.setForm('bombed');
v.sync(sample(ANIM.MOVE), 2);
assert.equal(v.actor.current, 'Move_Loop_2', 'no bomb any more');
v.sync(sample(ANIM.IDLE), 3);
assert.equal(v.actor.current, 'Idle_2');
v.die();
assert.equal(v.actor.current, 'Die_2');
const w = new UnitView(ctx, { id: 6, side: 'enemy', kind: 'enemy', defId: KEY, spine: KEY, tier: 2, x: 6, y: 10, maxHp: 4000, motion: 'FLY', form: 'bombed' });
await tick(); await tick();
w.sync(sample(ANIM.MOVE), 1);
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 () => {
// 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' });
await tick(); await tick();
const frames = (n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); };
v.sync(sample(ANIM.MOVE), 1);
assert.equal(v.actor.current, 'Move_Loop');
// the 'atk' droneBomb is 0.2 game s ahead in the buffer: the clip winds up to its OnAttack (0.267 s)
assert.ok(v.windUp(0.2, 'droneBomb'), 'wound up');
assert.equal(v.actor.current, 'Attack');
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)');
assert.equal(v.atkInterval, 5, 'a one-off drop leaves the attack rhythm alone');
});
test('the drop draws a falling bomb at the sim\'s speed and the mode change draws nothing of its own', () => {
assert.ok(PROJ.droneBomb, 'PROJ.droneBomb');
assert.equal(PROJ.droneBomb.speed, PROJECTILE_SPEEDS.droneBomb);
assert.equal(PROJ.droneBomb.look, 'shell', 'a bomb with a ground shadow, exploding where it lands');
assert.equal(FX.fxSpec('phase', { kind: 'bombed', id: 5 }).a, 'none');
assert.notEqual(FX.fxSpec('phase', { kind: 'crawl', id: 5 }).a, 'none', '掠海漂移体 keeps its puff');
});
});
+2 -2
View File
@@ -189,8 +189,8 @@ describe('shots', () => {
assert.equal(fx.projs.length, 1, kind);
const pr = fx.projs[0];
fx.update(DT);
assert.equal(pr.shadow.visible, kind === 'bomb' || kind === 'lob' || kind === 'boomerang', `${kind} shadow`);
if (kind === 'bomb') {
assert.equal(pr.shadow.visible, PROJ[kind].look === 'shell' || kind === 'boomerang', `${kind} shadow`);
if (kind === 'bomb' || kind === 'droneBomb') {
run(fx, pr.dur);
assert.ok(fx.rings.some((r) => r.sp.texture === fx.tex.shock), 'bomb explosion shockwave');
}
+125
View File
@@ -0,0 +1,125 @@
// test/sim/feedback1d-bombd.test.js — community report D4 after 0.1.0 ("敌方无人机暴鸰的炸弹无法正常投放"). 暴鸰
// (enemy_1040_bombd, only from the 悬赏·飞行II / 战术特训·飞行II bounties) did trigger its one bomb, but the damage and the
// blast hit the operator in the very tick of the trigger while the drone flew on with the bomb still hanging under it
// 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.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, give, legalTileFor, chessOfTier, DATA } from '../match/harness.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { PROJECTILE_SPEEDS, TICK } from '../../server/sim/constants.js';
const KEY = 'enemy_1040_bombd';
const RELEASE = enemiesMod.BOMBD_RELEASE;
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 暴鸰). */
function realRound(seed = 3, round = 4) {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed, fake: false });
h.start();
h.toPrep(round);
const m = h.m, ps = h.ps('p_0');
let placed = 0;
for (const id of [...chessOfTier(1), ...chessOfTier(2)]) {
if (placed >= 7) break;
const t = legalTileFor(m, ps, id);
if (!t) continue;
give(m, ps, id, 'board', t);
placed++;
}
m.addBounty(ps, DATA.choices.cards.bounty.find((c) => c.effectId === 'enemyeffect_10_5'));
const opts = m._normalOpts(ps);
assert.ok(opts.spawns.some((s) => s.enemyKey === KEY), 'the bounty adds a 暴鸰 to the real wave');
return { h, m, b: m.newBattle(opts) };
}
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', () => {
const { m, b } = realRound();
const log = [];
let drone = null, castAt = null, hitAt = null, firstHit = null;
const hits = [];
b.on('damaged', (c) => {
if (c.source !== drone || !drone) return;
hits.push({ t: b.time, target: c.target, amount: c.amount ?? c.dmg?.amount, splash: (c.dmg?.tags || []).includes('splash') });
if (hitAt == null) { hitAt = b.time; firstHit = c.target; }
});
while (!b.finished && b.time < 200) {
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;
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;
}
assert.ok(drone, 'the drone spawned');
assert.ok(castAt != null, 'it triggered its bomb near the operators');
const atk = log.filter(([, ev]) => ev[0] === 'atk');
assert.equal(atk.length, 1, 'exactly one drop (and never a normal attack)');
const [relT, relEv] = atk[0];
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(hitAt != null, 'the bomb lands');
assert.equal(firstHit.id, relEv[2], 'on the operator it was dropped on');
assert.ok(hitAt - relT > TICK, `then flies (${(hitAt - relT).toFixed(3)} s at ${PROJECTILE_SPEEDS.droneBomb} tiles/s)`);
assert.ok(hitAt - relT < 1.2, 'and lands promptly');
const main = hits.filter((x) => !x.splash);
assert.equal(main.length, 1, 'one main hit');
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');
}
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(drone.stats.attacks, 0, 'no normal attack');
m.dispose();
});
const WALL = (id) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const arena = (units) => makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: { t_a: WALL('t_a'), t_b: WALL('t_b'), t_c: WALL('t_c') } },
kits: { t_a: () => ({ trait: { noAttack: true } }), t_b: () => ({ trait: { noAttack: true } }), t_c: () => ({ trait: { noAttack: true } }) },
units,
});
const put = (h, pos) => h.spawn(KEY, { pos, routeIndex: 0, mods: { speedMul: 0 }, route: 2 });
test('a target gone mid-flight: the bomb still lands where it was and splashes its neighbours', () => {
const h = arena([{ chessId: 't_a', row: 10, col: 5 }, { chessId: 't_b', row: 11, col: 5 }]);
h.step();
const e = put(h, [10, 7]);
const [a, bb] = [h.unit('t_a'), h.unit('t_b')];
h.runUntil(() => (ability(e)?.casts ?? 0) > 0, 5);
h.run(RELEASE + TICK);
const rel = h.eventsOf('atk').find((ev) => ev[1] === e.id);
assert.ok(rel, 'released');
const tgt = [a, bb].find((u) => u.id === rel[2]);
const other = tgt === a ? bb : a;
h.b.retreat(tgt, { reason: 'test', permanent: true });
h.run(1.5);
assert.equal(tgt.stats.taken, 0, 'the withdrawn target is not hit');
assert.ok(other.stats.taken > 0, 'its neighbour takes the splash at the landing point');
});
test('the drone killed before the release: no bomb, no mode change', () => {
const h = arena([{ chessId: 't_a', row: 10, col: 6 }]);
h.step();
const e = put(h, [10, 7]);
h.runUntil(() => (ability(e)?.casts ?? 0) > 0, 5);
h.b.kill(e, null);
h.run(2);
assert.equal(h.unit('t_a').stats.taken, 0);
assert.ok(!h.events.some((ev) => ev[0] === 'atk' && ev[1] === e.id), 'no drop');
assert.ok(!h.events.some((ev) => ev[0] === 'fx' && ev[1] === 'phase' && ev[4]?.id === e.id), 'no bomb-less mode');
});
});