- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
402 lines
20 KiB
JavaScript
402 lines
20 KiB
JavaScript
// server/sim/damage.js — damage & heal pipeline, shields, dodge, element gauges (DESIGN §5.5).
|
||
//
|
||
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
|
||
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
|
||
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul × type-taken mul × dmg.mul
|
||
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
|
||
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
|
||
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
|
||
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
|
||
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000), reduced by the
|
||
// target's 损伤抵抗 (data `epResistance`, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)").
|
||
// A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
|
||
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
|
||
// (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
|
||
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
|
||
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
|
||
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
|
||
// 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
|
||
// fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
|
||
// resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
|
||
// counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
|
||
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
|
||
// travel in b.snap `elem` (elementView).
|
||
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
|
||
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
|
||
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
|
||
|
||
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
|
||
|
||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||
|
||
/** Normalise a DamageInfo descriptor. */
|
||
export function makeDamageInfo(d = {}) {
|
||
const type = d.type ?? 'phys';
|
||
return {
|
||
_norm: true,
|
||
amount: Number.isFinite(+d.amount) ? +d.amount : 0,
|
||
type,
|
||
element: d.element ?? null,
|
||
atkScale: d.atkScale ?? 1,
|
||
defIgnoreFlat: d.defIgnoreFlat ?? 0,
|
||
defIgnorePct: d.defIgnorePct ?? 0,
|
||
resIgnoreFlat: d.resIgnoreFlat ?? 0,
|
||
resIgnorePct: d.resIgnorePct ?? 0,
|
||
mul: d.mul ?? 1,
|
||
canDodge: d.canDodge ?? (type === 'phys' || type === 'arts'),
|
||
isSkill: !!d.isSkill,
|
||
isSplash: !!d.isSplash,
|
||
isAttack: !!d.isAttack,
|
||
isProjectile: !!d.isProjectile,
|
||
tags: d.tags ?? [],
|
||
cancel: false,
|
||
noSp: !!d.noSp,
|
||
ignoreSleep: !!d.ignoreSleep,
|
||
attackId: d.attackId ?? 0,
|
||
};
|
||
}
|
||
|
||
/** 沉睡 (ba.sleep "无敌且无法行动"): only attackers whose profile has `hitSleep` (or `ignoreSleep` damage) reach a sleeper. */
|
||
function sleepBlocks(target, source, dmg) {
|
||
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
|
||
}
|
||
|
||
/** Pure mitigation formula (exported for tests). */
|
||
export function mitigate(amount, type, target, ign = {}) {
|
||
if (type === 'phys') {
|
||
const D = target.def ?? 0;
|
||
const eff = Math.max(0, D * (1 - clamp01(ign.defIgnorePct ?? 0)) - (ign.defIgnoreFlat ?? 0));
|
||
return Math.max(amount - eff, MIN_DAMAGE_RATIO * amount);
|
||
}
|
||
if (type === 'arts') {
|
||
const R = target.res ?? 0;
|
||
const eff = Math.max(0, R * (1 - clamp01(ign.resIgnorePct ?? 0)) - (ign.resIgnoreFlat ?? 0));
|
||
return Math.max(amount * (1 - Math.min(100, eff) / 100), MIN_DAMAGE_RATIO * amount);
|
||
}
|
||
return amount;
|
||
}
|
||
|
||
/** Absorb damage with shields on `target`. Returns the remaining amount. */
|
||
export function absorbShields(battle, target, amount) {
|
||
if (amount <= 0) return 0;
|
||
let changed = false;
|
||
let rest = amount;
|
||
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
|
||
const b = target.buffs[i];
|
||
if (b.shieldHits > 0) {
|
||
b.shieldHits--;
|
||
rest = 0;
|
||
if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; }
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
|
||
const b = target.buffs[i];
|
||
if (b.shield > 0) {
|
||
const take = Math.min(b.shield, rest);
|
||
b.shield -= take;
|
||
rest -= take;
|
||
changed = true;
|
||
if (b.shield <= 1e-9) {
|
||
b.shield = 0;
|
||
if (!b.mods && !b.flags && !(b.shieldHits > 0)) { battle._removeBuffAt(target, i); i--; }
|
||
}
|
||
}
|
||
}
|
||
if (changed) target.markDirty();
|
||
return rest;
|
||
}
|
||
|
||
/**
|
||
* Full damage pipeline. Returns the HP actually removed (0 when dodged/cancelled/absorbed).
|
||
*/
|
||
export function dealDamage(battle, source, target, dmgIn) {
|
||
if (!target || !target.alive || target.removed || target.hidden || !target.deployed) return 0;
|
||
const dmg = dmgIn && dmgIn._norm ? dmgIn : makeDamageInfo(dmgIn);
|
||
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
|
||
let ts = target.s;
|
||
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
|
||
if (battle._hooks.hit) {
|
||
battle.emit('hit', { source, target, dmg });
|
||
if (dmg.cancel || !target.alive || !target.deployed) return 0;
|
||
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
|
||
ts = target.s; // handlers may have changed the target's buffs (fragile, invulnerable, dodge…): never use stale stats
|
||
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
|
||
}
|
||
const type = dmg.type;
|
||
// dodge
|
||
if (dmg.canDodge && (type === 'phys' || type === 'arts')) {
|
||
const p = type === 'phys' ? ts.dodgePhys : ts.dodgeArts;
|
||
if (p > 0 && battle.rng() < p) {
|
||
battle.fx('dodge', { x: target.x, y: target.y, id: target.id });
|
||
if (battle._hooks.dodge) battle.emit('dodge', { source, target, dmg });
|
||
return 0;
|
||
}
|
||
}
|
||
// 频次 units (flags hitCount / hitCountArts): every damage instance removes exactly 1 HP (maxHp = hits needed);
|
||
// hitCountArts ignores physical instances. The instance keeps its own DamageInfo, so `damaged` handlers still
|
||
// recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp.
|
||
if (ts.flags.hitCount || ts.flags.hitCountArts) {
|
||
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
|
||
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
|
||
}
|
||
const ss = source && source.s ? source.s : null;
|
||
let final = mitigate(dmg.amount, type, ts, {
|
||
defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0),
|
||
defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0),
|
||
resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0),
|
||
resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0),
|
||
});
|
||
let mul = dmg.mul * ts.dmgTakenMul;
|
||
if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1);
|
||
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elemTakenMul : ts.trueTakenMul;
|
||
final *= mul;
|
||
if (!(final > 0) || !Number.isFinite(final)) final = 0;
|
||
final = absorbShields(battle, target, final);
|
||
return applyHpLoss(battle, source, target, final, dmg);
|
||
}
|
||
|
||
/**
|
||
* Remove HP (after mitigation) and run damaged/fatal/kill bookkeeping. Also used by `battle.loseHp` (流失).
|
||
*/
|
||
export function applyHpLoss(battle, source, target, amount, dmg) {
|
||
if (!target.alive) return 0;
|
||
let dealt = 0;
|
||
if (target.bossPool) {
|
||
const pool = target.bossPool;
|
||
const before = pool.hp;
|
||
const pid = source && source.side === 'ally' ? source.ownerId : null;
|
||
if (amount > 0) {
|
||
try { pool.damage(pid, amount); } catch (e) { battle._internalError('bossPool.damage', e); pool.hp = Math.max(0, pool.hp - amount); }
|
||
}
|
||
dealt = Math.max(0, before - pool.hp);
|
||
if (!Number.isFinite(dealt)) dealt = 0; // a misbehaving pool (NaN hp) must not poison damage stats
|
||
if (pid != null) { const pp = battle._pp(pid); if (pp) pp.bossDamage += dealt; }
|
||
battle._syncBossHp(target);
|
||
} else {
|
||
const before = target.hp;
|
||
target.hp -= amount;
|
||
if (target.hp <= 0) {
|
||
target.hp = 0;
|
||
if (battle._hooks.fatal) {
|
||
const fctx = { unit: target, source, dmg, amount, prevented: false };
|
||
battle.emit('fatal', fctx);
|
||
if (fctx.prevented && target.alive) { if (target.hp < 1) target.hp = Math.min(1, target.s.maxHp); }
|
||
}
|
||
}
|
||
dealt = Math.max(0, before - Math.max(0, target.hp));
|
||
}
|
||
if (source) {
|
||
source.stats.dmg += dealt;
|
||
if (source.side === 'ally' && source.ownerId != null) { const pp = battle._pp(source.ownerId); if (pp) pp.damageDealt += dealt; }
|
||
}
|
||
target.stats.taken += dealt;
|
||
target.lastHitAt = battle.time;
|
||
if (dmg && !dmg.silent && amount >= 0.5) {
|
||
const shown = dmg.type === 'element' ? dmg.element : dmg.type === 'elemental' ? (dmg.element || 'true') : dmg.type;
|
||
battle._ev(['dmg', target.id, Math.round(amount), shown]);
|
||
}
|
||
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount, type: dmg ? dmg.type : 'true', dmg });
|
||
if (target.side === 'ally' && target.skill && dmg && !dmg.noSp && dmg.type !== 'element') battle._skills.onDamaged(target);
|
||
const dead = target.bossPool ? target.bossPool.hp <= 0 : target.hp <= 0;
|
||
if (dead && target.alive) battle.kill(target, source);
|
||
return dealt;
|
||
}
|
||
|
||
/**
|
||
* Are `target`'s element gauges locked (爆发冷却, see header): a burst's `<el>Burst` lock is up (any element — the lock
|
||
* holds every gauge of the unit), or a burst of the unit is resolving right now. The element argument of older callers
|
||
* is ignored.
|
||
*/
|
||
export function burstLocked(target) {
|
||
const p = target.burstPending;
|
||
if (p) for (const k in p) if (p[k]) return true;
|
||
return !!target.s.flags.burstLock;
|
||
}
|
||
|
||
/** The burst lock buff (爆发冷却) running on `u`, or null. */
|
||
function burstLockOf(u) {
|
||
let best = null;
|
||
for (const b of u.buffs) if (b.flags && b.flags.burstLock && (!best || b.timeLeft > best.timeLeft)) best = b;
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* The element gauge a unit shows (b.snap `elem`, render/units.js): null when every gauge is empty and no burst
|
||
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge (ties: the lower
|
||
* official element id, constants.js ELEMENT_ORDER) — with its remaining EP; during a 爆发冷却 the bursting element,
|
||
* refilling over the cooldown. Returns `[el, fill 0..1 (2 decimals), cooldownEnd (game s), cooldown (s)]`; the two
|
||
* cooldown numbers are 0 outside a cooldown. `now` = the battle time.
|
||
*/
|
||
export function elementView(u, now) {
|
||
const e = u.elem;
|
||
if (!e) return null;
|
||
const lock = u.s.flags.burstLock ? burstLockOf(u) : null;
|
||
if (lock) {
|
||
const el = lock.key.endsWith('Burst') ? lock.key.slice(0, -5) : 'burn';
|
||
const dur = Number.isFinite(lock.duration) && lock.duration > 0 ? lock.duration : 0;
|
||
const left = Number.isFinite(lock.timeLeft) ? Math.max(0, lock.timeLeft) : 0;
|
||
return [el in e ? el : 'burn', 1, Math.round((now + left) * 100) / 100, Math.round(dur * 100) / 100];
|
||
}
|
||
let best = null, bv = 0;
|
||
for (const k of ELEMENT_ORDER) { const v = e[k]; if (v > bv) { bv = v; best = k; } }
|
||
if (!best) return null;
|
||
const fill = Math.min(1, Math.max(0.01, Math.round((bv / u.gaugeMax) * 100) / 100));
|
||
return [best, fill, 0, 0];
|
||
}
|
||
|
||
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
|
||
export function applyElement(battle, source, target, dmg) {
|
||
const el = dmg.element;
|
||
if (!el || !(el in target.elem)) return 0;
|
||
if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
|
||
if (burstLocked(target, el)) return 0;
|
||
if (battle._hooks.elementHit) {
|
||
battle.emit('elementHit', { source, target, dmg });
|
||
if (dmg.cancel || !target.alive || !target.deployed || burstLocked(target, el)) return 0;
|
||
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
|
||
}
|
||
const max = target.gaugeMax;
|
||
// 损伤抵抗 (official EP_RESISTANCE = data epResistance, a percentage)
|
||
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01((target.def?.epResistance ?? 0) / 100));
|
||
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
|
||
target.elem[el] = Math.min(max, target.elem[el] + amt);
|
||
battle._ev(['dmg', target.id, Math.round(amt), el]);
|
||
if (source) source.stats.elem = (source.stats.elem ?? 0) + amt;
|
||
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount: amt, type: 'element', dmg });
|
||
if (target.elem[el] >= max && target.alive) elementBurst(battle, source, target, el);
|
||
return amt;
|
||
}
|
||
|
||
/**
|
||
* A burst (see header): side-aware official effects; the `<el>Burst` buff is the 爆发冷却 that locks every gauge of the
|
||
* unit for its duration. The unit is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing
|
||
* re-bursts it in the meantime.
|
||
*/
|
||
export function elementBurst(battle, source, target, el) {
|
||
if (!ELEMENT[el] || !target.alive || burstLocked(target)) return;
|
||
const pending = target.burstPending || (target.burstPending = {});
|
||
pending[el] = true;
|
||
try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; }
|
||
}
|
||
|
||
function resolveBurst(battle, source, target, el) {
|
||
const cfg = ELEMENT[el];
|
||
target.elem[el] = target.gaugeMax;
|
||
// the cooldown's end restores every element of the unit ("随后单位所有种类的元素值恢复至最大值")
|
||
const reset = () => { for (const k of ELEMENT_ORDER) if (k in target.elem) target.elem[k] = 0; };
|
||
battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el });
|
||
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
|
||
if (!target.alive) { reset(); return; }
|
||
const tags = ['burst', el];
|
||
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, tags });
|
||
const lock = (duration, extra = {}) => {
|
||
if (!(duration > 0)) { reset(); return null; }
|
||
return battle.addBuff(target, { key: `${el}Burst`, duration, visible: true, onExpire: reset, onRemove: reset, ...extra, flags: { burstLock: true, ...(extra.flags || {}) } });
|
||
};
|
||
if (el === 'necrosis') {
|
||
lock(cfg.duration, {
|
||
mods: { atkMul: 1 - cfg.atkDownPct }, interval: 1,
|
||
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, tags: ['burst', 'necrosis'] }),
|
||
});
|
||
return;
|
||
}
|
||
const enemy = target.side === 'enemy';
|
||
const c = enemy ? cfg.enemy : cfg.ally;
|
||
if (el === 'burn') {
|
||
lock(c.duration, { mods: { resFlat: -c.resDown } });
|
||
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
|
||
} else if (el === 'neural') {
|
||
// PRTS 元素: the status first ("立刻获得等时长的眩晕" / "获得3层麻痹"), then the hit
|
||
lock(c.duration);
|
||
if (enemy) {
|
||
battle.applyStatus(target, 'palsy', { value: c.palsy, source });
|
||
if (target.alive) hit(c.elemDamage, 'elemental');
|
||
} else {
|
||
battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
|
||
if (target.alive) hit(c.damage, c.type);
|
||
}
|
||
} else if (el === 'apoptosis') {
|
||
if (enemy) {
|
||
// 50 % 虚弱 that recovers linearly over the burst, 800 元素伤害 per second
|
||
lock(c.duration, {
|
||
mods: { atkMul: 1 - c.weaken }, interval: 1,
|
||
onTick: ({ buff }) => {
|
||
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft - 1) / c.duration };
|
||
target.markDirty();
|
||
hit(c.elemDps, 'elemental');
|
||
},
|
||
});
|
||
} else {
|
||
// 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second
|
||
lock(c.duration, {
|
||
flags: { silence: true, noSp: true }, interval: 1,
|
||
onTick: () => {
|
||
const sk = target.skill;
|
||
if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec);
|
||
hit(c.dps, c.dpsType);
|
||
},
|
||
});
|
||
}
|
||
} else if (el === 'erosion') {
|
||
// the permanent DEF cut lands first, then the hit; both sides then have their cooldown (operators 10 s, enemies 8 s)
|
||
lock(c.duration);
|
||
battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true });
|
||
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
|
||
}
|
||
}
|
||
|
||
/** Add `n` 麻痹 stacks (at most PALSY_MAX). */
|
||
export function palsyBuff(n) {
|
||
const stacks = Math.max(1, Math.min(PALSY_MAX, Math.floor(Number.isFinite(n) ? n : 1)));
|
||
return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' };
|
||
}
|
||
|
||
/**
|
||
* Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. Nothing recovers during a
|
||
* burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、无法被其他手段回复").
|
||
*/
|
||
export function reduceElement(target, amount, el = null) {
|
||
let removed = 0;
|
||
if (!target || !target.elem || burstLocked(target)) return 0;
|
||
const els = el ? [el] : Object.keys(target.elem);
|
||
for (const k of els) {
|
||
const take = Math.min(target.elem[k], amount - removed);
|
||
if (take > 0) { target.elem[k] -= take; removed += take; }
|
||
if (removed >= amount) break;
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
/** Heal pipeline. Returns the HP actually restored. */
|
||
export function heal(battle, source, target, amount, opts = {}) {
|
||
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
|
||
const self = source === target || !!opts.self;
|
||
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
|
||
let amt = amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
|
||
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
|
||
if (battle._hooks.heal) {
|
||
const ctx = { source, target, amount: amt, opts };
|
||
battle.emit('heal', ctx);
|
||
amt = Number.isFinite(ctx.amount) ? Math.max(0, ctx.amount) : 0;
|
||
// a handler may have killed / retreated the target: healing a dead unit would leave it "dead with hp > 0"
|
||
if (!target.alive || !target.deployed) return 0;
|
||
}
|
||
const max = target.s.maxHp;
|
||
const actual = Math.max(0, Math.min(amt, max - target.hp));
|
||
target.hp = Math.min(max, target.hp + actual);
|
||
if (opts.overheal && amt > actual) {
|
||
// overheal becomes a shield (capped at max HP) so hp stays within [0, maxHp]
|
||
const cur = target.findBuff('overheal');
|
||
const val = Math.min(max, (cur ? cur.shield : 0) + (amt - actual));
|
||
battle.addBuff(target, { key: 'overheal', shield: val, duration: opts.overhealDuration ?? Infinity });
|
||
}
|
||
if (source) {
|
||
source.stats.heal += actual;
|
||
if (source.side === 'ally' && source.ownerId != null) { const pp = battle._pp(source.ownerId); if (pp) pp.healingDone += actual; }
|
||
}
|
||
if (actual >= 0.5 && !opts.silent) battle._ev(['heal', target.id, Math.round(actual)]);
|
||
return actual;
|
||
}
|