- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double, one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them. - sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's. Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). - Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency). - Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors, typecheck, check:imports.
403 lines
20 KiB
JavaScript
403 lines
20 KiB
JavaScript
// 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';
|
||
import { hypot } from '../../detmath.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, // 家族暗影灭迹人
|
||
});
|
||
|
||
/**
|
||
* 隐匿 of the 鸭爵 strategy's swapped-in enemies (spawn tag 'duck': content/bands/meta.js duckReplace) comes back no sooner
|
||
* than this many seconds after a block ends — in practice 流泪小子 (enemy_2034_sythef_2, the only one of the four with 隐匿;
|
||
* officially 0 s, STEALTH_RESTORE_BY_KEY). A deliberate deviation from the official game: the owner's decision of
|
||
* 2026-10-07 「鸭爵策略产生的泪眼汪汪…要不等待个1秒这样再重新隐匿」 (community report of 2026-10-07, GitHub #214). The same
|
||
* enemy anywhere else — the 流泪小子 of a bounty card (enemy_2034_sythef), a test spawn — keeps the official time.
|
||
*/
|
||
const DUCK_STEALTH_RESTORE = 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 = 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): `n` rounded to a whole count, at least 1. */
|
||
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, DUCK_STEALTH_RESTORE, TEA_SPLASH_RADIUS, SHELL_SPLASH_RADIUS, stOf, safe, dispatch,
|
||
num, auraBuff, watchDeaths, onTerrain,
|
||
};
|