Files
ark-stronghold-protocol/server/sim/content/enemies.js
T

322 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 below, 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); 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 below (御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, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below.
// 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} · 'phase' {id, kind, dur?, form?} (form changes — 掠海漂移体 'crawl', 暴鸰 'bombed',
// translator_* — 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';
export {
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, 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) && (!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) || Math.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;
}
}