- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
412 lines
20 KiB
JavaScript
412 lines
20 KiB
JavaScript
// server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
|
||
//
|
||
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
|
||
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP
|
||
// gain of any kind — time, attack, hurt or granted).
|
||
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
|
||
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
|
||
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
|
||
// TAKE_DAMAGE (ready + just took a hit), SP_FULL/ALWAYS (as soon as ready), CUSTOM_RANGE (enemy inside the
|
||
// custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick without waiting for an attack:
|
||
// BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1 — not any enemy on
|
||
// the field, which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 = DEFAULT, NEVER/MANUAL (never
|
||
// auto-cast: the kit calls skill.activate() itself); any unknown rule falls back to DEFAULT. Units that never attack
|
||
// (noAttack profiles) evaluate DEFAULT every tick instead.
|
||
// Kinds: duration (mods for `duration` s), ammo (mods until `ammo` attacks were made; optional duration cap),
|
||
// instant (onStart + optional one-shot attack override for the next attack), charges (= instant with charges),
|
||
// passive (always on from deployment, no SP), toggle (stays on until death once activated).
|
||
// SkillSpec fields (all optional): kind, duration, ammo, spCost, initSp, charges, spType, trigger,
|
||
// mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend},
|
||
// attack {dmgType, atkScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, onHit, heal…},
|
||
// heal (bool: heal-type skill for the trigger rule), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx).
|
||
// ctx passed to spec callbacks: { battle, unit, skill, bb, target?, dealt?, targets?, dt?, reason? }; onAttack's ctx
|
||
// also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are
|
||
// still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill).
|
||
|
||
import { absoluteRangeKeys } from './targeting.js';
|
||
|
||
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE']);
|
||
/** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */
|
||
const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
|
||
|
||
export class SkillRuntime {
|
||
/**
|
||
* @param {object} battle
|
||
* @param {object} unit
|
||
* @param {object|null} def normalised skill def (data)
|
||
* @param {object|null} spec SkillSpec from the kit
|
||
* @param {object} bb blackboard
|
||
*/
|
||
constructor(battle, unit, def, spec, bb = {}) {
|
||
Object.defineProperty(this, 'battle', { value: battle, writable: true, enumerable: false });
|
||
Object.defineProperty(this, 'unit', { value: unit, writable: true, enumerable: false });
|
||
this.def = def || {};
|
||
this.spec = spec || {};
|
||
this.bb = bb;
|
||
const s = this.spec;
|
||
const d = this.def;
|
||
this.id = s.id ?? d.id ?? 'skill';
|
||
this.name = s.name ?? d.name ?? '';
|
||
this.kind = s.kind ?? 'instant';
|
||
this.duration = Number.isFinite(+s.duration) ? +s.duration : (d.duration > 0 ? d.duration : 0);
|
||
this.ammo = Math.max(0, Math.floor(+s.ammo || 0));
|
||
this.baseSpCost = Math.max(0, Number.isFinite(+s.spCost) ? +s.spCost : (d.spCost ?? 0));
|
||
this.initSp = Math.max(0, Number.isFinite(+s.initSp) ? +s.initSp : (d.initSp ?? 0));
|
||
this.maxCharges = Math.max(1, Math.floor(s.charges ?? d.maxCharges ?? 1));
|
||
this.spType = s.spType ?? d.spType ?? 'time';
|
||
const trig = typeof s.trigger === 'string' ? { rule: s.trigger } : (s.trigger || d.trigger || {});
|
||
this.rule = String(trig.rule ?? 'DEFAULT').toUpperCase();
|
||
if (this.rule === 'ALWAYS') this.rule = 'SP_FULL';
|
||
if (this.rule === 'MANUAL') this.rule = 'NEVER';
|
||
if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE';
|
||
this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null;
|
||
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal);
|
||
if (this.kind === 'passive') this.baseSpCost = 0;
|
||
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
|
||
this.sp = 0;
|
||
this.charges = 0;
|
||
this.active = false;
|
||
this.timeLeft = 0;
|
||
this.ammoLeft = 0;
|
||
this.pending = false; // instant/charges: next attack uses spec.attack
|
||
this.activations = 0;
|
||
this.lastStart = -Infinity;
|
||
this._buffKey = `skill:${unit.id}`;
|
||
this._trigKeys = null;
|
||
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
|
||
this.noSkill = !spec; // unit without any skill spec
|
||
}
|
||
|
||
/**
|
||
* Extra DEFAULT-trigger range (海嗣 "攻击范围视为自身攻击范围的延伸", 流形): `fn(battle, unit)` returns a list whose
|
||
* entries are ally units (their current `rangeKeys` count while they are on the field) or arrays of absolute tile
|
||
* keys. A targetable enemy (flyers included) on those tiles satisfies the DEFAULT rule (and unknown DEFAULT-like
|
||
* rules); it is checked every tick, since the unit itself may have nothing to attack. Returns an unregister fn.
|
||
*/
|
||
addTriggerRange(fn) {
|
||
if (typeof fn !== 'function') return () => {};
|
||
this.triggerRanges.push(fn);
|
||
return () => { const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
|
||
}
|
||
|
||
/** An enemy inside one of the content trigger ranges. */
|
||
_extraTriggerSatisfied() {
|
||
const b = this.battle;
|
||
const u = this.unit;
|
||
for (const fn of this.triggerRanges) {
|
||
const list = b._safe(() => fn(b, u), 'skill.triggerRange', u);
|
||
if (!list || typeof list[Symbol.iterator] !== 'function') continue;
|
||
for (const x of list) {
|
||
let keys = null;
|
||
if (Array.isArray(x)) keys = x;
|
||
else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys;
|
||
if (keys && keys.length && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
get spCost() {
|
||
const c = Math.floor(this.baseSpCost * this.spCostMul + (this.unit.s ? this.unit.s.spCostFlat : 0));
|
||
// content writes spCostMul / spCostFlat: a NaN/Infinity cost would freeze SP forever — use the base cost instead
|
||
return Number.isFinite(c) ? Math.max(0, c) : this.baseSpCost;
|
||
}
|
||
|
||
/** SP cost multiplier (content-writable). Invalid values count as 1; SP/charges are re-normalised immediately. */
|
||
get spCostMul() { return this._spCostMul; }
|
||
set spCostMul(v) {
|
||
this._spCostMul = typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 1;
|
||
if (this.unit) this._normalize();
|
||
}
|
||
|
||
get ready() { return !this.noSkill && this.kind !== 'passive' && this.charges >= 1; }
|
||
|
||
get isTimed() { return this.kind === 'duration' || this.kind === 'ammo' || this.kind === 'toggle'; }
|
||
|
||
_ctx(extra) { return { battle: this.battle, unit: this.unit, skill: this, bb: this.bb, ...extra }; }
|
||
|
||
_call(fnName, extra) {
|
||
const fn = this.spec[fnName];
|
||
if (typeof fn !== 'function') return undefined;
|
||
return this.battle._safe(() => fn(this._ctx(extra)), `skill.${fnName}`, this.unit);
|
||
}
|
||
|
||
/** Called on every (re)deployment. `carry` = { sp, skillActive } for unite helpers. */
|
||
reset(carry = null) {
|
||
this.active = false;
|
||
this.pending = false;
|
||
this.timeLeft = 0;
|
||
this.ammoLeft = 0;
|
||
this.charges = 0;
|
||
this.sp = 0;
|
||
this._trigKeys = null;
|
||
if (this.noSkill) return;
|
||
if (this.kind === 'passive') {
|
||
this._startPassive();
|
||
return;
|
||
}
|
||
this.sp = 0;
|
||
this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true);
|
||
// a free (spCost 0) non-passive skill is available once per deployment
|
||
if (this.spCost <= 0) this.charges = this.maxCharges;
|
||
// unite helpers whose timed skill was running when their combat ended: it keeps running (a fresh duration/ammo),
|
||
// without spending a charge — the carried SP is what they had accumulated (0 while a skill runs).
|
||
if (carry && carry.skillActive && this.isTimed) this.activate('carry', { free: true });
|
||
}
|
||
|
||
_startPassive() {
|
||
this.active = true;
|
||
this._applyMods();
|
||
this._call('onStart', { reason: 'passive' });
|
||
}
|
||
|
||
_applyMods() {
|
||
const s = this.spec;
|
||
if (s.mods || s.flags) {
|
||
this.battle.addBuff(this.unit, { key: this._buffKey, mods: s.mods || null, flags: s.flags || null, tags: ['skill'] });
|
||
}
|
||
if (s.targeting && (s.targeting.rangeGrid || s.targeting.rangeExtend)) this.battle._refreshRange(this.unit);
|
||
}
|
||
|
||
_removeMods() {
|
||
this.battle.removeBuff(this.unit, this._buffKey);
|
||
if (this.spec.targeting && (this.spec.targeting.rangeGrid || this.spec.targeting.rangeExtend)) this.battle._refreshRange(this.unit);
|
||
}
|
||
|
||
/** Add SP (fires the `spGain` hook). Ignored while a duration/ammo/toggle skill runs (its bar shows the skill). */
|
||
gainSp(amount, reason = 'time', silent = false) {
|
||
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
|
||
if (this.active && this.isTimed && reason !== 'init') return 0;
|
||
// 阻回 (the operators' 凋亡 burst, damage.js): "停止并阻止任意形式的技力回复" — no SP of any kind (time, attack, hurt, gifts)
|
||
if (reason !== 'init' && this.unit.s.flags.noSp) return 0;
|
||
let cost = this.spCost;
|
||
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
|
||
let amt = amount;
|
||
const b = this.battle;
|
||
if (!silent && b._hooks.spGain) {
|
||
const ctx = { unit: this.unit, amount: amt, reason, skill: this };
|
||
b.emit('spGain', ctx);
|
||
amt = Number.isFinite(ctx.amount) ? ctx.amount : 0;
|
||
if (!(amt > 0)) return 0;
|
||
// handlers may change the cost (spCostMul), end the skill or kill the unit: re-read, never use a stale cost
|
||
if (this.kind === 'passive' || this.noSkill) return 0;
|
||
cost = this.spCost;
|
||
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
|
||
}
|
||
if (cost <= 0) return 0; // free skills never recharge (see reset)
|
||
this.sp += amt;
|
||
while (this.sp >= cost && this.charges < this.maxCharges) {
|
||
this.charges++;
|
||
if (this.charges < this.maxCharges) this.sp -= cost;
|
||
else this.sp = cost;
|
||
}
|
||
if (this.charges >= this.maxCharges) this.sp = cost;
|
||
return amt;
|
||
}
|
||
|
||
/** Re-establish sp/charges invariants after an SP-cost change (e.g. content sets spCostMul mid-battle). */
|
||
_normalize() {
|
||
if (this.noSkill || this.kind === 'passive') return;
|
||
const cost = this.spCost;
|
||
if (cost <= 0) { if (this.sp > 0) { this.sp = 0; this.charges = this.maxCharges; } return; } // became free
|
||
while (this.sp >= cost && this.charges < this.maxCharges) {
|
||
this.charges++;
|
||
this.sp = this.charges < this.maxCharges ? this.sp - cost : cost;
|
||
}
|
||
if (this.sp > cost) this.sp = cost;
|
||
}
|
||
|
||
/** Per-tick update: durations, SP regen, tick-based triggers. */
|
||
tick(dt) {
|
||
if (this.noSkill) return;
|
||
const u = this.unit;
|
||
if (this.kind !== 'passive' && this.sp > this.spCost) this._normalize();
|
||
if (this.active && this.kind !== 'passive') {
|
||
if (this.spec.onTick) this._call('onTick', { dt });
|
||
if (this.kind === 'duration' || (this.kind === 'ammo' && Number.isFinite(this.timeLeft))) {
|
||
this.timeLeft -= dt;
|
||
if (this.timeLeft <= 1e-9) { this.end('duration'); }
|
||
}
|
||
} else if (this.active && this.kind === 'passive' && this.spec.onTick) {
|
||
this._call('onTick', { dt });
|
||
}
|
||
if (!u.canAct) return;
|
||
if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp) {
|
||
const rate = u.s.spRecovery;
|
||
if (rate > 0) this.gainSp(rate * dt, 'time');
|
||
}
|
||
if (!this.ready || u.s.flags.silence) return;
|
||
if (this.active && this.isTimed) return;
|
||
if (TICK_RULES.has(this.rule)) {
|
||
if (this._tickRuleSatisfied()) this.activate(this.rule);
|
||
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
|
||
// DEFAULT (and unknown rules) for units that cannot attack right now: check the initial range every tick;
|
||
// content trigger ranges are checked every tick for everyone (nothing may be in the unit's own range)
|
||
const prof = u.profile;
|
||
if (prof && (prof.noAttack || (prof.noAttackUnlessSkill && !this.active))) {
|
||
if (this._defaultCondition()) this.activate('DEFAULT');
|
||
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied()) this.activate('DEFAULT');
|
||
}
|
||
}
|
||
|
||
_tickRuleSatisfied() {
|
||
const b = this.battle;
|
||
const u = this.unit;
|
||
if (this.rule === 'SP_FULL') return true;
|
||
// SEARCH: an enemy inside the initial attack range (research 03 Addendum C1), every tick (librators / phalanxes
|
||
// and 安洁莉娜 do not attack while the skill is off, so DEFAULT's "about to attack" never comes)
|
||
if (this.rule === 'SEARCH') return this._defaultCondition();
|
||
if (this.rule === 'CUSTOM_RANGE') {
|
||
if (!this.triggerGrid) return this._defaultCondition();
|
||
if (!this._trigKeys) this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
|
||
return b.enemiesInKeys(this._trigKeys, u, { canHitFly: true }).length > 0;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* DEFAULT rule condition: an enemy (or injured ally for heal skills) inside the initial range (baseRangeKeys: own
|
||
* grid + permanent rangeExtend), or an enemy inside a content trigger range (addTriggerRange; not for heal skills).
|
||
*/
|
||
_defaultCondition() {
|
||
const b = this.battle;
|
||
const u = this.unit;
|
||
if (b.rangeChanged(u)) b._refreshRange(u);
|
||
const keys = u.baseRangeKeys || u.rangeKeys;
|
||
if (keys) {
|
||
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
|
||
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
|
||
}
|
||
// the enemies a melee unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
|
||
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy
|
||
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
|
||
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
|
||
}
|
||
|
||
/** Called by the attack loop right before a normal attack/heal. Returns true when the skill fired. */
|
||
onAboutToAttack() {
|
||
if (!this.ready || this.unit.s.flags.silence) return false;
|
||
if (this.active && this.isTimed) return false;
|
||
if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) return false;
|
||
if (!this._defaultCondition()) return false;
|
||
return this.activate('DEFAULT');
|
||
}
|
||
|
||
/** TAKE_DAMAGE trigger + INCREASE_WHEN_TAKEN_DAMAGE SP. */
|
||
onDamaged() {
|
||
if (this.noSkill) return;
|
||
if (this.spType === 'hurt' && !(this.active && this.isTimed)) this.gainSp(1, 'hurt');
|
||
if (this.rule === 'TAKE_DAMAGE' && this.ready && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence) {
|
||
this.activate('TAKE_DAMAGE');
|
||
}
|
||
}
|
||
|
||
/** Activate now (consumes one charge). Returns true on success. */
|
||
activate(reason = 'manual', { free = false } = {}) {
|
||
if (this.noSkill || this.kind === 'passive') return false;
|
||
if (!free && !this.ready) return false;
|
||
if (this.active && this.isTimed) return false;
|
||
const u = this.unit;
|
||
if (!u.alive || !u.deployed) return false;
|
||
if (!free) {
|
||
const wasFull = this.charges >= this.maxCharges;
|
||
this.charges--;
|
||
if (this.maxCharges === 1 || wasFull) this.sp = 0;
|
||
}
|
||
this.activations++;
|
||
this.lastStart = this.battle.time;
|
||
const b = this.battle;
|
||
if (this.isTimed) {
|
||
this.active = true;
|
||
this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity);
|
||
this.ammoLeft = this.kind === 'ammo' ? Math.max(1, this.ammo) : 0;
|
||
this._applyMods();
|
||
} else {
|
||
// instant / charges
|
||
this.active = true;
|
||
this.pending = !!this.spec.attack;
|
||
// (a targeting-only instant skill must still switch to its skill range for the pending attack)
|
||
if (this.spec.mods || this.spec.flags || this.spec.targeting) this._applyMods();
|
||
}
|
||
b._ev(['skill', u.id, 1]);
|
||
u.skillAnimUntil = b.time + 0.5;
|
||
this._call('onStart', { reason });
|
||
if (b._hooks.skillStart) b.emit('skillStart', { unit: u, skill: this, reason });
|
||
if (!this.isTimed && !this.pending) this.end('instant');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Called after each attack/heal performed by the unit. `noAmmo` (Battle.forceAttack opts, or `ctx.noAmmo = true`
|
||
* set by the spec's onAttack) = this attack spends no ammo of a running ammo skill (no `ammoUsed`).
|
||
*/
|
||
onAttackPerformed(targets, usedOverride, noAmmo = false) {
|
||
if (this.noSkill) return;
|
||
const b = this.battle;
|
||
// AK: attacks made by the skill (the "next attack" of an instant/charge skill, every attack of a running timed
|
||
// skill — including the one that ends it) never recover attack-type SP ⇒ a cost-N skill fires every N+1 attacks.
|
||
const skillAttack = this.active && (this.isTimed || (!!usedOverride && this.pending));
|
||
if (this.active && typeof this.spec.onAttack === 'function') {
|
||
const fn = this.spec.onAttack;
|
||
const ctx = this._ctx({ targets, noAmmo: !!noAmmo });
|
||
b._safe(() => fn(ctx), 'skill.onAttack', this.unit);
|
||
noAmmo = !!ctx.noAmmo;
|
||
}
|
||
if (this.active && this.kind === 'ammo' && noAmmo) {
|
||
// an extra attack that spends no bullet
|
||
} else if (this.active && this.kind === 'ammo') {
|
||
this.ammoLeft--;
|
||
if (b._hooks.ammoUsed) b.emit('ammoUsed', { unit: this.unit, left: this.ammoLeft, skill: this });
|
||
if (this.ammoLeft <= 0) this.end('ammo');
|
||
} else if (usedOverride && this.pending && !this.isTimed) {
|
||
this.pending = false;
|
||
this.end('instant');
|
||
}
|
||
if (this.spType === 'attack' && !skillAttack && !(this.active && this.isTimed)) this.gainSp(1, 'attack');
|
||
}
|
||
|
||
/**
|
||
* End the active skill. onEnd runs while the skill's mods / range are still applied (end-of-skill effects — finishers,
|
||
* bombardments — use the skill's stats and range; `active` is already false), then they are removed (kept when onEnd
|
||
* re-activated the skill), then `skillEnd` fires.
|
||
*/
|
||
end(reason = 'end') {
|
||
if (!this.active || this.kind === 'passive' && reason !== 'death') return;
|
||
const u = this.unit;
|
||
const b = this.battle;
|
||
this.active = false;
|
||
this.pending = false;
|
||
this.timeLeft = 0;
|
||
this.ammoLeft = 0;
|
||
const n = this.activations;
|
||
this._call('onEnd', { reason });
|
||
if (!this.active && this.activations === n) this._removeMods();
|
||
if (b._hooks.skillEnd) b.emit('skillEnd', { unit: u, skill: this, reason });
|
||
if (this.kind !== 'passive') b._ev(['skill', u.id, 0]);
|
||
}
|
||
|
||
/** The attack override to use for the next attack, or null. */
|
||
attackOverride() {
|
||
if (!this.active) return null;
|
||
if (this.isTimed || this.kind === 'passive') return this.spec.attack || null;
|
||
return this.pending ? (this.spec.attack || null) : null;
|
||
}
|
||
|
||
targetingOverride() {
|
||
if (!this.active || !this.spec.targeting) return null;
|
||
if (!this.isTimed && this.kind !== 'passive' && !this.pending) return null;
|
||
return this.spec.targeting;
|
||
}
|
||
|
||
// ---- helpers for content -------------------------------------------------------------------------------
|
||
// (non-finite arguments are ignored: a NaN timer/ammo count would keep the skill active forever)
|
||
addAmmo(n) { if (this.active && this.kind === 'ammo' && Number.isFinite(n)) this.ammoLeft += n; }
|
||
extend(seconds) { if (this.active && Number.isFinite(this.timeLeft) && Number.isFinite(seconds)) this.timeLeft += seconds; }
|
||
addCharge(n = 1) { if (!Number.isFinite(n)) return; this.charges = Math.max(0, Math.min(this.maxCharges, this.charges + n)); if (this.charges >= this.maxCharges) this.sp = this.spCost; }
|
||
stop() { this.end('stopped'); }
|
||
|
||
[Symbol.for('nodejs.util.inspect.custom')]() {
|
||
return `Skill<${this.id} ${this.kind} sp=${this.sp.toFixed(2)}/${this.spCost} ch=${this.charges}${this.active ? ' active' : ''}>`;
|
||
}
|
||
}
|