394 lines
23 KiB
JavaScript
394 lines
23 KiB
JavaScript
// server/sim/content/generic.js — generic kit derived from a chess's skill blackboard + skill metadata.
|
||
//
|
||
// Used for every chess without a hand-authored kit (and for tokens with skills), so every unit fights sensibly.
|
||
// Kind: PASSIVE (or a free skill without duration/ammo) ⇒ passive · durationType AMMO ⇒ ammo · duration > 0 ⇒
|
||
// duration · duration < 0 ⇒ ammo when an ammo key exists, toggle only for an explicitly endless skill
|
||
// ("持续时间无限", 史尔特尔) — the official data also uses −1 for instant/charge skills ("立即…", "下一次攻击…",
|
||
// "可充能N次": 夕, 塑心, 妮芙, 莱恩哈特, 流星 …) · otherwise instant (charges when maxCharges > 1).
|
||
// Blackboard keys → SkillSpec (stat keys prefer the plain key, attack/targeting keys prefer the `attack@` key —
|
||
// e.g. 薄绿 `attack@atk_scale` 1.1 per hit vs `atk_scale` 2.1 at skill end):
|
||
// atk→mods.atkPct · def→defPct · max_hp→hpPct (values > 5 are flat: 史尔特尔 "生命上限+5000") · attack_speed→aspd
|
||
// base_attack_time→batPct · magic_resistance→resMul (|v| < 1, "法术抗性+60%") or resFlat · damage_scale→dmgDealtMul
|
||
// block_cnt→blockCnt · taunt_level→taunt · damage_resistance→dmgTakenMul (1−v) · hp_recovery_per_sec(_by_max_hp_ratio)
|
||
// →hpRegen(Ratio) · sp_recovery_per_sec→spRecoveryFlat · magic_resist_penetrate_fixed→resIgnoreFlat ·
|
||
// def_penetrate_fixed→defIgnoreFlat · ability_range_forward_extend→targeting.rangeExtend · max_target→targeting.maxTargets
|
||
// atk_scale→attack.atkScale · heal_scale→attack.healScale · times→attack.hits · attack@range_radius→attack.splashRadius
|
||
// trigger_time / ammo→ammo.
|
||
// Negative atk/def/magic_resistance described on enemies ("攻击范围内所有敌人防御力-40%", "命中目标的防御力-30%") debuff
|
||
// the targets (aura while the skill runs, or on hit for bb.duration s) instead of the operator.
|
||
// Statuses stun/cold/sleep/fear/sluggish/root/unmovable (× prob): `attack@` keys → on hit; plain keys → on hit for
|
||
// instant/charge skills, once at skill start on the enemies in range (≤ max_target) for timed skills (小满 sleeps 3
|
||
// enemies once, not on every attack); a plain `stun` described "…结束后…晕眩" (幽灵鲨, 雷蛇) stuns the operator itself
|
||
// when the skill ends.
|
||
// Bursts: a timed skill whose text says "立即…造成…" deals its plain atk_scale once at start (乌尔比安's anchor); "技能结束时
|
||
// …造成…" deals it at the end (薄绿). Those plain scales never become the per-attack scale.
|
||
// Text rules: "停止攻击…" (duration skills; not "…之后…") ⇒ no attacks while active (泡泡, 蛇屠箱, 凯瑟琳, 铃兰, 菲莱);
|
||
// "受到攻击时…造成…攻击力/防御力N%的X伤害" ⇒ counter-damage to the attacker (or around the operator when the text says
|
||
// 周围) while the skill is active (星熊, 泡泡, 菲莱; aoe_cd = cooldown), never a per-attack scale;
|
||
// "附带…凋亡/灼燃/神经损伤" + ep_damage_ratio ⇒ element damage on hit (× damage dealt when the text says "伤害N%的…损伤",
|
||
// else × ATK); "屏障" + shield_max_hp_ratio / hp_ratio ⇒ self shield at start decaying over its duration (砾, 新约能天使);
|
||
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
|
||
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects
|
||
// above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
|
||
// force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says
|
||
// 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 往攻击方向 (the 推击手 wording, PRTS
|
||
// 推与拉 方向力) / 朝部署方向 / 向前 / 身前方向 or for 推击手 (directional), otherwise away from the unit (radial); a skill of
|
||
// constants.js PUSH_EFFECT_SKILLS (见行者 S1) pushes by PRTS 推与拉's 特效 column. These keywords are only a fallback:
|
||
// the hand-written kits follow the client's buff templates (knockback[dir] = directional, knockback[relative] = radial),
|
||
// and the text can mislead — 琳琅诗怀雅 S3's "向前推开" is knockback[relative] (kits/ops/chess_char_3_04-swire2.js). No pool skill pushes
|
||
// through this path with 向前 / 往攻击方向; the only generic push is 见行者 S1, a 推击手.
|
||
|
||
import { normalizeSkill } from '../simdata.js';
|
||
import { sortEnemyTargets } from '../targeting.js';
|
||
import { PUSH_EFFECT_SKILLS } from '../constants.js';
|
||
|
||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : undefined));
|
||
|
||
/** Plain key first, then `attack@` / `skill@` (stat modifiers). */
|
||
function getter(bb) {
|
||
return (k) => {
|
||
for (const key of [k, 'attack@' + k, 'skill@' + k]) {
|
||
const v = num(bb[key]);
|
||
if (v !== undefined) return v;
|
||
}
|
||
return undefined;
|
||
};
|
||
}
|
||
|
||
/** `attack@` key first (per-attack values), then plain / `skill@`. */
|
||
function attackGetter(bb) {
|
||
return (k) => {
|
||
for (const key of ['attack@' + k, k, 'skill@' + k]) {
|
||
const v = num(bb[key]);
|
||
if (v !== undefined) return v;
|
||
}
|
||
return undefined;
|
||
};
|
||
}
|
||
|
||
const ENDLESS = /持续时间无限|无限持续/;
|
||
const STOP_ATTACK = /(^|[,;。,;])停止(主动)?攻击(敌人)?([,;。,;]|$)/;
|
||
const ELEMENTS = [[/凋亡损伤/, 'apoptosis'], [/灼燃损伤/, 'burn'], [/神经损伤/, 'neural']];
|
||
const STATUS_KEYS = [['stun', 'stun'], ['cold', 'cold'], ['sleep', 'sleep'], ['fear', 'fear'], ['sluggish', 'sluggish'], ['root', 'bind'], ['unmovable', 'bind']];
|
||
|
||
/** Decide the skill kind from metadata. */
|
||
export function genericKind(sk, bb = {}) {
|
||
if (!sk) return null;
|
||
const g = getter(bb);
|
||
if (sk.kind) return sk.kind;
|
||
if (sk.skillType === 'PASSIVE') return 'passive';
|
||
// a skill that costs no SP and has no duration/ammo is effectively always on (e.g. 迷迭香's shield token)
|
||
if (!(sk.spCost > 0) && sk.durationType !== 'AMMO' && !(sk.duration > 0)) return 'passive';
|
||
if (sk.durationType === 'AMMO') return 'ammo';
|
||
if (sk.duration > 0) return 'duration';
|
||
if (sk.duration < 0) {
|
||
if (g('trigger_time') !== undefined || g('ammo') !== undefined) return 'ammo';
|
||
if (ENDLESS.test(String(sk.description || ''))) return 'toggle';
|
||
}
|
||
return sk.maxCharges > 1 ? 'charges' : 'instant';
|
||
}
|
||
|
||
function dmgTypeFromText(text, fallback) {
|
||
if (/法术伤害/.test(text)) return 'arts';
|
||
if (/物理伤害/.test(text)) return 'phys';
|
||
if (/真实伤害/.test(text)) return 'true';
|
||
return fallback === 'heal' || fallback === 'none' || !fallback ? 'phys' : fallback;
|
||
}
|
||
|
||
/** Enemies currently in the unit's range, best targets first (at most `n`). */
|
||
function enemiesInRange(battle, unit, n) {
|
||
const list = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true });
|
||
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
|
||
return n > 0 && list.length > n ? list.slice(0, n) : list;
|
||
}
|
||
|
||
/** Build a SkillSpec from a normalised skill def and its blackboard. `def` (optional) = normalised unit def. */
|
||
export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||
if (!sk) return null;
|
||
const g = getter(bb);
|
||
const ga = attackGetter(bb);
|
||
const kind = genericKind(sk, bb);
|
||
const desc = String(sk.description || '');
|
||
const passive = kind === 'passive';
|
||
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
|
||
// "受到攻击时…造成…" numbers belong to a counter effect (the operator's own, or an ally's: 刺玫 "该角色受到攻击时")
|
||
const counterCtx = /受到(敌人的)?攻击时/.test(desc);
|
||
const counterText = counterCtx && !/该(角色|干员|单位)受到攻击时/.test(desc);
|
||
|
||
// ---- stat mods (self) and target debuffs
|
||
const mods = {};
|
||
const debuff = {};
|
||
const set = (k, v) => { if (v !== undefined && Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
|
||
const onEnemies = (stat) => new RegExp(`(敌人|目标|敌方)[^。;]*${stat}-`).test(desc);
|
||
const pctOrFlat = (v, pctKey, flatKey, target) => { if (v === undefined || v === 0) return; if (Math.abs(v) > 5) target[flatKey] = (target[flatKey] ?? 0) + v; else target[pctKey] = (target[pctKey] ?? 0) + v; };
|
||
const atk = g('atk');
|
||
if (atk !== undefined && atk < 0 && onEnemies('攻击力')) pctOrFlat(atk, 'atkPct', 'atkFlat', debuff); else pctOrFlat(atk, 'atkPct', 'atkFlat', mods);
|
||
const defv = g('def');
|
||
if (defv !== undefined && defv < 0 && onEnemies('防御力')) pctOrFlat(defv, 'defPct', 'defFlat', debuff); else pctOrFlat(defv, 'defPct', 'defFlat', mods);
|
||
pctOrFlat(g('max_hp'), 'hpPct', 'hpFlat', mods);
|
||
set('aspd', g('attack_speed'));
|
||
const bat = g('base_attack_time');
|
||
if (bat !== undefined && bat !== 0) mods.batPct = Math.max(-0.9, bat);
|
||
const mr = g('magic_resistance');
|
||
if (mr !== undefined && mr !== 0) {
|
||
const tgt = mr < 0 && onEnemies('法术抗性') ? debuff : mods;
|
||
if (Math.abs(mr) < 1) tgt.resMul = (tgt.resMul ?? 1) * Math.max(0, 1 + mr);
|
||
else tgt.resFlat = (tgt.resFlat ?? 0) + mr;
|
||
}
|
||
const ds = g('damage_scale');
|
||
if (ds !== undefined && ds > 0) mods.dmgDealtMul = ds;
|
||
set('blockCnt', g('block_cnt'));
|
||
set('taunt', g('taunt_level'));
|
||
const dr = g('damage_resistance');
|
||
if (dr !== undefined && dr > 0 && dr < 1) mods.dmgTakenMul = 1 - dr;
|
||
// negative regen keys describe enemies ("受到的治疗和回复效果降低50%", 引星棘刺): never a self drain
|
||
const hrs = g('hp_recovery_per_sec'), hrr = g('hp_recovery_per_sec_by_max_hp_ratio');
|
||
if (hrs > 0) set('hpRegen', hrs);
|
||
if (hrr > 0) set('hpRegenRatio', hrr);
|
||
set('spRecoveryFlat', g('sp_recovery_per_sec'));
|
||
set('resIgnoreFlat', g('magic_resist_penetrate_fixed'));
|
||
set('defIgnoreFlat', g('def_penetrate_fixed'));
|
||
|
||
// ---- targeting / attack override (never for passives: their scales describe procs)
|
||
const targeting = {};
|
||
const attack = {};
|
||
const mt = ga('max_target');
|
||
if (!passive && mt !== undefined && mt > 0) targeting.maxTargets = Math.floor(mt);
|
||
const ext = g('ability_range_forward_extend');
|
||
if (!passive && ext !== undefined && ext > 0) targeting.rangeExtend = Math.round(ext);
|
||
if (!passive && sk.rangeGrid && sk.rangeGrid.length) targeting.rangeGrid = sk.rangeGrid;
|
||
|
||
const atkAt = num(bb['attack@atk_scale']);
|
||
const atkPlain = num(bb.atk_scale) ?? num(bb['skill@atk_scale']);
|
||
const startBurst = timed && atkPlain !== undefined && /立即[^。]*造成/.test(desc) ? atkPlain : undefined;
|
||
const endBurst = timed && atkPlain !== undefined && /技能结束时[^。]*造成[^。]*攻击力/.test(desc) ? atkPlain : undefined;
|
||
const counterScale = counterText && atkPlain !== undefined && atkAt === undefined ? atkPlain : undefined;
|
||
// "额外造成攻击力N%的…伤害" is bonus damage on top of the attack (烛煌), not the attack's own scale
|
||
const isExtra = (v) => v !== undefined && [...desc.matchAll(/额外(造成|附带)[^。;]*?攻击力(\d+(\.\d+)?)%/g)].some((m) => Math.abs(+m[2] - v * 100) < 1e-6);
|
||
// a non-healer whose skill "恢复…一名(其他)友方…生命" (古米, 瑕光): its hits heal the most injured ally nearby
|
||
const healerUnit = !!def && (def.dmgType === 'heal' || def.profession === 'MEDIC');
|
||
const allyHealScale = ga('heal_scale');
|
||
const allyHeal = !passive && !!def && !healerUnit && allyHealScale > 0 && /(恢复|治疗)[^。]*友(方|军)[^。]*生命/.test(desc);
|
||
const allyHealOthersOnly = /其他友(方|军)/.test(desc);
|
||
const allyHealAll = /所有友(方|军)/.test(desc); // 塞雷娅: heals every injured ally in the skill range
|
||
if (!passive) {
|
||
let as = isExtra(atkAt) ? undefined : atkAt;
|
||
if (as === undefined && atkAt === undefined && atkPlain !== undefined && !counterCtx && !isExtra(atkPlain) && startBurst === undefined && endBurst === undefined) as = atkPlain;
|
||
if (as !== undefined && as > 0) attack.atkScale = as;
|
||
const hs = ga('heal_scale');
|
||
if (hs !== undefined && hs > 0 && !allyHeal) attack.healScale = hs;
|
||
const times = ga('times');
|
||
if (times !== undefined && times > 1) attack.hits = Math.min(10, Math.floor(times));
|
||
const rr = num(bb['attack@range_radius']);
|
||
if (rr !== undefined && rr > 0 && rr < 5) attack.splashRadius = rr;
|
||
if (kind === 'duration' && STOP_ATTACK.test(desc) && !/之后/.test(desc)) attack.noAttack = true;
|
||
}
|
||
|
||
// ---- statuses
|
||
const hitStatuses = [];
|
||
const startStatuses = [];
|
||
let selfStunOnEnd = 0;
|
||
if (!passive) {
|
||
for (const [key, status] of STATUS_KEYS) {
|
||
const onAtk = num(bb['attack@' + key]);
|
||
const plain = num(bb[key]) ?? num(bb['skill@' + key]);
|
||
if (onAtk !== undefined && onAtk > 0) hitStatuses.push({ key: status, duration: onAtk });
|
||
if (plain !== undefined && plain > 0 && !(onAtk > 0 && plain === onAtk)) {
|
||
if (key === 'stun' && /结束后[^,。;]*晕眩/.test(desc)) selfStunOnEnd = plain;
|
||
else if (timed) startStatuses.push({ key: status, duration: plain });
|
||
else if (!(onAtk > 0)) hitStatuses.push({ key: status, duration: plain });
|
||
}
|
||
}
|
||
}
|
||
const prob = g('prob');
|
||
const rollProb = (battle) => !(prob !== undefined && prob > 0 && prob < 1) || battle.rng.chance(prob);
|
||
|
||
// ---- element damage attached to hits / counters
|
||
let element = null;
|
||
const epr = g('ep_damage_ratio');
|
||
if (epr !== undefined && epr > 0 && /附带/.test(desc) && !/其他友方/.test(desc)) {
|
||
const el = ELEMENTS.find(([re]) => re.test(desc));
|
||
if (el) element = { el: el[1], ratio: epr, ofDamage: /伤害\d+(\.\d+)?%的[^。,;]{0,3}损伤/.test(desc) };
|
||
}
|
||
const applyElement = (battle, unit, target, dealt) => {
|
||
if (!element || !target || !target.alive) return;
|
||
const amount = element.ofDamage ? (dealt || 0) * element.ratio : unit.s.atk * element.ratio;
|
||
if (amount > 0) battle.dealDamage(unit, target, { type: 'element', element: element.el, amount, tags: ['skill'] });
|
||
};
|
||
|
||
// ---- displacement
|
||
const forceRaw = g('force');
|
||
const hasForce = !passive && forceRaw !== undefined;
|
||
const force = forceRaw ?? 0; // 力度 (微小力 −1 … 特大力 5)
|
||
const pull = /拖拽/.test(desc) || (!/推开|击退/.test(desc) && def && def.subProf === 'hookmaster');
|
||
const directional = !pull && (/攻击方向|朝部署方向|向前|身前方向/.test(desc) || (def && def.subProf === 'pusher'));
|
||
const effectPush = PUSH_EFFECT_SKILLS.has(sk.id);
|
||
const hitElement = element && !counterText;
|
||
|
||
const healAlly = (battle, unit) => {
|
||
if (allyHealAll) {
|
||
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) battle.heal(unit, a, unit.s.atk * allyHealScale);
|
||
return;
|
||
}
|
||
let best = null;
|
||
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) { best = a; break; }
|
||
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
|
||
if (best) battle.heal(unit, best, unit.s.atk * allyHealScale);
|
||
};
|
||
const onHit = hitStatuses.length || hasForce || hitElement || allyHeal
|
||
? (ctx) => {
|
||
if (allyHeal) healAlly(ctx.battle, ctx.unit);
|
||
if (!ctx.target || !ctx.target.alive) return;
|
||
if (hitElement) applyElement(ctx.battle, ctx.unit, ctx.target, ctx.dealt);
|
||
if (hitStatuses.length && rollProb(ctx.battle)) {
|
||
for (const s of hitStatuses) ctx.battle.applyStatus(ctx.target, s.key, { duration: s.duration, source: ctx.unit });
|
||
}
|
||
if (hasForce && ctx.target.alive && ctx.target.side === 'enemy') {
|
||
const u = ctx.unit;
|
||
if (pull) ctx.battle.pullToFront(ctx.target, u, force);
|
||
else ctx.battle.push(ctx.target, force, { from: u, dir: directional && u.fwd ? { x: u.fwd[1], y: u.fwd[0] } : null, effect: effectPush });
|
||
}
|
||
}
|
||
: null;
|
||
|
||
// target debuffs: aura over the range while a timed skill runs, else on hit for bb.duration (default 5 s)
|
||
const hasDebuff = Object.keys(debuff).length > 0 && !passive;
|
||
const debuffKey = `generic:debuff:${sk.id ?? 'skill'}`;
|
||
const aura = hasDebuff && timed && /攻击范围内[^。;]*敌人/.test(desc);
|
||
const debuffDur = timed ? 5 : (num(bb.duration) > 0 ? num(bb.duration) : 5);
|
||
let onHitFinal = onHit;
|
||
if (hasDebuff && !aura) {
|
||
const base = onHit;
|
||
onHitFinal = (ctx) => {
|
||
if (base) base(ctx);
|
||
if (ctx.target && ctx.target.alive) ctx.battle.addBuff(ctx.target, { key: debuffKey, duration: debuffDur, mods: { ...debuff }, refresh: 'extend', source: ctx.unit });
|
||
};
|
||
}
|
||
if (onHitFinal) attack.onHit = onHitFinal;
|
||
|
||
let ammo;
|
||
if (kind === 'ammo') ammo = Math.max(1, Math.floor(g('trigger_time') ?? g('ammo') ?? g('cnt') ?? 8));
|
||
|
||
const spec = {
|
||
id: sk.id,
|
||
name: sk.name,
|
||
kind,
|
||
duration: kind === 'duration' ? sk.duration : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
|
||
ammo,
|
||
};
|
||
|
||
// passive stat buffs limited in time ("部署后…在14秒内攻击力+65%", 宴) become a timed buff at each deployment
|
||
const passiveTimed = passive && Object.keys(mods).length && num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : 0;
|
||
if (Object.keys(mods).length && !passiveTimed) spec.mods = mods;
|
||
if (Object.keys(targeting).length) spec.targeting = targeting;
|
||
// instant/charges skills act on the next attack: mods/targeting without an explicit attack still need one
|
||
if (!Object.keys(attack).length && (kind === 'instant' || kind === 'charges') && (spec.mods || spec.targeting)) spec.attack = {};
|
||
if (Object.keys(attack).length) spec.attack = attack;
|
||
|
||
// ---- start / end effects
|
||
const starts = [];
|
||
const ends = [];
|
||
if (passiveTimed) {
|
||
const m = { ...mods };
|
||
starts.push(({ battle, unit }) => battle.addBuff(unit, { key: `generic:passive:${sk.id ?? 'skill'}`, duration: passiveTimed, mods: m, tags: ['skill'] }));
|
||
}
|
||
const hr = g('hp_ratio');
|
||
if (hr !== undefined && hr > 0 && hr <= 1 && /立即流失\d+(\.\d+)?%(的)?当前生命/.test(desc)) {
|
||
starts.push(({ battle, unit }) => { const loss = unit.hp * hr; if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); });
|
||
} else if (hr !== undefined && hr > 0 && hr <= 1 && /(恢复|回复)[^。,]*生命/.test(desc) && !/流失/.test(desc)) {
|
||
starts.push(({ battle, unit }) => battle.heal(unit, unit, unit.s.maxHp * hr, { self: true }));
|
||
}
|
||
const shieldRatio = /屏障/.test(desc) ? (g('shield_max_hp_ratio') ?? (/生命(上限|值)?\d+(\.\d+)?%的屏障/.test(desc) ? hr : undefined)) : undefined;
|
||
if (shieldRatio !== undefined && shieldRatio > 0) {
|
||
const dur = g('shield_max_duration') ?? num(bb.duration) ?? (sk.duration > 0 ? sk.duration : 0);
|
||
const decays = /衰减/.test(desc) && dur > 0;
|
||
starts.push(({ battle, unit }) => {
|
||
const total = unit.s.maxHp * shieldRatio;
|
||
battle.addBuff(unit, {
|
||
key: `generic:shield:${sk.id ?? 'skill'}`, shield: total, duration: dur > 0 ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0,
|
||
onTick: decays ? ({ unit: u, buff }) => { buff.shield = Math.max(0, buff.shield - total * 0.5 / dur); u.markDirty(); } : null,
|
||
});
|
||
});
|
||
}
|
||
const burst = (scale) => ({ battle, unit }) => {
|
||
const type = dmgTypeFromText(desc, unit.profile?.dmgType ?? 'phys');
|
||
for (const e of enemiesInRange(battle, unit, 0)) {
|
||
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type, isSkill: true, tags: ['skill', 'burst'] });
|
||
applyElement(battle, unit, e, dealt);
|
||
}
|
||
};
|
||
if (startBurst !== undefined && startBurst > 0) starts.push(burst(startBurst));
|
||
if (startStatuses.length) {
|
||
const n = mt !== undefined && mt > 0 ? Math.floor(mt) : 0;
|
||
starts.push(({ battle, unit }) => {
|
||
for (const e of enemiesInRange(battle, unit, n)) for (const s of startStatuses) battle.applyStatus(e, s.key, { duration: s.duration, source: unit });
|
||
});
|
||
}
|
||
if (endBurst !== undefined && endBurst > 0) {
|
||
const b = burst(endBurst);
|
||
ends.push((ctx) => { if (ctx.reason !== 'death' && ctx.unit.alive) b(ctx); });
|
||
}
|
||
if (selfStunOnEnd > 0) {
|
||
ends.push((ctx) => { if (ctx.reason !== 'death' && ctx.unit.alive) ctx.battle.applyStatus(ctx.unit, 'stun', { duration: selfStunOnEnd, source: ctx.unit }); });
|
||
}
|
||
if (starts.length) spec.onStart = (ctx) => { for (const f of starts) f(ctx); };
|
||
if (ends.length) spec.onEnd = (ctx) => { for (const f of ends) f(ctx); };
|
||
if (aura) {
|
||
const m = { ...debuff };
|
||
spec.onTick = ({ battle, unit }) => {
|
||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.addBuff(e, { key: debuffKey, duration: 0.5, mods: m, refresh: 'extend', source: unit });
|
||
};
|
||
}
|
||
|
||
// ---- counter-damage while the skill is active ("受到攻击时对目标造成…攻击力/防御力N%的X伤害")
|
||
if (counterText && (counterScale !== undefined || element)) {
|
||
const m = desc.match(/受到(敌人的)?攻击时[^。]*?(攻击力|防御力)(\d+(\.\d+)?)%的(物理|法术|真实)伤害/);
|
||
spec.counter = {
|
||
scale: counterScale ?? 0,
|
||
stat: m && m[2] === '防御力' ? 'def' : 'atk',
|
||
type: m ? dmgTypeFromText(m[5] + '伤害', 'phys') : 'phys',
|
||
around: /受到(敌人的)?攻击时对周围/.test(desc),
|
||
groundOnly: /周围的地面敌人/.test(desc),
|
||
cooldown: num(bb.aoe_cd) ?? 0,
|
||
applyElement: element ? applyElement : null,
|
||
};
|
||
}
|
||
return spec;
|
||
}
|
||
|
||
/** Per-unit hooks for the generic spec (counter-damage). */
|
||
function installGeneric(spec) {
|
||
const c = spec && spec.counter;
|
||
if (!c) return null;
|
||
return (battle, unit) => {
|
||
let readyAt = -Infinity;
|
||
battle.on('damaged', (ctx) => {
|
||
if (ctx.target !== unit || !unit.alive || !unit.skill?.active || battle.time < readyAt) return;
|
||
const src = ctx.source;
|
||
if (!src || src.side !== 'enemy' || !ctx.dmg || !ctx.dmg.isAttack) return;
|
||
readyAt = battle.time + c.cooldown;
|
||
const amount = (c.stat === 'def' ? unit.s.def : unit.s.atk) * c.scale;
|
||
const victims = c.around ? battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []);
|
||
for (const e of victims) {
|
||
const dealt = amount > 0 ? battle.dealDamage(unit, e, { amount, type: c.type, canDodge: false, isSkill: true, tags: ['counter'] }) : 0;
|
||
if (c.applyElement) c.applyElement(battle, unit, e, dealt);
|
||
}
|
||
}, { owner: unit });
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Generic kit: `(bb, chess, def?) => Kit`. `chess` is the data record; `def` the normalised def when available.
|
||
*/
|
||
export function genericKit(bb, chess, def = null) {
|
||
const sk = def?.skill ?? normalizeSkill(chess);
|
||
if (!sk) return { skill: null, talents: [], generic: true };
|
||
const spec = genericSkillSpec(sk, bb && Object.keys(bb).length ? bb : sk.bb, def);
|
||
const kit = { skill: spec, talents: [], generic: true };
|
||
const inst = installGeneric(spec);
|
||
if (inst) kit.install = inst;
|
||
return kit;
|
||
}
|
||
|
||
export default genericKit;
|