diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..592442dd 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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]`, diff --git a/public/js/render/app.js b/public/js/render/app.js index 7d87e9a2..2e145fa4 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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; diff --git a/public/js/render/fx.js b/public/js/render/fx.js index 2b33238a..615d6e86 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -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)); diff --git a/public/js/render/spine.js b/public/js/render/spine.js index eb19706d..96777a4a 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -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) { diff --git a/public/js/render/style.js b/public/js/render/style.js index 839f7353..8729b145 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -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. */ diff --git a/public/js/render/units.js b/public/js/render/units.js index c2dcccb5..1421cf71 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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; } diff --git a/server/sim/constants.js b/server/sim/constants.js index 8103d7d7..5343047d 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -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; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 6b4bc47a..1954fe4e 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -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 }), ]; } diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index 36ef0d71..2120105d 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -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 %'); diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index eba2a1d2..bec417d9 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -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'); diff --git a/test/render/feedback1d-bombd.test.js b/test/render/feedback1d-bombd.test.js new file mode 100644 index 00000000..a00f4cf1 --- /dev/null +++ b/test/render/feedback1d-bombd.test.js @@ -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'); + }); +}); diff --git a/test/render/fxproj.test.js b/test/render/fxproj.test.js index 84dee306..31a6213d 100644 --- a/test/render/fxproj.test.js +++ b/test/render/fxproj.test.js @@ -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'); } diff --git a/test/sim/feedback1d-bombd.test.js b/test/sim/feedback1d-bombd.test.js new file mode 100644 index 00000000..153d2b3d --- /dev/null +++ b/test/sim/feedback1d-bombd.test.js @@ -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'); + }); +});