feedback5 R3: content/enemies.js → content/enemies/*.js, a pure move — every declaration verbatim with its comments: helpers.js (shared constants, per-battle state, ability bookkeeping, selection / damage helpers), archetypes.js (ability archetypes and the archetype kits reused by several keys), one file per kit family with its kit-only constants and its part of KITS (invisible, times, element, dot, reflection, fly, special, leaders — the kitLeaderMisc entries that closed the SPECIAL section open the leaders part, so the order stands); enemies.js keeps the header, the dispatch hooks and dispatchers, STATS_ONLY and the chalice share, builds KITS from the parts (188 keys, same order, same functions) and re-exports all 43 names it exported before. docs-consistency pins kitMace's inR in enemies/leaders.js

This commit is contained in:
sganggs committed 2026-10-05 16:16:37 +08:00
1 parent 13224f9483
commit 560328cacc
12 files changed
+3192 -2885

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
// server/sim/content/enemies/helpers.js — shared helpers of the enemy kits (split from content/enemies.js; the module
// map is in its header): the constants several families use, the per-battle state, the ability bookkeeping (abOf /
// attach / dispatch, T, canCast …) and the selection and damage helpers (elem, hurt, areaAllies & friends, zone,
// spawnChildren, setForm, lpLoss, expose …).
import { ELEMENT } from '../../constants.js';
import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js';
import { mitigate } from '../../damage.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
/** 侵蚀 → engine gauge. */
export const EROSION = 'erosion';
/** Official erosion burst on operators (gamedata_const termDescriptionDict ba.dt.erosion; engine constants ELEMENT.erosion.ally). */
export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDown, physDamage: ELEMENT.erosion.ally.damage });
/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为
* 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */
export const HUSK_REBIRTH = 1;
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
const BOOM_RADIUS = 1.25;
/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */
const POLLUTION_INTERVAL = 1;
/**
* 隐匿 that comes back sooner after a block than the general 3 s (PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言…不被阻挡的
* 3秒后重新进入隐匿"; engine STEALTH_RESTORE): the enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" — PRTS text search
* "解除阻挡0秒后恢复" (21 enemies; these 6 are in this mode) and "解除阻挡1秒后恢复" (the two 家族灭迹人), checked
* 2026-10-03. Every other stealthy enemy here has a plain "隐匿" (3 s), the 深池逐火 embers included. 清明 / 堂皇's veil
* ("获得隐匿(解除阻挡0秒后恢复)") is kitInvisShield's.
*/
const STEALTH_RESTORE_BY_KEY = Object.freeze({
enemy_10031_cnvsld: 0, // 业余竞演者
enemy_10034_cnvsax: 0, // 节日爵士乐手
enemy_9008_acbunn: 0, // 假想敌:骨刺
enemy_2034_sythef: 0, // 流泪小子
enemy_2034_sythef_2: 0, // 流泪小子 (its stronger copy)
enemy_1389_winbab_2: 0, // 访问团强攻冠军
enemy_1283_sgkill: 1, // 家族灭迹人
enemy_1283_sgkill_2: 1, // 家族暗影灭迹人
});
/** Splash radius (中点判定 around the target) of 烹泉 / 沏虹's attack (PRTS 天赋 "普通攻击对目标对及目标周围半径1.0范围内的所有
* 我方单位造成法术普通伤害(无视迷彩,不可对空)") and of 集团军重型火炮's shell (PRTS "对目标半径1.0范围内的所有我方单位造成攻击力
* 100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)"). */
const TEA_SPLASH_RADIUS = 1, SHELL_SPLASH_RADIUS = 1;
/** Every Nth attack for "数次攻击后" abilities without an SP cost in the data [ASSUMED]; with a cost N = spCost + 1
* (official 粉碎攻坚手 text for spCost 2: "攻击2次后,下一次攻击" ⇒ 3rd; 码头水手 / 澪 / 清扫小队 spCost 3 ⇒ 4th). */
const NTH_ATTACK = 3;
export const nthOf = (s) => (s && s.sp > 0 ? s.sp + 1 : NTH_ATTACK);
// ---------------------------------------------------------------------------------------------------------------
// per-battle state + installation
const STATE = new WeakMap();
function stOf(b) {
let st = STATE.get(b);
if (!st) { st = { lpLoss: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); }
return st;
}
// ---------------------------------------------------------------------------------------------------------------
// ability bookkeeping
/** The enemy's ability record (created on demand). */
export function abOf(b, e) {
if (e.mem.ab) return e.mem.ab;
const key = e.defId;
const ov = (b.enemyOverrides && (b.enemyOverrides[key] ?? b.enemyOverrides[e.def && e.def.key])) || null;
const raw = (e.def && e.def.raw) || {};
const t = { ...((e.def && e.def.talent) || {}), ...((ov && ov.talents && ov.talents.bb) || {}) };
const tS = { ...((raw.talents && raw.talents.bbStr) || {}), ...((ov && ov.talents && ov.talents.bbStr) || {}) };
const sk = {};
for (const s of (ov && Array.isArray(ov.skills) ? ov.skills : (e.def && e.def.skills) || [])) {
if (!s || s.prefabKey == null) continue;
sk[s.prefabKey] = { cd: num(s.cooldown, 0), icd: num(s.initCooldown, 0), sp: num(s.spCost, 0), bb: s.bb || {}, bs: s.bbStr || {} };
}
e.mem.ab = { key, t, tS, sk, list: [], times: null, hitShield: 0, atkType: null, immune: null, origMaxHp: e.base.maxHp };
return e.mem.ab;
}
/** Attach abilities to an enemy (calls their spawn()). */
export function attach(b, e, list) {
const ab = abOf(b, e);
for (const a of list) {
if (!a) continue;
if (a.cd != null) a.left = num(a.left, num(a.icd, 0));
ab.list.push(a);
if (a.spawn) safe(b, e, () => a.spawn(b, e, a, ab));
}
return ab;
}
function safe(b, e, fn) {
try { return fn(); } catch (err) { b._handlerError('content:enemies', e, err); return undefined; }
}
function dispatch(b, e, name, c) {
const ab = e && e.mem && e.mem.ab;
if (!ab) return false;
let any = false;
for (const a of ab.list) {
const f = a[name];
if (!f) continue;
if (a.sil && e.s.flags.silence) continue;
try { if (f.call(a, c, b, e, a, ab)) any = true; } catch (err) { b._handlerError(`enemies:${name}`, e, err); }
}
return any;
}
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
/** First finite talent value among keys. */
export function T(ab, ...keys) { for (const k of keys) { const v = ab.t[k]; if (typeof v === 'number' && Number.isFinite(v)) return v; } return undefined; }
export function silenced(e) { return !!e.s.flags.silence; }
export function canCast(e, sil) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence); }
// ---------------------------------------------------------------------------------------------------------------
// helpers (exported for bosses.js)
/** Element damage (erosion mapped onto the engine gauge); `ignoreSelect` / `tags` as hurt(). */
export function elem(b, src, tgt, el, amount, { ignoreSelect = false, tags = [] } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
const element = el === 'erosion' ? EROSION : el;
return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/**
* Skill / ability damage (no dodge unless asked). `ignoreSelect` = no selection the target's 无法选择 effects stop
* (abilities that "无视无法选择", direct picks, a flying unit's blast credited to a ground leader): it also reaches an
* airborne 起飞 ally, which a ground enemy's damage otherwise skips (damage.js, targeting.js evadesGround).
*/
export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true, ignoreSelect = false } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/** Allies whose tile is within `n` of (r, c): 'plus' = Manhattan (周围四格), 'box' = Chebyshev (周围8格). */
export function alliesInTiles(b, r, c, shape = 'plus', n = 1) {
const out = [];
for (const a of b.allies()) {
if (a.hidden) continue;
const dr = Math.abs(a.tileR - r), dc = Math.abs(a.tileC - c);
if (shape === 'plus' ? dr + dc <= n : Math.max(dr, dc) <= n) out.push(a);
}
return out;
}
/** Allies within radius that enemy `e` could target (ranged rules: stealth / untargetable skipped). */
export function targetsNear(b, e, r, { ranged = true, x = e.x, y = e.y } = {}) {
return b.alliesInRadius(x, y, r).filter((a) => canTargetAlly(e, a, ranged));
}
/** Every targetable ally on the field. */
export function allTargets(b, e) {
return b.allies().filter((a) => canTargetAlly(e, a, true));
}
/**
* The allies an AREA effect of enemy `src` selects within `r` of (x, y) — splash, blast, area skill / status, pulse, zone
* tick, chain or bounce jump (targeting.js areaSelectable: no 隐匿 ally, the blocker included, no untargetable or sleeping ally, no 起飞 one
* for a ground `src`; 迷彩 is not checked). `src` = the enemy whose effect it is (null: none — 隐匿 still applies). A
* locked target goes first through targetAndArea. Abilities PRTS marks "无视无法选择 / 无视可选性" use b.alliesInRadius with
* `ignoreSelect` instead.
*/
export function areaAllies(b, src, x, y, r) {
return b.alliesInRadius(x, y, r).filter((a) => areaSelectable(src, a));
}
/** alliesInTiles for an area effect of `src` (周围四格 / 周围八格 / cross / 3×3 areas — 格子判定): areaSelectable. */
export function areaAlliesInTiles(b, src, r, c, shape = 'plus', n = 1) {
return alliesInTiles(b, r, c, shape, n).filter((a) => areaSelectable(src, a));
}
/** Every ally on the field a whole-field ability of `src` selects ("对场上所有我方单位…": 【大潮】, 【斥退】 …). */
export function fieldAllies(b, src) {
return b.allies().filter((a) => areaSelectable(src, a));
}
/**
* The allies within `r` of (x, y) a buff aura of enemy `src` takes (targeting.js auraSelectable: no 隐匿 ally, the blocker included,
* untargetable or sleeping ally — PRTS "隐匿状态下的单位一般无法被敌方的…Buff选择器选中"; an airborne 起飞 ally is still
* taken [ASSUMED, §21.22]). 寒霜's aura, which PRTS says ignores 隐匿, keeps b.alliesInRadius (allyAura).
*/
export function auraAllies(b, src, x, y, r) {
return b.alliesInRadius(x, y, r).filter((a) => auraSelectable(src, a));
}
/**
* A locked target `t` and the allies an area takes around it: the target was picked directly when the ability started
* (an attack's target, a channel's or a C4's lock), so a 隐匿 it gains meanwhile does not save it — PRTS 异常效果 "'直接选中'
* 的能力不会进行具体的目标选择,故同样不受这些仅在选择时生效的异常效果制约"; only the others are an area selection (`area`).
*/
export function targetAndArea(t, area) {
return t && t.alive && t.deployed ? [t, ...area.filter((u) => u !== t)] : area;
}
/** Engine priority (blocker → taunt → latest deployed) on a copy. */
export function byPriority(e, list) { return sortAllyTargets(e, list.slice()); }
/**
* An HP-loss / damage zone. `tick(units)` runs every `iv` s for `life` s on the allies inside: `pick(x, y, r)` chooses
* them — by default every ally in the circle (【污染秽蚀】 "无视无法选择"); the other zones (dmgZone, 烹泉's steam) pass
* their area selection (areaAllies).
*/
export function zone(b, { x, y, r, life, iv = 1, kind = 'zone', tick, pick = null }) {
b.fx('zone', { x, y, r, dur: life, kind });
let left = life;
const h = b.every(iv, () => {
tick(pick ? pick(x, y, r) : b.alliesInRadius(x, y, r));
left -= iv;
if (left <= 1e-6) h.cancel();
});
return h;
}
/** Remaining route of an enemy from its current position (for summons / phantoms that continue its path). */
export function remainingRoute(e) {
const R = e.route;
const here = [e.y, e.x];
if (!R || !Array.isArray(R.legs) || !R.legs.length) return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end: here, checkpoints: [] };
const steps = [];
let end = null;
for (let i = Math.max(0, R.legIdx); i < R.legs.length; i++) {
const L = R.legs[i];
if (L.final) { end = [L.r, L.c]; break; }
if (L.t === 'move') steps.push({ t: 'move', p: [L.r, L.c] });
else if (L.t === 'wait') steps.push({ t: 'wait', s: i === R.legIdx && R.waitLeft != null ? R.waitLeft : L.time });
else if (L.t === 'disappear') steps.push({ t: 'disappear' });
else if (L.t === 'appear') steps.push({ t: 'appear', p: [L.r, L.c] });
}
if (!end) { const last = R.legs[R.legs.length - 1]; end = last && last.r != null ? [last.r, last.c] : [Math.round(e.y), Math.round(e.x)]; }
const route = { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end, checkpoints: [] };
if (steps.length) route.steps = steps;
return route;
}
/** A route that keeps an enemy in place (content moves it by hand). */
export function stayRoute(e, secs = 99999) {
const p = [Math.round(e.y), Math.round(e.x)];
return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: p, end: p, steps: [{ t: 'wait', s: secs }] };
}
/**
* Mods copied onto a split or summoned child. The parent's stat multipliers travel; a kill bounty does not
* (owner 2026-10-04: the main body alone carries it — GitHub #67, #89-2). `bountyId` is the 悬赏 card and
* `bountyCoins` is the coin copy the match writes for 联防. The parent object is left as it is.
*/
function modsWithoutBounty(mods) {
if (!mods || typeof mods !== 'object') return mods ?? null;
if (mods.bountyId == null && mods.bountyCoins == null) return mods;
const copy = { ...mods };
delete copy.bountyId;
delete copy.bountyCoins;
return copy;
}
/** Spawn `n` enemies at the parent's position that continue its route. Returns the spawned units. */
export function spawnChildren(b, parent, key, n, opts = {}) {
const out = [];
const route = opts.route ?? remainingRoute(parent);
const cnt = Math.max(0, Math.min(20, Math.floor(n)));
const mods = modsWithoutBounty(opts.mods ?? parent.mods ?? null);
for (let i = 0; i < cnt; i++) {
const off = cnt > 1 ? (i - (cnt - 1) / 2) * 0.2 : 0;
const pos = opts.pos ?? [parent.y, parent.x + off];
const c = b.spawnEnemy(key, {
pos, route, mods, tag: opts.tag ?? null, countInTotal: opts.countInTotal,
ownerPlayerId: parent.ownerId ?? null, sourcePlayerId: parent.sourcePlayerId ?? null,
});
if (c) out.push(c);
}
if (out.length) b.fx('summon', { x: parent.x, y: parent.y, id: parent.id, key, n: out.length });
return out;
}
/** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */
export function stepToward(u, tx, ty, dist) {
const dx = tx - u.x, dy = ty - u.y;
const d = Math.hypot(dx, dy);
if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; }
u.x += (dx / d) * dist;
u.y += (dy / d) * dist;
return false;
}
/** Set a 频次 unit's hits (maxHp := hits, full). */
export function setHits(e, n) {
const h = Math.max(1, Math.round(n));
e.base.maxHp = h;
e.markDirty();
void e.s;
e.hp = h;
}
const HIT_COUNT_KEY = 'ab:hitCount';
/** 频次 on/off: every damage instance removes exactly 1 HP (engine flag; `artsOnly` = 茶器, physical instances remove 0). */
export function hitCount(b, e, on, artsOnly = false) {
if (!on) { b.removeBuff(e, HIT_COUNT_KEY); return; }
b.addBuff(e, { key: HIT_COUNT_KEY, persist: true, flags: artsOnly ? { hitCountArts: true } : { hitCount: true } });
}
export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY);
/**
* The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit
* (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched
* later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`) carrying it
* as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the
* client never drops it, not in a catch-up frame nor while its tab is hidden. Barrier / charge 'phase' kinds are no
* forms: they go through b.fx directly, without `form`.
*/
export function setForm(b, e, form, fxKind = 'phase', params = null) {
e.form = form;
b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form, form } : { ...params, x: e.x, y: e.y, id: e.id, form });
}
/** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */
export function lpLoss(b, amount, reason, source = null) {
if (!(amount > 0)) return;
const st = stOf(b);
st.lpLoss += amount;
b.fx('lpLoss', { x: source ? source.x : 0, y: source ? source.y : 0, value: amount, reason });
b.emit('lpLoss', { amount, reason, source });
}
/** 暴露: damage taken ×scale for `dur` s. */
export function expose(b, u, dur, scale) {
if (!u || !u.alive || !(dur > 0)) return;
b.addBuff(u, { key: 'ab:exposed', duration: dur, refresh: 'extend', mods: { dmgTakenMul: scale }, visible: true });
b.fx('expose', { x: u.x, y: u.y, id: u.id });
}
/**
* Final HP damage the pipeline would deal for a phys/arts/true/elemental `dmg` before shields (mitigation is linear in
* the amount) — the same terms as damage.js dealDamage (元素伤害: 元素抗性 + elementalTakenMul, no dmgTakenMul).
*/
export function expectedFinal(src, tgt, dmg) {
const ts = tgt.s, ss = src && src.s && !dmg.sourceless ? src.s : null, ty = dmg.type; // 无来源 (bursts): no source stats
const v = mitigate(dmg.amount, ty, ts, {
defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0),
elementalRes: ty === 'elemental' ? (tgt.def?.epDamageResistance ?? 0) : 0,
});
let mul = (dmg.mul ?? 1) * (ty === 'elemental' ? 1 : ts.dmgTakenMul); // 脆弱 skips 元素伤害 (damage.js)
if (ss) mul *= ss.dmgDealtMul * (ty === 'phys' ? ss.physDealtMul : ty === 'arts' ? ss.artsDealtMul : 1);
mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ty === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
const f = v * mul;
return Number.isFinite(f) && f > 0 ? f : 0;
}
/**
* "吸收法术伤害的屏障": absorb up to `left` of an arts instance in a `hit` handler (after RES, like a shield) by scaling
* the instance down (cancelled when fully absorbed). Returns the amount absorbed.
*/
export function absorbArts(c, left) {
if (!c || c.dmg.type !== 'arts' || !(left > 0) || c.dmg.cancel) return 0;
const f = expectedFinal(c.source, c.target, c.dmg);
if (!(f > 0)) return 0;
const take = Math.min(left, f);
if (take >= f - 1e-9) c.dmg.cancel = true;
else c.dmg.amount *= (f - take) / f;
return take;
}
/** Timed buff on each unit (same key never stacks: aura semantics). */
function auraBuff(b, u, key, iv, mods, flags = null, visible = false) {
b.addBuff(u, { key, duration: iv + 0.1, refresh: 'replace', mods, flags, visible });
}
/** Mark an ally/enemy ability as watching every death on the field. */
function watchDeaths(b, e) { stOf(b).deathWatch.add(e); }
const tileOf = (u) => [Math.round(u.y), Math.round(u.x)];
const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.tile(r, c).terrain === terrain; };
// used by the other enemy modules (content/enemies.js and content/enemies/*.js)
export {
BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, TEA_SPLASH_RADIUS, SHELL_SPLASH_RADIUS, stOf, safe, dispatch,
num, auraBuff, watchDeaths, onTerrain,
};