Branch review of fb7-unbalance (MERGE AFTER FIXES): 1. A 静态刚体 pulled got the 0.1 s floor: _staticForce now knows push from pull — a push's force is instant (推与拉), so 0.1 s; a pull's lasts its window (pullUnbalance: 1 s, 0.5 s below −1) — PRTS 特殊机制 静态刚体 「在失去施力后立刻」 out, 「0.1 秒保底」. pull() checks the puller's own block before the static case. 2. 弧光锋卫 (PRTS 天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」, data unbalanced_bleed): a tick bleed of 400 every 0.066 s while unbalancedNow holds; the per-tile one-shot from the position jump ([ASSUMED], UNBALANCE_SPEED) is gone. A 受力等级 0 push (0.8 s) → 12 hits = 4800 (was 2037), a 急停 pull with no movement (1 s) → 15 = 6000 (was 0), a wall-shortened slide keeps the 0.8 s row → 4800. 3. 失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」: enemies/helpers.js unbalancedNow; canCast(e, sil, battle) refuses while it runs (content skills; the boss parts' calls pass the battle), blinkForward returns null, 枯朽之种's dive / blast and the 铳 / 碎铳之簧 charges wait, 乌顶巨角卢鲁's clash waits for its end. A route APPEAR relocation clears it (a forced state switch, 失衡位移机制). Left running on purpose (docs/SIM.md): 水遁忍者's damage check (异常效果 「仍然能正常造成伤害」), passive auras and terrain / periodic effects, death / damage-taken triggers, skill cooldowns, 弧光锋卫's bleed itself. 4. husk() (逐火 余烬 重生) clears the state like reborn(). 5. The 35/30 row held 36 frames (t0 + 35/30 overshoots in floats): every check compares with 1e-9; the frames are asserted. Tests: test/sim/unbalance.test.js +6 (exact frames 6 / 12 / 24 / 27 / 32 / 35 with the walk on the next frame; static pull 1 s / 0.5 s, push 0.1 s; 弧光锋卫 4800 / 6000 / 4800; canCast / blinkForward refused, 枯朽之种 held then diving; the elk's clash after the state; APPEAR and a 逐火 重生 clear it — all six fail on the parent); enemies_bosses (失衡 kit test) and feedback1d-solvent (BUFF damage) updated to the state bleed. test/sim + test/content + test/render pass. Docs: SIM.md (push / pull row), PLAYING.md, design/engine.md (helpers, enemy AI), constants / displacement comments. Golden against the previous commit — outcome moves: roster-010 (歌蕾蒂娅 pulls 妖怪 drones, now held 1 s: killed 44→45, leaks 58→57), roster-040 (static pulls of 妖怪: killed 28→25, leaks 37→40, deaths 29→30), roster-043 / 044 (山: deaths 14→16 / 14→13), hidden-boss_9-pair (锏: boss HP 3636991→3586929), coop4-ABYSS-7 (ai_2 LP +1 from round 5), diy-115 (黑键: leaks 70→75), diy-116 (黑键: killed 2→3, leaks 121→120) — the static pulls (probe) and the 1e-9 end (a frame shorter where the float sum overshot: the 35/30 row, some pull windows); other moves (damage / snapshots only): roster-008 / 017 / 039, 9 bonds, boss-boss_4-pair / boss_6-pair / hidden-boss_10-pair, coop2-NORMAL-14-standins / coop2-NORMAL-3 / solo-ABYSS-1 / solo-HARD-1 / solo-HARD-9-boosted / solo-NORMAL-1, diy-053 / 085 / 102.
324 lines
22 KiB
JavaScript
324 lines
22 KiB
JavaScript
// server/sim/content/enemies.js — enemy special types (特训敌人) and individual enemy abilities (DESIGN §7).
|
||
//
|
||
// Battle side: install(battle) registers ONE set of global dispatch hooks per battle (ensureInstalled — idempotent,
|
||
// bosses.js calls it too) and attaches an ability list to every spawned enemy whose key has a kit in KITS.
|
||
// Every number comes from the enemy's talent / skill blackboard (data/enemies.json, merged with the wave template's
|
||
// `overrides[key].talents/skills` — the engine only applies `overrides.stats`). Numbers that exist nowhere in the
|
||
// data are named constants (enemies/helpers.js when several kit families use them, else in the family's file), marked
|
||
// [ASSUMED] or sourced from the official term table (gamedata_const termDescriptionDict) / PRTS.
|
||
//
|
||
// Runtime model: `enemy.mem.ab = { key, t (talent bb), tS (talent bbStr), sk {prefabKey → {cd, icd, sp, bb, bs}},
|
||
// list [abilities], times, hitShield, atkType, immune, … }`. An ability is a plain object with optional methods
|
||
// spawn(b,e,a,ab) · dealt(c,…) ('damaged' by one of e's ATTACKS on an ally, non-element) · taken(c,…) ('damaged' on e,
|
||
// non-element) · hitIn(c,…) ('hit' on e, late: after every other hit handler) · hitOut(c,…) ('hit' from e, early) ·
|
||
// (`c.source` is null for 无来源 damage — element bursts; target-side reactions that must still fire, e.g. damage
|
||
// sharing, read `c.source || c.credit`, `credit` naming the unit that filled the gauge) ·
|
||
// before(c,…) (beforeAttack of e) · attack(c,…) · blocked(c,…) · killed(c,…) (return true = revived; the kill is then
|
||
// hidden from later 'kill' handlers) · death(c,…) · status(c,…) (statusApplied on e) · burst(c,…) (elementBurst on e)
|
||
// · tick(b,e,a,dt) every `iv` s (every tick when iv is 0) · skill: `cd`/`left`(=icd)/`cond`/`fire` (cooldown runs
|
||
// continuously; fires when ready, the enemy can act and `cond` holds). `sil: true` = the ability's handbook line
|
||
// is flagged SILENCE (沉默 disables it: exactly the data's `format: "SILENCE"` lines — and 萨卡兹枯朽战士 / 组长's
|
||
// 死亡爆炸, a NORMAL line whose client template checks 沉默: archetypes.js kitPolluted); stun / freeze / sleep block
|
||
// skills. Enemy SP skills ("数次攻击后…"): SP +1 per attack, the (spCost+1)-th attack is the skill attack (official
|
||
// 粉碎攻坚手 text: spCost 2 = "攻击2次后,下一次攻击").
|
||
// Shared mechanics: reborn() — "首次被击倒后重生 / 第二形态" (the first KO is hidden from kill credit and bounty; a
|
||
// rebirth of Reborn.duration s, untargetable and inert, then the new form); artsBarrier() — "吸收法术伤害的屏障"
|
||
// (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd);
|
||
// unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook);
|
||
// husk() — "被击倒时暂时变为…,一段时间后重生" (Revive[Trigger]: every knock-out of the first form ⇒ 1 s 重生 ⇒ a walking
|
||
// hit-count husk — 隐匿 逐火 embers, the unblockable 再生 puppet — until the real death or its revival); every 重生
|
||
// (reborn / husk / statue) clears the enemy's statuses and the buffs allies gave it (rebirthCleanse, PRTS 特殊机制
|
||
// §重生 "清空自身身上除白名单外所有Buff"); a data-unarmed
|
||
// enemy armed by a form (转译基底) sets `e.profile` noAttack / melee / dmgType / maxTargets and attacks through the
|
||
// engine (ai.js enemyAttack); float() — 近地悬浮 (an air unit that
|
||
// keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the
|
||
// enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a
|
||
// special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed
|
||
// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值). Area effects — splash, blasts, area skills and statuses,
|
||
// pulses, the zones an enemy leaves, chain / bounce jumps, 周围四格 additions, whole-column / whole-field skills —
|
||
// select with areaAllies / areaAlliesInTiles / fieldAllies (targeting.js areaSelectable; PRTS 作战机制 §AOE伤害判定
|
||
// "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"): no 隐匿 ally, the one blocking the enemy included (GitHub #97), no untargetable
|
||
// or sleeping one, no airborne 起飞 one for a ground enemy; 迷彩 is not checked (splash-type, 中点判定 / 格子判定 and
|
||
// aura effects ignore it — ba.camou "无法躲避溅射类攻击", PRTS 异常效果 迷彩; the sites with no such reading are
|
||
// [ASSUMED] in DESIGN §22.12). Buff auras select with auraAllies (auraSelectable: 隐匿 kept out, 起飞 not). A locked
|
||
// target (an attack's, a channel's, a C4's) is hit as a direct pick, only the others around it as an area
|
||
// (targetAndArea). Until 0.1.2 they took every ally in the area (GitHub issue #32 item 6; DESIGN §22.12). An airborne ally
|
||
// (起飞, flag `liftoff`) is no target of a ground enemy (对地规避, targeting.js evadesGround): targetsNear / allTargets
|
||
// skip it through canTargetAlly, the area selectors through areaSelectable, and the engine refuses a ground enemy's
|
||
// damage and statuses on it. Not selections, so they still reach it (`ignoreSelect`): abilities PRTS marks "无视无法
|
||
// 选择 / 无视(目标)可选性" (【污染秽蚀】, 【盲信之誓】, 萨卡兹悖谬暴虐兵长's 暴击 splash, 假想敌:淤困's burst spread),
|
||
// direct picks of the attacker (碎铳之簧's counter — PRTS 异常效果 "'直接选中'的能力…不受这些仅在选择时生效的异常效果
|
||
// 制约"), the blasts of flying units credited to a ground leader (刺胄之弹, 斩胄之剑 / 破胄之锤) and the ticks of a debuff
|
||
// already on it (出血, 沙狱, “庞贝”'s burning, 淤困, 【自然涌动】 — a tick selects nobody). Ground enemies' buff auras
|
||
// (深池伙友卫队, 扎罗's 远古威慑) and the sourceless 毒雾 of 假想敌:蚀裂 still reach it [ASSUMED, §21.22] while keeping
|
||
// a 隐匿 ally out; 寒霜's aura (PRTS: its debuff "无视隐匿状态起作用") and map / tile effects (“墓碑” — PRTS: a map effect —,
|
||
// 【国度】, the chimera's 源石污染区 [ASSUMED]) reach every ally.
|
||
//
|
||
// Special types (factions.json):
|
||
// FLY — engine (FLY motion, ranged-only targeting). Flyer kits in enemies/fly.js (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …).
|
||
// TIMES 频次 — "需要N次伤害击倒": maxHp := N hits; engine buff flag hitCount — every damage instance (any type, element
|
||
// bursts included) removes exactly 1 on its own DamageInfo (茶器 hitCountArts: phys removes 0); HP loss
|
||
// (loseHp) bypasses the rule. The faction's enemies
|
||
// create those units (death spawns, embers, blades, 再生).
|
||
// ELEMENT — element damage on hit = ATK × ep_damage_ratio into the ally's gauge. 侵蚀 (erosion) is an engine
|
||
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
|
||
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit, "可对空,无视无法选择"
|
||
// — `ignoreSelect`, 起飞 / 隐匿 allies included; 燃烧区域: the enemy's area selection — PRTS 集团军重型火炮
|
||
// "碰撞不受迷彩制约,不可对空", no 无视无法选择 —, so no 起飞 or 隐匿 ally; 假想敌:蚀裂's sourceless
|
||
// 毒雾 skips a 隐匿 ally, not a 起飞 one), bleeding (removed by healing), pulsing damage around an enemy
|
||
// (area selection).
|
||
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). An operator's radius area
|
||
// damage skips an unblocked one too (Battle.foesInRadius: profession splash around a struck target,
|
||
// skill circles — PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 it
|
||
// still hit it); tile selectors (enemiesInKeys) always skipped it. After a block it hides again only
|
||
// STEALTH_RESTORE (3) s later — or after the "(解除阻挡N秒后恢复)" of its PRTS page (STEALTH_RESTORE_BY_KEY,
|
||
// 清明's veil 0 s) — Battle._stealthSwitch / targeting.js enemyStealthed (until 0.1.2: at once).
|
||
// The mirror rule for an ally's 隐匿 — enemy area effects skip it even when it blocks the enemy (0.1.3, GitHub #97) — is areaAllies.
|
||
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
|
||
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
|
||
// SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … in enemies/special.js.
|
||
// Every enemy key of data/enemies.json is either in KITS (this file), BOSS_KEYS (bosses.js) or STATS_ONLY (with the
|
||
// reason) — pinned by test/content/enemies_bosses.test.js. Bounty (悬赏) leaders are included: multi-form ones
|
||
// (巨大的丑东西, 杰斯顿, 自在, 锏, 扎罗) get their second form; event-only mechanics (芦苇, 摄影区, 狂欢时刻, 悬索桥, 唤血祭坛,
|
||
// 封冻/供暖器, 赘生甲壳, 锁链, 血债账款, 晦明) have no counterpart in this mode and are listed where they apply.
|
||
//
|
||
// fx kinds emitted (battle.fx(kind, {x, y, …})): 'explode' {r, kind} · 'zone' {r, dur, kind} · 'telegraph' {r, dur,
|
||
// kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} ·
|
||
// 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'}
|
||
// · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak'
|
||
// {id} · 'liberate' {id, form: 'liberty'} · 'phase' {id, kind, dur?, form?} (form changes — 掠海漂移体 'crawl', 暴鸰
|
||
// 'bombed', translator_*, a prisoner's 'warning' — and barrier / charge states) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through
|
||
// setForm(): the unit keeps it (`e.form`, UnitInfo `form`) and the fx's `form` is the model's clip set from then on
|
||
// (render/app.js → UnitView.setForm, units.js FORMS); the client keeps every fx with a `form` through catch-ups and
|
||
// hidden tabs (shared/protocol.js fxForm).
|
||
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
|
||
//
|
||
// Code layout (0.2.0): this file installs the battle-wide dispatch hooks (ensureInstalled), runs the global
|
||
// dispatchers, builds KITS from the kit families and lists STATS_ONLY; the rest lives in server/sim/content/enemies/ —
|
||
// helpers.js constants several families use, per-battle state, ability bookkeeping (abOf / attach / dispatch, T,
|
||
// canCast …), selection and damage helpers (elem, hurt, areaAllies & friends, zone, spawnChildren,
|
||
// setForm, lpLoss, expose, absorbArts …)
|
||
// archetypes.js ability building blocks (stealth, splashAttack, deathBoom, refraction, float, reborn, husk, statue,
|
||
// artsBarrier, bleed, pollution, dmgZone, skill(), blinkForward …) and the archetype kits reused by
|
||
// several keys (kitEp, kitStealth, kitTimes, kitRefraction, kitPrisoner, kitDeathSpawn, kitStun3 …)
|
||
// invisible.js, times.js, element.js, dot.js, reflection.js, fly.js, special.js — one file per special type
|
||
// (factions.json): its kits, its kit-only constants and its part of KITS
|
||
// leaders.js bounty (悬赏) leaders: kitLeaderMisc (single form) and the multi-form kits
|
||
// Every name this file exported before the split is still exported from here.
|
||
|
||
import { TICK } from '../constants.js';
|
||
import { stOf, abOf, attach, safe, dispatch, canCast, elem, alliesInTiles } from './enemies/helpers.js';
|
||
import { INVISIBLE_KITS } from './enemies/invisible.js';
|
||
import { TIMES_KITS } from './enemies/times.js';
|
||
import { ELEMENT_KITS } from './enemies/element.js';
|
||
import { DOT_KITS } from './enemies/dot.js';
|
||
import { REFLECTION_KITS } from './enemies/reflection.js';
|
||
import { FLY_KITS } from './enemies/fly.js';
|
||
import { SPECIAL_KITS } from './enemies/special.js';
|
||
import { LEADER_KITS } from './enemies/leaders.js';
|
||
import { hypot } from '../detmath.js';
|
||
|
||
export {
|
||
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, unbalancedNow, elem, hurt, alliesInTiles,
|
||
targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, auraAllies, targetAndArea, byPriority, zone,
|
||
remainingRoute, stayRoute, spawnChildren, stepToward, setHits, hitCount, isHitCount, setForm, lpLoss, expose,
|
||
expectedFinal, absorbArts,
|
||
} from './enemies/helpers.js';
|
||
export { TRANSLATOR_CHANGE } from './enemies/leaders.js';
|
||
export { BOMBD_RELEASE, BOMBD_POST_DELAY } from './enemies/fly.js';
|
||
export { HOVER_KEYS, blinkForward } from './enemies/archetypes.js';
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// per-battle state + installation
|
||
|
||
/** Register the global dispatch hooks once per battle (idempotent). */
|
||
export function ensureInstalled(b) {
|
||
const st = stOf(b);
|
||
if (st.installed) return st;
|
||
st.installed = true;
|
||
b.on('enemySpawn', ({ enemy }) => onSpawn(b, enemy), { priority: 100 });
|
||
b.on('hit', (c) => onHitOut(b, c), { priority: 200 });
|
||
b.on('hit', (c) => chaliceShare(b, c), { priority: -400 });
|
||
b.on('hit', (c) => onHitIn(b, c), { priority: -500 });
|
||
b.on('damaged', (c) => onDamaged(b, c), { priority: 50 });
|
||
b.on('beforeAttack', (c) => onBeforeAttack(b, c), { priority: 50 });
|
||
b.on('attack', (c) => { if (c.attacker && c.attacker.side === 'enemy') dispatch(b, c.attacker, 'attack', c); }, { priority: 50 });
|
||
b.on('blocked', (c) => dispatch(b, c.enemy, 'blocked', c), { priority: 50 });
|
||
b.on('kill', (c) => onKill(b, c), { priority: 1000 });
|
||
b.on('death', (c) => onDeath(b, c), { priority: 50 });
|
||
b.on('beforeStatus', (c) => {
|
||
const ab = c.target && c.target.mem && c.target.mem.ab;
|
||
if (!ab) return;
|
||
if (ab.immune && ab.immune.has(c.status)) c.cancel = true;
|
||
}, { priority: 50 });
|
||
b.on('statusApplied', (c) => { if (c.target && c.target.side === 'enemy') dispatch(b, c.target, 'status', c); }, { priority: 50 });
|
||
b.on('heal', (c) => {
|
||
// 逐腐兽 bleeding ends when the target receives healing (natural regeneration excluded)
|
||
if (c.amount > 0 && c.target && c.target.side === 'ally' && !(c.opts && c.opts.regen) && c.target.findBuff('ab:bleed')) b.removeBuff(c.target, 'ab:bleed');
|
||
}, { priority: -50 });
|
||
b.on('elementBurst', (c) => onBurst(b, c), { priority: 50 });
|
||
b.on('tick', (c) => onTick(b, c.dt), { priority: 50 });
|
||
b.on('battleEnd', ({ result }) => { if (st.lpLoss > 0 && result) result.lpLoss = (result.lpLoss ?? 0) + st.lpLoss; });
|
||
return st;
|
||
}
|
||
|
||
export function install(battle) { ensureInstalled(battle); }
|
||
|
||
export function registerMeta() {}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// global dispatchers
|
||
|
||
function onSpawn(b, e) {
|
||
const kit = KITS[e.defId];
|
||
if (typeof kit !== 'function') return;
|
||
const ab = abOf(b, e);
|
||
const list = safe(b, e, () => kit(ab, e, b)) || [];
|
||
attach(b, e, list);
|
||
}
|
||
|
||
function onHitOut(b, c) {
|
||
const s = c.source;
|
||
if (!s || s.side !== 'enemy' || !s.mem.ab || c.dmg.cancel) return;
|
||
const ab = s.mem.ab;
|
||
if (ab.atkType && c.dmg.isAttack && c.dmg.type !== 'element') c.dmg.type = ab.atkType;
|
||
dispatch(b, s, 'hitOut', c);
|
||
}
|
||
|
||
function onHitIn(b, c) {
|
||
const t = c.target;
|
||
if (!t || t.side !== 'enemy' || !t.mem.ab || c.dmg.cancel) return;
|
||
const ab = t.mem.ab;
|
||
dispatch(b, t, 'hitIn', c);
|
||
if (c.dmg.cancel) return;
|
||
const ty = c.dmg.type;
|
||
if (ab.hitShield > 0 && (ty === 'phys' || ty === 'arts')) {
|
||
// "可以抵挡一次物理或法术伤害" / 再生's aura shield: the whole instance is negated
|
||
c.dmg.cancel = true;
|
||
ab.hitShield--;
|
||
if (ab.hitShield <= 0) b.fx('shieldBreak', { x: t.x, y: t.y, id: t.id });
|
||
}
|
||
}
|
||
|
||
function onDamaged(b, c) {
|
||
const { source: s, target: t, dmg } = c;
|
||
if (!dmg || dmg.type === 'element') return;
|
||
if (t && t.side === 'enemy' && t.mem.ab) dispatch(b, t, 'taken', c);
|
||
if (s && s.side === 'enemy' && s.mem.ab && dmg.isAttack && t && t.side === 'ally') dispatch(b, s, 'dealt', c);
|
||
}
|
||
|
||
function onBeforeAttack(b, c) {
|
||
const a = c.attacker;
|
||
if (a && a.side === 'enemy' && a.mem.ab) dispatch(b, a, 'before', c);
|
||
}
|
||
|
||
function onKill(b, c) {
|
||
const v = c.victim;
|
||
if (!v || v.side !== 'enemy' || !v.mem.ab) return;
|
||
if (dispatch(b, v, 'killed', c) && v.hp > 0) c.stopPropagation = true; // revived: not a kill for anyone else
|
||
}
|
||
|
||
function onDeath(b, c) {
|
||
const u = c.unit;
|
||
const st = stOf(b);
|
||
if (u && u.side === 'enemy' && u.mem.ab) dispatch(b, u, 'death', c);
|
||
if (st.deathWatch.size) for (const w of [...st.deathWatch]) { if (!w.alive) { st.deathWatch.delete(w); continue; } dispatch(b, w, 'otherDeath', c); }
|
||
}
|
||
|
||
function onBurst(b, c) {
|
||
const t = c.target;
|
||
if (!t) return;
|
||
if (t.side === 'enemy') { if (t.mem.ab) dispatch(b, t, 'burst', c); return; }
|
||
// 假想敌:淤困 parasite: the host's burst spreads the same element to the 4 neighbouring allies — PRTS 天赋 "(中点判定,
|
||
// 无视目标可选性,不受迷彩制约)": every ally there, 隐匿 / untargetable / airborne 起飞 ones included (`ignoreSelect`)
|
||
const par = t.findBuff('ab:parasite');
|
||
if (par && par.data && par.data.src) {
|
||
const src = par.data.src;
|
||
for (const o of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) if (o !== t) elem(b, src, o, c.element, par.data.spread, { ignoreSelect: true });
|
||
}
|
||
}
|
||
|
||
function onTick(b, dt) {
|
||
const list = b.enemies;
|
||
const n = list.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const e = list[i];
|
||
if (!e || !e.alive || !e.mem.ab) continue;
|
||
const ab = e.mem.ab;
|
||
for (let j = 0; j < ab.list.length; j++) {
|
||
const a = ab.list[j];
|
||
if (!e.alive) break;
|
||
if (a.tick) {
|
||
if (!(a.sil && e.s.flags.silence)) {
|
||
if (!(a.iv > 0)) safe(b, e, () => a.tick(b, e, a, dt));
|
||
else {
|
||
a._acc = (a._acc ?? 0) + dt;
|
||
let k = 0;
|
||
while (a._acc >= a.iv - 1e-9 && k++ < 4 && e.alive) { a._acc -= a.iv; safe(b, e, () => a.tick(b, e, a, a.iv)); }
|
||
}
|
||
}
|
||
}
|
||
if (a.fire && a.cd != null) {
|
||
a.left -= dt;
|
||
if (a.left <= 1e-9 && canCast(e, a.sil, b) && (!a.cond || safe(b, e, () => a.cond(b, e, a)))) {
|
||
a.left = Math.max(TICK, a.cd);
|
||
a.casts = (a.casts ?? 0) + 1;
|
||
e.skillAnimUntil = b.time + 0.5;
|
||
safe(b, e, () => a.fire(b, e, a));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// KITS: enemyKey → (ab, e, battle) => ability list. One line per key (name · what), in the family files
|
||
// (enemies/*.js); spread here in the original order — the same keys in the same order as before the split.
|
||
|
||
export const KITS = Object.freeze({
|
||
...INVISIBLE_KITS,
|
||
...TIMES_KITS,
|
||
...ELEMENT_KITS,
|
||
...DOT_KITS,
|
||
...REFLECTION_KITS,
|
||
...FLY_KITS,
|
||
...SPECIAL_KITS,
|
||
...LEADER_KITS,
|
||
});
|
||
|
||
/** Keys deliberately left to their data stats (reason). */
|
||
export const STATS_ONLY = Object.freeze({
|
||
enemy_1000_gopro_2: 'plain melee', enemy_1001_bigbo: 'plain leader (bounty)', enemy_1005_yokai: 'FLY, no attack (engine)',
|
||
enemy_1005_yokai_2: 'FLY ranged (engine)', enemy_1005_yokai_3: 'FLY ranged (engine)', enemy_1006_shield: 'heavy defender',
|
||
enemy_1006_shield_2: 'heavy defender', enemy_1006_shield_3: 'heavy defender', enemy_1007_slime: 'plain',
|
||
enemy_1010_demon: 'plain', enemy_1010_demon_2: 'plain', enemy_1041_lazerd: 'FLY arts (engine)', enemy_1041_lazerd_2: 'FLY arts (engine)',
|
||
enemy_1043_zomsbr: 'regen is data (hpRecoveryPerSec 80)', enemy_1061_zomshd: 'regen is data (hpRecoveryPerSec 200)',
|
||
enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)',
|
||
enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same',
|
||
enemy_1252_lysytb_2: 'same', enemy_1254_lypa_2: 'no listed ability', enemy_1325_cbgpro: 'plain', enemy_1325_cbgpro_2: 'plain',
|
||
enemy_1367_dseed: 'altar pulse absent (骸骨拷打者 leaves them inert, uncounted, in place)',
|
||
enemy_1381_winman: 'plain', enemy_1381_winman_2: 'plain', enemy_1387_winshd: '封冻/供暖器 zones absent', enemy_1415_mmkabi_2: 'chains absent',
|
||
enemy_1433_dsbasi: 'plain', enemy_1433_dsbasi_2: 'plain', enemy_1438_dspred: '赘生甲壳 absent',
|
||
enemy_2002_bearmi: 'plain', enemy_2002_bearmi_2: 'plain',
|
||
enemy_9012_acloon: '炎佑 is a bond summon (bonds.js)', enemy_10040_cnvbln: '狂欢时刻 absent', enemy_10043_sailor: 'bridges/cannons absent',
|
||
enemy_10073_mpcar: 'plain', enemy_10124_uashld_2: 'taunt is data; 矿工游击队 absent',
|
||
});
|
||
|
||
// 圣杯 damage sharing: ground enemies near a living chalice pass `share` of the damage they take to it.
|
||
function chaliceShare(b, c) {
|
||
const st = stOf(b);
|
||
if (!(st.chalices > 0)) return;
|
||
const t = c.target;
|
||
if (!t || t.side !== 'enemy' || t.isFlying || c.dmg.cancel || c.dmg.type === 'element' || (c.dmg.tags && c.dmg.tags.includes('chalice'))) return;
|
||
for (const o of b.enemies) {
|
||
if (!o.alive || o === t || o.defId !== 'enemy_1430_lrrook' || !o.mem.ab) continue;
|
||
const a = o.mem.ab.list[0];
|
||
if (!a || !(a.share > 0) || hypot(o.x - t.x, o.y - t.y) > a.r) continue;
|
||
const part = c.dmg.amount * a.share;
|
||
c.dmg.amount -= part;
|
||
// a 无来源 burst's share stays 无来源, credited like the burst (damage.js)
|
||
b.dealDamage(c.dmg.sourceless ? c.credit : c.source, o, { amount: part, type: c.dmg.type, canDodge: false, sourceless: c.dmg.sourceless, tags: [...(c.dmg.tags || []), 'chalice'] });
|
||
break;
|
||
}
|
||
}
|