Player reports after 0.1.0 (second batch), both confirmed on the real match path: - B3 维娜·维多利亚 S3 俱以我之名 placed one 黄金盟誓. Official text "立即在天赋一生效范围内 可部署地面召唤“黄金盟誓”", EN client "Summons Golden Vows on deployable tiles within Talent 1's range": one on every free deployable melee tile of her talent-1 area (fences included). The token's maxDeployCount 1 is a hand count: tokens.js SKILL_SUMMON_UNCAPPED keeps the per-owner deploy limit from withdrawing them; the generic fallback places 8. - B4 玛恩纳 S3 未照耀的荣光 (CUSTOM_RANGE, counts flyers) fired on flying waves and hit nothing for 26 s: PRTS 备注 "※可对空" — the skill targeting now hits air units. Same note: the trait bonus drops by 10 % (absolute) per enemy knocked out by his own attack or the damage it carries, settled after the attack, floor +0 % (was −10 % of the x2 multiplier per kill, applied mid-attack, floored at the plain ramp, reflect kills counted). S2's "trait not reset" kill uses the same own-attack rule. Tests: test/content/feedback1b_kits.test.js (real Match → PlayerState → Match._normalOpts spec on 战场#05 with the R8 / flying R2 waves), kits_t6 adapted.
2536 lines
152 KiB
JavaScript
2536 lines
152 KiB
JavaScript
// server/sim/content/kits/tier5.js — hand-authored kits for every tier-5 chess (DESIGN §7, docs/SIM.md §7.2).
|
||
//
|
||
// export default { [baseChessId]: (bb, chess, def) => Kit }. `bb` = skill blackboard at the chess's level (normal Lv4 /
|
||
// elite Lv7), `chess` = data/chess.json record (talents with their own bb — elite records already carry the module talent
|
||
// upgrades — trait.bb incl. the module trait upgrade, stats, tokens), `def` = normalised def (skill.rangeGrid, …).
|
||
// Every number comes from those blackboards; the few constants below exist only in the skill TEXT (no blackboard key).
|
||
//
|
||
// Conventions shared by the kits of this file:
|
||
// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s,
|
||
// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT.
|
||
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge,
|
||
// × elementalTakenMul = 元素脆弱), with `element` set for the client colour.
|
||
// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload
|
||
// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun
|
||
// radius 1.7; 寒檀 icicles splash 1.5 and cycle left row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit
|
||
// at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain
|
||
// sleep picks the highest-aggro enemy within 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile;
|
||
// 引星棘刺 throws at the farthest forward tile when no enemy is in range; 夕 T2 summons only on a deployable target tile.
|
||
// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field.
|
||
// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins.
|
||
// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an
|
||
// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her
|
||
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发)
|
||
// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP:
|
||
// the heal replaces that attack). Every other
|
||
// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an
|
||
// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for
|
||
// every MANUAL skill; AUTO skills never take a class row).
|
||
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
|
||
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
|
||
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit',
|
||
// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'.
|
||
|
||
import { COLS } from '../../constants.js';
|
||
import { bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
|
||
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
|
||
import { frontOf, rotateOffset, toLocal } from '../../dir.js';
|
||
import { mitigate, hasHp } from '../../damage.js';
|
||
|
||
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
|
||
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
|
||
const HALF_HP = 0.5;
|
||
/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */
|
||
const DUSK_SPLASH_RADIUS = 1.7;
|
||
/** 隐德来希 S3 "每次攻击对心烛至少造成35%攻击力的伤害". */
|
||
const CANDLE_MIN_ATK = 0.35;
|
||
/** 隐德来希 S3 心烛 enemy key (hidden talent bbStr take_extra_enemy_key; not in data/enemies.json). */
|
||
const CANDLE_KEY = 'enemy_5601_entlec';
|
||
/** 烛煌 绝处重燃 "使附近的敌人晕眩" radius (tiles). PRTS 备注: 1.7. */
|
||
const NEARBY_RADIUS = 1.7;
|
||
/** 玛恩纳 游侠 "周围存在3名及以上敌人" radius (8-neighbourhood). */
|
||
const AROUND_RADIUS = 1.5;
|
||
/** 安洁莉娜 S3 失重: weight (massLevel) reduction while weightless. PRTS: "重量下降一个等级". */
|
||
const WEIGHTLESS_MASS = 1;
|
||
/** 寒檀 S2 icicle splash radius (PRTS 备注 "冰凌溅射半径1.5"). */
|
||
const ICICLE_RADIUS = 1.5;
|
||
/** 寒檀 S2 icicle fall time (s) between the attack and the impact. [ASSUMED] */
|
||
const ICICLE_DELAY = 0.3;
|
||
/** 寒檀 S2 icicle lines, lateral offsets in her facing-RIGHT frame: ① the line on her left, ② on her right, ③ her own, then ①. */
|
||
const ICICLE_ROWS = [1, -1, 0];
|
||
/** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */
|
||
const MOTE_HIT_RADIUS = 0.4;
|
||
/**
|
||
* Abyssal Hunters (【深海猎人】 = character_table groupId `abyssal`; data/chess.json has no groupId, the charIds come from
|
||
* docs/research/03-operators.json): 幽灵鲨, 斯卡蒂, 歌蕾蒂娅, 乌尔比安, 归溟幽灵鲨. 浊心斯卡蒂 (char_1012_skadi2) has groupId
|
||
* null in the official data — she is NOT an Abyssal Hunter for these talents.
|
||
*/
|
||
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
|
||
/** Never auto-fires (CUSTOM_RANGE with an empty trigger grid): the kit activates the skill itself. */
|
||
const NEVER = Object.freeze({ rule: 'CUSTOM_RANGE', grid: Object.freeze([]) });
|
||
const AURA_IV = 0.25;
|
||
const AURA_DUR = 0.5;
|
||
// alternate skills (loadouts) — text-only numbers
|
||
/** "周围" (8-neighbourhood) as a radius: 隐德来希 S2 血镰, 缇缇 S2 sleep ward, 乌尔比安 S1 anchor, 引星棘刺 S3 area. */
|
||
const RING1 = 1.5;
|
||
/** 夕 S3 "攻击范围与溅射范围扩大" — the expanded splash radius, the same 1.7 as S1 (PRTS). [ASSUMED for S3] */
|
||
const DUSK_S3_SPLASH = DUSK_SPLASH_RADIUS;
|
||
/** 号角 S2 过载: the second half of the ammo (PRTS 备注 of S3 "技能进行到一半时"; S2 counts shots). [ASSUMED] */
|
||
const HORN_S2_OVERLOAD_AT = 0.5;
|
||
|
||
// ---- helpers ------------------------------------------------------------------------------------------------------
|
||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||
const on = (u) => !!u && u.alive && u.deployed && !u.removed;
|
||
/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */
|
||
const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet);
|
||
const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {};
|
||
const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null;
|
||
const traitBb = (chess) => chess?.trait?.bb ?? {};
|
||
const skillGrid = (chess, def) => def?.skill?.rangeGrid ?? chess?.skill?.rangeGrid ?? null;
|
||
const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill?.maxChargeTime, 1));
|
||
/** Blackboard `base_attack_time` (seconds) → batPct of this chess's base attack time. */
|
||
const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; };
|
||
/** Drop zero / non-finite entries (a zero mod is noise in the buff list). */
|
||
const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; };
|
||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||
const nationOf = (u) => u?.def?.raw?.nationId ?? null;
|
||
const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u)));
|
||
const isLaterano = (u) => inFaction(u, 'lateranoShip', ['laterano']);
|
||
const isKazimierz = (u) => inFaction(u, 'kazimierzShip', ['kazimierz']);
|
||
const isKjerag = (u) => inFaction(u, 'kjeragShip', ['kjerag']);
|
||
const isSargonMinos = (u) => inFaction(u, 'sargonShip', ['sargon', 'minos']);
|
||
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId);
|
||
const isOp = (u) => u && u.kind === 'op';
|
||
|
||
// ---- operator loadouts (DESIGN §16) -------------------------------------------------------------------------------
|
||
// A kit function is resolved per SELECTED skill: `bb` / `chess.skill` / `def.skill` are the selected skill's. The default
|
||
// skill keeps its `skill` spec; every other selectable skill is a `skills[skillId]` entry. Kit `install` / talents run
|
||
// under every skill, so default-skill machinery in them is guarded by the selected skill id (`sid`).
|
||
/** Id of the selected skill of a chess record / def. */
|
||
const selectedId = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null;
|
||
/** `skills` map whose entries are built only when read (each spec is a function of the SELECTED skill's bb). */
|
||
function lazySkills(builders) {
|
||
const o = {};
|
||
for (const [id, build] of Object.entries(builders)) Object.defineProperty(o, id, { enumerable: true, get: build });
|
||
return o;
|
||
}
|
||
/** Spec kind of an instant skill (charges when the selected skill can charge). */
|
||
const instantKind = (chess, def) => (maxCharges(chess, def) > 1 ? 'charges' : 'instant');
|
||
/** Skill-range targeting override of the selected skill (undefined without a skill range). */
|
||
const skillRange = (chess, def, extra = null) => {
|
||
const g = skillGrid(chess, def);
|
||
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
|
||
};
|
||
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
|
||
function crowdAspd(battle, unit, key, aspd, cnt) {
|
||
if (!aspd || !(cnt > 0)) return;
|
||
whileOn(battle, unit, 0.2, () => {
|
||
if (battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt) battle.addBuff(unit, { key, duration: 0.3, mods: { aspd } });
|
||
});
|
||
}
|
||
/** "攻击范围内存在元素损伤爆发的敌人时,技力自然恢复速度+X/秒" (modules PRI-Y). */
|
||
function burstSpUp(battle, unit, key, sp) {
|
||
if (!(sp > 0)) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).some((e) => e.s.flags.burstLock)) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
|
||
});
|
||
}
|
||
/** "攻击范围内存在敌人时,技力自然恢复速度+X/秒" (modules DEC-X). */
|
||
function enemySpUp(battle, unit, key, sp) {
|
||
if (!(sp > 0)) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
|
||
});
|
||
}
|
||
|
||
/** First living deployed ally with the same operator (charId) — used to keep same-name talents from stacking. */
|
||
function leaderOf(battle, unit) {
|
||
const cid = unit.def?.charId;
|
||
for (const a of battle.allyUnits) if (on(a) && a.def?.charId === cid) return a;
|
||
return null;
|
||
}
|
||
|
||
/** Enemy `e` attacks `t` in melee (not a ranged shot), mirroring ai.js enemyAttack. */
|
||
function meleeAttacker(e, t) {
|
||
const melee = e.profile?.melee ?? e.def?.applyWay === 'MELEE';
|
||
const r = num(e.base?.rangeRadius, 0);
|
||
return melee || !(r > 0) || (e.blockedBy === t && r < 1);
|
||
}
|
||
|
||
/** Enemy not moving along its route (blocked, stunned, rooted, waiting…). */
|
||
const isStill = (e) => !!(e.blockedBy || !e.moving || e.s.flags.stun || e.s.flags.noMove || !(e.s.moveSpeed > 0));
|
||
|
||
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */
|
||
function elementHit(battle, src, tgt, amount, tag, element = null) {
|
||
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
|
||
return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] });
|
||
}
|
||
|
||
/**
|
||
* Activate the unit's skill whenever it is ready and `cond()` holds (NEVER-trigger skills). Consecutive casts of a
|
||
* multi-charge skill are spaced by at least one attack interval (like the attack-driven DEFAULT rule).
|
||
*/
|
||
function autoCast(battle, unit, cond) {
|
||
battle.on('tick', () => {
|
||
const sk = unit.skill;
|
||
if (!sk || sk.noSkill || !on(unit) || !unit.canAct || !sk.ready || unit.s.flags.silence) return;
|
||
if (sk.active && sk.isTimed) return;
|
||
if (battle.time - sk.lastStart < unit.s.interval - 1e-9) return;
|
||
if (cond()) sk.activate('DEFAULT');
|
||
}, { owner: unit });
|
||
}
|
||
|
||
/** Periodic check while the unit is on the field. */
|
||
function whileOn(battle, unit, sec, fn) {
|
||
battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit });
|
||
}
|
||
|
||
/** "同类效果取最高" SP-recovery aura: refresh `aura:spRecovery` on matching allies unless a higher one is present. */
|
||
function spAura(battle, unit, value, filter) {
|
||
if (!(value > 0)) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
for (const a of battle.alliesFor(unit)) {
|
||
if (!filter(a)) continue;
|
||
const cur = a.findBuff('aura:spRecovery');
|
||
if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue;
|
||
battle.addBuff(a, { key: 'aura:spRecovery', duration: AURA_DUR, mods: { spRecoveryFlat: value } });
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Module PRI-X (本源术师): damage vs enemies in an element burst ×damage_scale. */
|
||
function burstDamageUp(battle, unit, mul) {
|
||
if (!(mul > 1)) return;
|
||
battle.on('hit', (c) => {
|
||
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return;
|
||
if (c.target.s.flags.burstLock) c.dmg.mul *= mul;
|
||
}, { owner: unit });
|
||
}
|
||
|
||
/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */
|
||
function lowHpHealUp(battle, unit, tb) {
|
||
const mul = num(tb.heal_scale), thr = num(tb.hp_ratio);
|
||
if (!(mul > 1) || !(thr > 0)) return;
|
||
battle.on('heal', (c) => {
|
||
if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return;
|
||
if (c.target.hpRatio < thr) c.amount *= mul;
|
||
}, { owner: unit });
|
||
}
|
||
|
||
/**
|
||
* Modules "攻击范围扩大" (夕 SPC-X, 白面鸮 RIN-X): the range becomes the SELECTED module's own grid — the data's
|
||
* range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like tier4's
|
||
* 莫斯提马 / 莱恩哈特 / 白面鸮 (integration review: a flat forward +1 added a whole column, 3 tiles, and widened the skill
|
||
* ranges too). It replaces the unit's own range, so the DEFAULT trigger's initial range follows; skills with their own
|
||
* range keep it. Without such a grid in the data: one extra forward tile [ASSUMED].
|
||
*/
|
||
function moduleRangeUp(battle, unit, chess) {
|
||
if (!chess?.isGolden || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return;
|
||
const m = chess.module;
|
||
const rec = m && m.active && m.id ? (chess.modules || []).find((x) => x && x.uniEquipId === m.id) : null;
|
||
const g = (rec?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
|
||
if (g) {
|
||
unit.rangeGrid = g.map((p) => [p[0], p[1]]);
|
||
battle.refreshRange(unit);
|
||
return;
|
||
}
|
||
// a permanent (persist, never-expiring) rangeExtend: the engine counts it in the initial range of the DEFAULT skill
|
||
// trigger ("敌人进入初始攻击范围") as well
|
||
battle.addBuff(unit, { key: 't5:moduleRange', mods: { rangeExtend: 1 }, persist: true, allowDead: true });
|
||
}
|
||
|
||
/** Permanent (talent) stat buff that survives death/redeploy. */
|
||
function permBuff(battle, unit, key, m) {
|
||
const mm = mods(m);
|
||
if (Object.keys(mm).length) battle.addBuff(unit, { key, mods: mm, persist: true, allowDead: true });
|
||
}
|
||
|
||
/** Enemies inside an absolute-key grid of `grid` offsets around `unit` (best targets first). */
|
||
function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}) {
|
||
const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0);
|
||
let list;
|
||
if (ignoreStealth) {
|
||
const set = new Set(keys);
|
||
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set));
|
||
} else list = battle.enemiesInKeys(keys, unit, { canHitFly: true });
|
||
sortEnemyTargets(battle, unit, list, null);
|
||
return n > 0 && list.length > n ? list.slice(0, n) : list;
|
||
}
|
||
|
||
/** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */
|
||
const ROAD_CACHE = new WeakMap();
|
||
function straightRun(battle, r, c) {
|
||
let m = ROAD_CACHE.get(battle);
|
||
if (!m) {
|
||
m = new Map();
|
||
const g = battle.grid, R = battle.rect;
|
||
const pass = (rr, cc) => g.groundPassable(rr, cc, true);
|
||
for (let rr = R.r0; rr <= R.r1; rr++) {
|
||
for (let cc = R.c0; cc <= R.c1; cc++) {
|
||
if (!pass(rr, cc)) continue;
|
||
let h = 1, v = 1;
|
||
for (let k = cc - 1; k >= R.c0 && pass(rr, k); k--) h++;
|
||
for (let k = cc + 1; k <= R.c1 && pass(rr, k); k++) h++;
|
||
for (let k = rr - 1; k >= R.r0 && pass(k, cc); k--) v++;
|
||
for (let k = rr + 1; k <= R.r1 && pass(k, cc); k++) v++;
|
||
m.set(rr * COLS + cc, Math.max(h, v));
|
||
}
|
||
}
|
||
ROAD_CACHE.set(battle, m);
|
||
}
|
||
return m.get(r * COLS + c) ?? 0;
|
||
}
|
||
|
||
// ==================================================================================================================
|
||
// kits
|
||
|
||
const KITS = {
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 圣约送葬人 — S2 近身铳斗 (ammo, attack SP): ATK/DEF +, block +1, melee hits dodged with prob (+ammo refill).
|
||
// T1 受选之人: extra attack chance (+prob_add per ammo spent in the skill). T2 铳弹共感: +ammo per Laterano op.
|
||
// S1 遗嘱执行 (ammo 8, attack SP): skill range, ATK +, ignores def_penetrate_fixed DEF.
|
||
// S3 圣约决裁 (ammo 16): skill range, BAT +0.5 s, ATK +; +attack@atk ATK per bullet spent (≤ attack@max_stack_cnt),
|
||
// reaper self-heal ×trait_ratio; at the end every enemy attacked during the skill takes attack@final_atk_scale × ATK phys.
|
||
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
|
||
chess_char_5_01_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
|
||
const sid = selectedId(chess, def);
|
||
const prob = num(bb.prob), refill = num(bb.recover_cnt, 1);
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_excu2_1: () => ({
|
||
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 8),
|
||
mods: mods({ atkPct: num(bb.atk), defIgnoreFlat: num(bb.def_penetrate_fixed) }),
|
||
targeting: skillRange(chess, def),
|
||
}),
|
||
skchr_excu2_3: () => ({
|
||
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 16),
|
||
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillRange(chess, def),
|
||
attack: { onEachHit({ unit, target }) { if (target && target.side === 'enemy') unit.mem.verdict?.add(target); } },
|
||
onStart({ unit }) {
|
||
unit.mem.verdict = new Set();
|
||
// 特性的回复生命效果提高至2倍 (the reaper profile reads profile.selfHeal on every attack)
|
||
if (unit.profile) { unit.mem.verdictHeal0 ??= num(unit.profile.selfHeal, 50); unit.profile.selfHeal = unit.mem.verdictHeal0 * num(bb.trait_ratio, 1); }
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const hit = unit.mem.verdict;
|
||
unit.mem.verdict = null;
|
||
if (unit.profile && unit.mem.verdictHeal0 != null) unit.profile.selfHeal = unit.mem.verdictHeal0;
|
||
if (hit && unit.alive) {
|
||
for (const e of hit) {
|
||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@final_atk_scale']), type: 'phys', isSkill: true, tags: ['skill', 'verdict'] });
|
||
}
|
||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1.5, skill: 'excu2' });
|
||
}
|
||
battle.removeBuff(unit, 'excu2:verdict');
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 12),
|
||
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }),
|
||
},
|
||
// REA-Y: its trait part is DISPLAY-only (official battle_equip_table uniequip_003_excu2: target DISPLAY, the ASPD
|
||
// text "{value}" = 12; the ASPD rider itself is the talent −1 part). data/chess.json merges that display blackboard
|
||
// into trait.bb ({value: 12}), which the reaper profile would read as its per-hit heal: the heal stays the base
|
||
// trait's (50).
|
||
trait: tm['trigger_cnt[equip]'] != null && chess?.traitBase?.bb?.value != null ? { selfHeal: num(chess.traitBase.bb.value, 50) } : undefined,
|
||
talents: [
|
||
{ install(battle, unit) { // 受选之人
|
||
let spent = 0;
|
||
const reset = (c) => { if (c.unit === unit) spent = 0; };
|
||
battle.on('skillStart', reset, { owner: unit });
|
||
battle.on('skillEnd', reset, { owner: unit });
|
||
battle.on('ammoUsed', (c) => { if (c.unit === unit) spent++; }, { owner: unit });
|
||
// PRTS 备注: "触发时可额外回复1点攻击回复技力,不额外消耗弹药" — the extra attack is an attack (attack SP, trait
|
||
// heal) but consumes no ammo (engine forceAttack `noAmmo`: no bullet, no ammoUsed for any listener — the
|
||
// 7-bullet 特质 counter, items, bonds and the prob_add counter above)
|
||
battle.on('attack', (c) => {
|
||
if (c.attacker !== unit || unit.mem.extraAttack || !on(unit)) return;
|
||
const p = num(t0.prob) + (unit.skill?.active ? num(t0.prob_add) * spent : 0);
|
||
if (!(p > 0) || !battle.rng.chance(Math.min(1, p))) return;
|
||
const dep = unit.deploySeq;
|
||
battle.after(0, () => {
|
||
if (!on(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return;
|
||
unit.mem.extraAttack = true;
|
||
let hit = false;
|
||
try { hit = battle.forceAttack(unit, null, { noAmmo: true }); } finally { unit.mem.extraAttack = false; }
|
||
if (hit) battle.fx('extraAttack', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 铳弹共感
|
||
battle.on('skillStart', (c) => {
|
||
if (c.unit !== unit || c.skill.kind !== 'ammo') return;
|
||
const n = battle.allies().filter((a) => isOp(a) && isLaterano(a)).length;
|
||
const stacks = Math.min(num(t1.add_count_max_stack, 4), n);
|
||
if (stacks > 0) c.skill.addAmmo(stacks * num(t1.add_count, 1));
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
crowdAspd(battle, unit, 'excu2:module', num(tm.attack_speed), num(tm['trigger_cnt[equip]']));
|
||
if (sid === 'skchr_excu2_3') { // S3: +attack@atk ATK per bullet spent
|
||
battle.on('ammoUsed', (c) => {
|
||
if (c.unit !== unit || !unit.skill?.active) return;
|
||
battle.addBuff(unit, { key: 'excu2:verdict', refresh: 'stack', maxStacks: Math.max(1, num(bb['attack@max_stack_cnt'], 30)), mods: mods({ atkPct: num(bb['attack@atk']) }) });
|
||
}, { owner: unit });
|
||
}
|
||
if (sid && sid !== 'skchr_excu2_2') return;
|
||
// S2: melee attacks dodged with `prob`, each dodge refills ammo
|
||
battle.on('hit', (c) => {
|
||
if (c.target !== unit || !unit.skill?.active || !c.source || c.source.side !== 'enemy' || !c.dmg.isAttack) return;
|
||
if (!meleeAttacker(c.source, unit) || !(prob > 0) || !battle.rng.chance(prob)) return;
|
||
c.dmg.cancel = true;
|
||
battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id });
|
||
// a dodge like the engine's (listeners of the `dodge` hook — items/bonds — see it too)
|
||
if (battle.hasHook('dodge')) battle.emit('dodge', { source: c.source, target: unit, dmg: c.dmg });
|
||
unit.skill.addAmmo(refill);
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 缇缇 — S3 旧日绽放 (25 s): ATK +, 2 targets, sleeps non-sleeping targets; during the skill every enemy waking up (or
|
||
// knocked out asleep) takes arts damage growing with the sleep time (linear min_atk_scale → max_atk_scale over `sleep` s,
|
||
// PRTS 备注) and sends the highest-aggro enemy within range_radius to sleep; other allies in range fall asleep (小睡)
|
||
// instead of dying. T1 凝固的时光: can attack sleeping enemies, +15 % ATK arts vs non-moving
|
||
// enemies, sleeping enemies take 30 % ATK arts/s. T2 勇气的报偿: Sargon/Minos ops above 50 % HP get +20 ASPD.
|
||
// S1 缓蚀 (duration): ATK +, attack@prob chance per attack to sleep the target attack@sleep s.
|
||
// S2 封护 (duration): no attacks, ATK +; she and the lowest-HP-ratio operator in her range sleep until the skill ends
|
||
// (沉睡 = invulnerable), enemies around either of them keep falling asleep; T1 ×talent_scale.
|
||
chess_char_5_02_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1);
|
||
const sid = selectedId(chess, def);
|
||
const hitSleep = num(bb['attack@sleep'], num(bb.sleep, 5));
|
||
const minS = num(bb.min_atk_scale, 1), maxS = num(bb.max_atk_scale, minS);
|
||
const chainSleep = num(bb.sleep, hitSleep), radius = num(bb.range_radius, 1.5), chainN = Math.max(1, num(bb.max_target, 1));
|
||
// S2 封护: "第一天赋效果提升至N倍" while it runs
|
||
const t0Scale = (unit) => (sid === 'skchr_titi_2' && unit.skill?.active ? num(bb.talent_scale, 1) : 1);
|
||
const ward = (battle, unit, a) => {
|
||
const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep');
|
||
battle.addBuff(a, { key: 'titi:ward', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit } });
|
||
battle.releaseBlocked(a);
|
||
if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered });
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_titi_1: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
|
||
attack: {
|
||
onHit({ battle, unit, target }) {
|
||
if (!target || !target.alive || target.s.flags.sleep || !battle.rng.chance(num(bb['attack@prob']))) return;
|
||
battle.applyStatus(target, 'sleep', { duration: num(bb['attack@sleep']), source: unit });
|
||
},
|
||
},
|
||
}),
|
||
skchr_titi_2: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
|
||
attack: { noAttack: true },
|
||
onStart({ battle, unit }) {
|
||
const pick = battle.alliesInGrid(unit).filter((a) => a !== unit && isOp(a) && a.hp > 0)
|
||
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0] ?? null;
|
||
unit.mem.titiWard = pick ? [unit, pick] : [unit];
|
||
for (const a of unit.mem.titiWard) ward(battle, unit, a);
|
||
unit.mem.titiWardAcc = AURA_IV;
|
||
battle.fx('sleepGuard', { x: unit.x, y: unit.y, id: unit.id });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.titiWardAcc += dt;
|
||
if (unit.mem.titiWardAcc < AURA_IV - 1e-9) return;
|
||
unit.mem.titiWardAcc = 0;
|
||
for (const a of unit.mem.titiWard || []) {
|
||
if (!a.alive || !a.deployed || !a.findBuff('titi:ward')) continue;
|
||
for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
for (const a of unit.mem.titiWard || []) {
|
||
const b = a.findBuff('titi:ward');
|
||
if (b && b.data.src === unit) battle.removeBuff(a, b);
|
||
}
|
||
unit.mem.titiWard = null;
|
||
},
|
||
}),
|
||
}),
|
||
trait: { hitSleep: true }, // 凝固的时光 (a): she targets and damages sleeping enemies (沉睡 = 无敌 for everyone else)
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk) }),
|
||
attack: {
|
||
maxTargets: Math.max(1, num(bb['attack@max_target'], 2)),
|
||
onHit({ battle, unit, target }) {
|
||
if (!target || !target.alive || target.s.flags.sleep) return;
|
||
battle.applyStatus(target, 'sleep', { duration: hitSleep, source: unit });
|
||
},
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
for (const a of battle.allyUnits) {
|
||
const b = a.findBuff('titi:allySleep');
|
||
if (b && b.data.src === unit) battle.removeBuff(a, b);
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 凝固的时光 — (a) is the `hitSleep` trait above
|
||
// (b) +extra_atk_scale arts vs non-moving enemies
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || !c.dmg?.isAttack || c.dmg.isSplash || c.type === 'element') return;
|
||
const e = c.target;
|
||
if (e.side !== 'enemy' || !e.alive || !isStill(e)) return;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.extra_atk_scale) * t0Scale(unit), type: 'arts', tags: ['talent', 'titiStill'] });
|
||
}, { owner: unit });
|
||
// (c) sleeping enemies take damage_atk_scale × ATK arts per second
|
||
whileOn(battle, unit, 1, () => {
|
||
const amount = unit.s.atk * num(t0.damage_atk_scale) * t0Scale(unit);
|
||
for (const e of battle.enemies) {
|
||
if (e.alive && !e.hidden && e.s.flags.sleep) battle.dealDamage(unit, e, { amount, type: 'arts', tags: ['talent', 'titiDream'] });
|
||
}
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 勇气的报偿: 精力充沛
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
for (const a of battle.allies()) {
|
||
if (isOp(a) && isSargonMinos(a) && a.hpRatio > num(t1.hp_ratio, 0.5)) battle.addBuff(a, { key: 'titi:vigor', duration: AURA_DUR, mods: { aspd: num(t1.attack_speed) } });
|
||
}
|
||
});
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
if (sid && sid !== 'skchr_titi_3') return;
|
||
// S3: wake-up burst chain (sleep applied by anyone counts)
|
||
const asleep = new Map();
|
||
// PRTS 备注: "向1.5半径内除该单位外仇恨值最高的敌方单位施加沉睡" — highest aggro (taunt) first, then the nearest
|
||
const sleepOthers = (from) => {
|
||
const cands = battle.enemiesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner)
|
||
.sort((a, b) => (b.s.taunt || 0) - (a.s.taunt || 0) || dist(a, from) - dist(b, from) || a.spawnSeq - b.spawnSeq);
|
||
let n = 0;
|
||
for (const o of cands) {
|
||
if (n >= chainN) break;
|
||
if (battle.applyStatus(o, 'sleep', { duration: chainSleep, source: unit })) n++;
|
||
}
|
||
};
|
||
battle.on('statusApplied', (c) => {
|
||
if (c.status === 'sleep' && c.target.side === 'enemy' && !asleep.has(c.target)) asleep.set(c.target, battle.time);
|
||
}, { owner: unit });
|
||
battle.on('tick', () => {
|
||
if (!asleep.size) return;
|
||
for (const [e, t0s] of asleep) {
|
||
if (!e.alive) { asleep.delete(e); continue; }
|
||
if (e.s.flags.sleep) continue;
|
||
asleep.delete(e);
|
||
if (!unit.skill?.active || !on(unit)) continue;
|
||
const scale = minS + (maxS - minS) * Math.min(1, (battle.time - t0s) / Math.max(0.1, chainSleep));
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'titiWake'] });
|
||
battle.fx('wake', { x: e.x, y: e.y, id: e.id, scale: Math.round(scale * 100) / 100 });
|
||
sleepOthers(e);
|
||
}
|
||
}, { owner: unit });
|
||
battle.on('kill', (c) => {
|
||
const v = c.victim;
|
||
if (v.side !== 'enemy' || !asleep.has(v)) return;
|
||
asleep.delete(v);
|
||
if (unit.skill?.active && on(unit)) sleepOthers(v);
|
||
}, { owner: unit });
|
||
// S3: other allies in range fall asleep instead of dying, until healed to full or the skill ends
|
||
battle.on('fatal', (c) => {
|
||
const a = c.unit;
|
||
if (c.prevented || a.side !== 'ally' || a === unit) return;
|
||
const guard = a.findBuff('titi:allySleep');
|
||
if (guard) { if (guard.data.src === unit) c.prevented = true; return; }
|
||
if (!isOp(a) || !unit.skill?.active || !on(unit) || !unit.rangeKeySet?.has(a.tileR * COLS + a.tileC)) return;
|
||
c.prevented = true;
|
||
const entered = !a.buffs.some((b) => (b.status ?? b.key) === 'sleep');
|
||
battle.addBuff(a, {
|
||
key: 'titi:allySleep', flags: { sleep: true }, visible: true, status: 'sleep', data: { src: unit }, interval: 0.2,
|
||
onTick: ({ unit: x, buff }) => { if (x.hp >= x.s.maxHp - 1e-6) battle.removeBuff(x, buff); },
|
||
});
|
||
// other content (特质 "干员进入沉睡时") observes it like any sleep status
|
||
if (battle.hasHook('statusApplied')) battle.emit('statusApplied', { source: unit, target: a, status: 'sleep', duration: Infinity, entered });
|
||
battle.fx('sleepGuard', { x: a.x, y: a.y, id: a.id });
|
||
}, { owner: unit, priority: 5 });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 烛煌 — S3 众恶的焚场 (ammo): skill range, ATK +, BAT −1.3 s, hits every enemy in range, +attack@atk_scale ATK
|
||
// elemental damage vs targets in a burn burst; loses 3 % max HP/s; any burn burst on the field refills ammo.
|
||
// T1 熔点引爆: burn burst anywhere → 350 % ATK elemental damage to it + heal 12 % max HP. T2 绝处重燃: downed instead of
|
||
// dying (6000 shield, no attack, no heal, 3 %/s regen) → revives at full HP and stuns nearby enemies.
|
||
// Module (elite): ×damage_scale vs enemies in an element burst.
|
||
// S1 炙手之援 (instant): the operator with the highest max HP in her range carries a fire aura for max_duration s —
|
||
// every `interval` s the enemies within range_radius of it take atk_scale × her ATK arts + element_multiplier × that
|
||
// damage as 灼燃损伤. S2 沸血燎原 (duration): skill range, BAT +0.9 s, ATK +, 3 targets; each attack costs
|
||
// attack@hp_ratio of her max HP and leaves a burning tile under every target: ground enemies on it move −50 % and take
|
||
// atk_scale × ATK arts + element_damage_scale × that as 灼燃损伤 per second (the tiles burn until the skill ends
|
||
// [ASSUMED: no duration in the data]). Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst.
|
||
chess_char_5_03_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
|
||
const sid = selectedId(chess, def);
|
||
const lose = num(bb.lose_hp_scale);
|
||
const burnTiles = (battle, unit) => {
|
||
const tiles = unit.mem.blazeTiles;
|
||
if (!tiles || !tiles.size) return;
|
||
unit.mem.blazeAcc2 = (unit.mem.blazeAcc2 ?? 0) + AURA_IV;
|
||
const dmgTick = unit.mem.blazeAcc2 >= 1 - 1e-9;
|
||
if (dmgTick) unit.mem.blazeAcc2 -= 1;
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, tiles)) continue;
|
||
battle.addBuff(e, { key: `blaze2:ground:${unit.id}`, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + num(bb.move_speed)) } });
|
||
if (!dmgTick) continue;
|
||
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeGround'] });
|
||
if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_damage_scale), tags: ['skill', 'blazeGround'] });
|
||
}
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_blaze2_1: () => ({
|
||
kind: instantKind(chess, def),
|
||
onStart({ battle, unit }) {
|
||
const t = battle.alliesInGrid(unit).filter((a) => isOp(a)).sort((a, b) => b.s.maxHp - a.s.maxHp || a.id - b.id)[0];
|
||
if (!t) return;
|
||
const r = num(bb.range_radius, 1.5);
|
||
battle.addBuff(t, {
|
||
key: `blaze2:aid:${unit.id}`, duration: num(bb.max_duration, 20), interval: Math.max(0.1, num(bb.interval, 1)), visible: true, data: { src: unit },
|
||
onTick: ({ unit: x }) => {
|
||
for (const e of battle.enemiesInRadius(x.x, x.y, r)) {
|
||
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeAid'] });
|
||
if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_multiplier), tags: ['skill', 'blazeAid'] });
|
||
}
|
||
},
|
||
});
|
||
battle.fx('aoe', { x: t.x, y: t.y, id: unit.id, r, skill: 'blaze2Aid' });
|
||
},
|
||
}),
|
||
skchr_blaze2_2: () => ({
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }),
|
||
attack: {
|
||
onEachHit({ unit, target }) {
|
||
if (target && target.side === 'enemy') unit.mem.blazeTiles?.add(Math.round(target.y) * COLS + Math.round(target.x));
|
||
},
|
||
},
|
||
onStart({ unit }) { unit.mem.blazeTiles = new Set(); unit.mem.blazeAcc2 = 0; },
|
||
onAttack({ battle, unit }) { battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit }); },
|
||
onEnd({ unit }) { unit.mem.blazeTiles = null; },
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'ammo', ammo: num(bb['attack@trigger_time'], 18),
|
||
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: {
|
||
allInRange: true,
|
||
onHit({ battle, unit, target }) {
|
||
if (target && target.alive && target.findBuff('burnBurst')) elementHit(battle, unit, target, unit.s.atk * num(bb['attack@atk_scale']), 'blazeBurn', 'burn');
|
||
},
|
||
},
|
||
onStart({ unit }) { unit.mem.blazeAcc = 0; },
|
||
onTick({ battle, unit, dt }) {
|
||
if (!(lose > 0)) return;
|
||
unit.mem.blazeAcc = (unit.mem.blazeAcc ?? 0) + dt;
|
||
while (unit.mem.blazeAcc >= 1 && unit.alive) {
|
||
unit.mem.blazeAcc -= 1;
|
||
battle.loseHp(unit, unit.s.maxHp * lose, { source: unit });
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 熔点引爆
|
||
battle.on('elementBurst', (c) => {
|
||
if (c.element !== 'burn' || !on(unit) || c.target.side !== 'enemy') return;
|
||
elementHit(battle, unit, c.target, unit.s.atk * num(t0.ep_damage_scale), 'blazeMelt', 'burn');
|
||
// 绝处重燃: "倒地期间…无法被治疗" — the meltdown heal does not reach her while downed
|
||
if (!unit.mem.downed) battle.heal(unit, unit, unit.s.maxHp * num(t0.hp_ratio), { self: true });
|
||
battle.fx('meltdown', { x: c.target.x, y: c.target.y, id: c.target.id });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 绝处重燃
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.downed = false; }, { owner: unit });
|
||
battle.on('fatal', (c) => {
|
||
if (c.unit !== unit || c.prevented || unit.mem.downed) return;
|
||
c.prevented = true;
|
||
unit.mem.downed = true;
|
||
if (unit.skill?.active) unit.skill.end('downed');
|
||
battle.releaseBlocked(unit);
|
||
const dep = unit.deploySeq;
|
||
battle.addBuff(unit, {
|
||
key: 'blaze2:downed', visible: true, shield: num(t1.dynamic), interval: 0.2,
|
||
flags: { disarm: true, noHeal: true },
|
||
mods: mods({ hpRegenRatio: num(t1.hp_recovery_per_sec_by_max_hp_ratio), blockCnt: -99 }),
|
||
onTick: ({ unit: u, buff }) => {
|
||
if (u.deploySeq !== dep || u.hp < u.s.maxHp - 1e-6) return;
|
||
battle.removeBuff(u, buff);
|
||
u.mem.downed = false;
|
||
for (const e of battle.enemiesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u });
|
||
battle.fx('revive', { x: u.x, y: u.y, id: u.id, r: NEARBY_RADIUS });
|
||
},
|
||
});
|
||
battle.fx('downed', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit, priority: -50 });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
burstDamageUp(battle, unit, num(tb.damage_scale));
|
||
burstSpUp(battle, unit, 'blaze2:module', num(tm.sp_recovery_per_sec));
|
||
if (sid === 'skchr_blaze2_2') whileOn(battle, unit, AURA_IV, () => burnTiles(battle, unit));
|
||
if (sid && sid !== 'skchr_blaze2_3') return;
|
||
battle.on('elementBurst', (c) => { // S3: burn bursts refill ammo
|
||
if (c.element === 'burn' && unit.skill?.active && unit.skill.kind === 'ammo' && on(unit)) unit.skill.addAmmo(num(bb.ammo_recover));
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 华法琳 — S1 紧急包扎 (charges, attack SP): the next heal on a target below half HP adds hp_ratio × its max HP.
|
||
// T1 血液样本回收: an enemy dying in range → +2 SP to herself and to a random ally in range.
|
||
// Module (elite): heals on allies below 50 % ×1.15.
|
||
chess_char_5_04_a: (bb, chess) => {
|
||
const t0 = talent(chess, 0), tb = traitBb(chess);
|
||
const ratio = num(bb.hp_ratio);
|
||
return {
|
||
skill: {
|
||
kind: 'charges', heal: true, trigger: NEVER,
|
||
onStart({ unit }) { unit.mem.bldskBonus = unit.mem.bldskTarget ?? null; },
|
||
},
|
||
talents: [{ install(battle, unit) { // 血液样本回收
|
||
battle.on('kill', (c) => {
|
||
const v = c.victim;
|
||
if (v.side !== 'enemy' || !on(unit) || !inRange(unit, v)) return;
|
||
unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp']), 'talent');
|
||
const others = battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive');
|
||
const pick = battle.rng.pick(others);
|
||
if (pick) pick.skill.gainSp(num(t0['bldsk_t_1[rand].sp']), 'talent');
|
||
}, { owner: unit });
|
||
} }],
|
||
install(battle, unit) {
|
||
// the skill only fires for a heal target below half HP: checked right before the heal
|
||
battle.on('beforeAttack', (c) => {
|
||
if (c.attacker !== unit) return;
|
||
unit.mem.bldskNoSp = false;
|
||
const sk = unit.skill;
|
||
if (!sk || !sk.ready || unit.s.flags.silence) return;
|
||
const t = c.targets[0];
|
||
if (!t || t.side !== 'ally' || !(t.hpRatio < HALF_HP)) return;
|
||
unit.mem.bldskTarget = t;
|
||
// the heal made by the skill recovers no attack SP (engine rule for skill attacks: cost N ⇒ every N+1 heals)
|
||
if (sk.activate('DEFAULT')) unit.mem.bldskNoSp = true;
|
||
unit.mem.bldskTarget = null;
|
||
}, { owner: unit });
|
||
battle.on('spGain', (c) => {
|
||
if (c.unit !== unit || c.reason !== 'attack' || !unit.mem.bldskNoSp) return;
|
||
unit.mem.bldskNoSp = false;
|
||
c.amount = 0;
|
||
}, { owner: unit, priority: 100 });
|
||
battle.on('heal', (c) => { // merged into the same heal
|
||
if (c.source !== unit || !unit.mem.bldskBonus || c.target !== unit.mem.bldskBonus) return;
|
||
unit.mem.bldskBonus = null;
|
||
// the bonus is part of the same heal: the healer's and the target's healing multipliers apply to it too
|
||
c.amount += c.target.s.maxHp * ratio * num(unit.s.healingDealtMul, 1) * num(c.target.s.healingTakenMul, 1);
|
||
battle.fx('healAoe', { x: c.target.x, y: c.target.y, id: c.target.id, r: 0.5 });
|
||
}, { owner: unit, priority: 10 });
|
||
lowHpHealUp(battle, unit, tb);
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 乌尔比安 — S3 必须开辟的通路 (25 s, CUSTOM_RANGE row ahead): max HP/ATK +, throws an anchor forward that stops on the
|
||
// first enemy or at max distance: 135 % ATK phys + 6 s stun around it (projectile_range); moves onto the anchor tile when
|
||
// deployable (a 从不混淆的方向 marker keeps his tile) and returns at skill end.
|
||
// T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9),
|
||
// other Abyssal Hunters +50 %. Module (elite): healing received ×1.2.
|
||
// S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked
|
||
// first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each;
|
||
// a 捕网 — ground enemies only ([ASSUMED] like 雪雉's "不对空" net). S3's anchor blast has no such note: it hits air
|
||
// units too [ASSUMED].
|
||
// S2 必须维系的界限 (toggle, 持续时间无限): T1 ×talent_scale, block +1, max HP +, ATK +.
|
||
chess_char_5_05_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const sid = selectedId(chess, def);
|
||
const t0Scale = sid === 'skchr_ulpia_2' ? num(bb.talent_scale, 1) : 1;
|
||
const tokenId = chess?.skill?.overrideTokenKey ?? def?.skill?.raw?.overrideTokenKey ?? 'token_10039_ulpia_block';
|
||
const trig = def?.skill?.trigger?.grid ?? chess?.skill?.trigger?.customRangeGrid ?? [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6]];
|
||
const reach = Math.max(1, ...trig.filter((p) => p[0] === 0).map((p) => p[1]));
|
||
const radius = num(bb.projectile_range, 1.5);
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_ulpia_1: () => ({
|
||
kind: instantKind(chess, def),
|
||
onStart({ battle, unit }) {
|
||
// "向前方扔出船锚": the anchor goes beyond his own reach first (pulling what he already hits is moot), then
|
||
// unblocked ground enemies, then the usual target order
|
||
const list = enemiesInGrid(battle, unit, skillGrid(chess, def) ?? unit.rangeGrid);
|
||
const reach = (e) => inRange(unit, e);
|
||
// the anchor is a 捕网 (PRTS 备注 "实际效果为捕网而非拖拽"); 雪雉's 捕网 is "不对空" (PRTS 雪雉 备注) — [ASSUMED] the same
|
||
// for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空)
|
||
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying);
|
||
if (!main) return;
|
||
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
|
||
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
|
||
.slice(0, Math.max(1, num(bb.max_target, 2)));
|
||
const force = num(bb.force, 1);
|
||
battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 });
|
||
for (const e of near) {
|
||
// "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") —
|
||
// Battle.pullToFront, the official 力度 − 重量 pull
|
||
battle.pullToFront(e, unit, force);
|
||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] });
|
||
}
|
||
},
|
||
}),
|
||
skchr_ulpia_2: () => ({
|
||
kind: 'toggle',
|
||
mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), blockCnt: num(bb.block_cnt) }),
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ hpPct: num(bb.max_hp), atkPct: num(bb.atk) }),
|
||
onStart({ battle, unit }) {
|
||
// the anchor flies straight ahead along his direction
|
||
let stop = null;
|
||
for (let d = 1; d <= reach; d++) {
|
||
const [r, c] = frontOf(unit.tileR, unit.tileC, unit.dir, d);
|
||
// the anchor stops at the field edge and in front of a ground obstacle (crates, roadblocks)
|
||
if (!battle.grid.inRect(r, c) || battle.grid.isObstacle(r, c)) break;
|
||
stop = d;
|
||
if (battle.enemiesInKeys([r * COLS + c], unit, { canHitFly: true }).length) break;
|
||
}
|
||
const [sr, sc] = frontOf(unit.tileR, unit.tileC, unit.dir, stop ?? 0);
|
||
const fromX = unit.x, fromY = unit.y;
|
||
battle.fx('anchor', { x: sc, y: sr, id: unit.id, fromX, fromY, r: radius });
|
||
for (const e of battle.enemiesInRadius(sc, sr, radius)) {
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchor'] });
|
||
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit });
|
||
}
|
||
if (stop == null || !unit.alive) return;
|
||
// PRTS 备注: landing tile > the tile one beyond it > his own tile (a deployable, free, unreserved melee tile)
|
||
const ok = ([r, c]) => (r !== unit.tileR || c !== unit.tileC) && battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c);
|
||
const dest = [[sr, sc], frontOf(unit.tileR, unit.tileC, unit.dir, stop + 1)].find(ok);
|
||
if (dest == null) return;
|
||
const home = [unit.tileR, unit.tileC];
|
||
if (!battle.relocate(unit, dest[0], dest[1])) return;
|
||
const marker = battle.spawnToken(unit, tokenId, home[0], home[1], { untargetable: true, kit: { skill: null, trait: { noAttack: true } } });
|
||
unit.mem.anchorHome = { r: home[0], c: home[1], marker };
|
||
battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY });
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const h = unit.mem.anchorHome;
|
||
unit.mem.anchorHome = null;
|
||
if (!h) return;
|
||
if (h.marker && h.marker.alive) battle.retreat(h.marker, { reason: 'expired', permanent: true });
|
||
if (unit.alive && unit.deployed) {
|
||
const fromX = unit.x, fromY = unit.y;
|
||
if (battle.relocate(unit, h.r, h.c)) battle.fx('teleport', { x: unit.x, y: unit.y, id: unit.id, fromX, fromY });
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 本性的坚守
|
||
battle.on('damaged', (c) => {
|
||
if (c.target !== unit || !on(unit) || unit.hp <= 0 || !(c.amount > 0) || c.type === 'element') return;
|
||
const v = (unit.hpRatio < num(t0.hp_ratio, 0.5) ? num(t0.value2) : num(t0.value1)) * (unit.skill?.active ? t0Scale : 1);
|
||
if (v > 0) battle.heal(unit, unit, v, { self: true });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 血脉的哺养
|
||
battle.on('kill', (c) => {
|
||
if (c.killer !== unit || c.victim.side !== 'enemy' || !on(unit)) return;
|
||
battle.addBuff(unit, { key: 'ulpia:blood', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 9)), mods: mods({ hpFlat: num(t1.max_hp), atkFlat: num(t1.atk) }) });
|
||
const share = mods({ hpFlat: num(t1['ulpia_t_1[abyssal].max_hp']), atkFlat: num(t1['ulpia_t_1[abyssal].atk']) });
|
||
if (!Object.keys(share).length) return;
|
||
for (const a of battle.allies()) {
|
||
if (a !== unit && isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ulpia:bloodShare', refresh: 'stack', maxStacks: Math.max(1, num(t1['ulpia_t_1[abyssal].max_stack_cnt'], 9)), mods: share });
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
if (num(tb.heal_scale) > 0) permBuff(battle, unit, 'ulpia:module', { healingTakenMul: num(tb.heal_scale) });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 隐德来希 — S3 灵与欲的惜别 (20 s): skill range, ATK +, ASPD +100; summons a 心烛 for up to 3 highest-HP ground enemies
|
||
// in range (60 % of their current HP, same DEF/RES); his hits on a candle deal at least 35 % ATK and the original loses
|
||
// the same HP; only he can damage candles (other allies' attacks are redirected to the original).
|
||
// T1 萃血: each hit steals 75 max HP (≤ 1350) and applies 200 arts/s for 5 s. T2 重盈: once below 25 % HP, heal 50 %
|
||
// max HP and take −10 % phys damage afterwards. Reaper trait (heal per hit, module 60) comes from the profession.
|
||
// S1 玫影觅迹 (instant, attack SP): next attack atk_scale × ATK, twice. S2 绯红壁合 (duration): no attacks; blood sickles
|
||
// on herself and on one other ground unit (the ally with the most enemies around it) cut every enemy around them (RING1)
|
||
// for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注).
|
||
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
|
||
chess_char_5_06_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
|
||
const nCandles = Math.max(1, num(bb['attack@max_target'], 3));
|
||
const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY;
|
||
const aroundN = (battle, a) => battle.enemiesInRadius(a.x, a.y, RING1).length;
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_etlchi_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), hits: 2 } }),
|
||
skchr_etlchi_2: () => ({
|
||
kind: 'duration', attack: { noAttack: true },
|
||
onStart({ battle, unit }) {
|
||
const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0)
|
||
.sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null;
|
||
unit.mem.sickles = other ? [unit, other] : [unit];
|
||
unit.mem.sickleAcc = 0;
|
||
for (const a of unit.mem.sickles) battle.fx('aoe', { x: a.x, y: a.y, id: unit.id, r: RING1, skill: 'etlchiSickle' });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const iv = Math.max(0.1, num(bb.interval, 0.5));
|
||
unit.mem.sickleAcc += dt;
|
||
while (unit.mem.sickleAcc >= iv - 1e-9) {
|
||
unit.mem.sickleAcc -= iv;
|
||
for (const a of unit.mem.sickles || []) {
|
||
if (!a.alive || !a.deployed) continue;
|
||
// PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空);
|
||
// 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly)
|
||
const air = !a.ground && !!a.s.flags.blockFly;
|
||
for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) {
|
||
if (e.isFlying && !air) continue;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] });
|
||
}
|
||
}
|
||
}
|
||
},
|
||
onEnd({ unit }) { unit.mem.sickles = null; },
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
onStart({ battle, unit }) {
|
||
unit.mem.candles = [];
|
||
const picks = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false })
|
||
.filter((e) => !e.isFlying && !e.mem?.candleOwner && !e.isBoss)
|
||
.sort((a, b) => b.hp - a.hp || a.spawnSeq - b.spawnSeq).slice(0, nCandles);
|
||
for (const e of picks) {
|
||
const r = Math.round(e.y), c = Math.round(e.x);
|
||
// 心烛 is not in data/enemies.json: an inline record (neutral, never attacks, never moves, not counted)
|
||
const def = {
|
||
name: '心烛', rank: 'NORMAL', applyWay: 'NONE', motion: 'WALK', notCountInTotal: true, lifePointReduce: 0,
|
||
stats: { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, magicResistance: e.s.res * resScale, moveSpeed: 0, massLevel: num(e.base.massLevel, 1) },
|
||
};
|
||
const cd = battle.spawnEnemy(candleKey, {
|
||
def, pos: [e.y, e.x], route: { motion: 'WALK', start: [r, c], end: [r, c], checkpoints: [] },
|
||
countInTotal: false, ownerPlayerId: e.ownerId,
|
||
});
|
||
if (!cd) continue;
|
||
cd.name = '心烛';
|
||
Object.assign(cd.base, { maxHp: Math.max(1, e.hp * hpScale), atk: 0, def: e.s.def * defScale, res: e.s.res * resScale, moveSpeed: 0 });
|
||
cd.profile = { ...(cd.profile || {}), noAttack: true };
|
||
cd.markDirty();
|
||
cd.hp = cd.s.maxHp;
|
||
cd.mem.candleOwner = unit;
|
||
cd.mem.candleOf = e;
|
||
battle.addBuff(cd, { key: 'etlchi:candle', flags: { unblockable: true, noMove: true } });
|
||
unit.mem.candles.push(cd);
|
||
battle.fx('candle', { x: cd.x, y: cd.y, id: cd.id, of: e.id });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
for (const cd of unit.mem.candles || []) if (cd.alive) battle.kill(cd, null);
|
||
unit.mem.candles = [];
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 萃血
|
||
const steal = num(t0['attack@steal_hp']), cap = num(t0['attack@steal_hp_max']);
|
||
const dot = num(t0.magic_value), dotDur = num(t0.dot_duration, 5), iv = num(t0.interval, 1);
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.stolen = 0; }, { owner: unit });
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element') return;
|
||
const e = c.target;
|
||
if (e.side !== 'enemy' || !e.alive || e.mem?.candleOwner || !on(unit)) return;
|
||
if (dot > 0) {
|
||
battle.addBuff(e, { key: `etlchi:dot:${unit.id}`, duration: dotDur, interval: iv, refresh: 'extend',
|
||
onTick: ({ unit: x }) => battle.dealDamage(unit, x, { amount: dot, type: 'arts', tags: ['talent', 'dot'] }) });
|
||
}
|
||
const stolen = num(unit.mem.stolen);
|
||
// (a boss sharing the match HP pool keeps its max HP: the pool is owned by the match)
|
||
const n = Math.min(steal, cap - stolen, Math.floor(e.s.maxHp - 1)); // never below 1 max HP
|
||
if (n > 0 && !e.bossPool) {
|
||
unit.mem.stolen = stolen + n;
|
||
// the victim keeps an explicit running total (a 'stack' refresh would rescale every stack by the last n)
|
||
const key = `etlchi:steal:${unit.id}`;
|
||
const lost = num(e.findBuff(key)?.data?.total) + n;
|
||
const hp0 = e.hp;
|
||
battle.addBuff(e, { key, mods: { hpFlat: -lost }, data: { total: lost } });
|
||
// 偷取生命上限: the victim's current HP only drops where it exceeds the new maximum (the engine keeps ratios)
|
||
if (e.alive) e.hp = Math.min(hp0, e.s.maxHp);
|
||
battle.addBuff(unit, { key: 'etlchi:gain', mods: { hpFlat: unit.mem.stolen } });
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 重盈
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.reborn = false; }, { owner: unit });
|
||
battle.on('damaged', (c) => {
|
||
if (c.target !== unit || unit.mem.reborn || !on(unit) || unit.hp <= 0 || !(unit.hpRatio < num(t1.hp_ratio))) return;
|
||
unit.mem.reborn = true;
|
||
battle.heal(unit, unit, unit.s.maxHp * num(t1['etlchi_t_2[heal].hp_ratio']), { self: true });
|
||
if (num(t1.damage_resistance) > 0) battle.addBuff(unit, { key: 'etlchi:reborn', mods: { physTakenMul: 1 - num(t1.damage_resistance) } });
|
||
battle.fx('reborn', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
crowdAspd(battle, unit, 'etlchi:module', num(tb.attack_speed), num(tb.cnt));
|
||
const mine = (e) => e && e.mem && e.mem.candleOwner === unit;
|
||
// "心烛只受隐德来希攻击的影响": other sources' element gauges never fill a candle either
|
||
battle.on('elementHit', (c) => { if (mine(c.target) && c.source !== unit) c.dmg.cancel = true; }, { owner: unit });
|
||
battle.on('beforeStatus', (c) => { if (mine(c.target) && c.source !== unit) c.cancel = true; }, { owner: unit });
|
||
battle.on('hit', (c) => {
|
||
if (!mine(c.target)) return;
|
||
if (c.source !== unit) { c.dmg.cancel = true; return; }
|
||
if (c.dmg.type !== 'phys' && c.dmg.type !== 'arts') return;
|
||
const ss = unit.s;
|
||
const mit = mitigate(c.dmg.amount, c.dmg.type, c.target.s, {
|
||
defIgnorePct: c.dmg.defIgnorePct + ss.defIgnorePct, defIgnoreFlat: c.dmg.defIgnoreFlat + ss.defIgnoreFlat,
|
||
resIgnorePct: c.dmg.resIgnorePct + ss.resIgnorePct, resIgnoreFlat: c.dmg.resIgnoreFlat + ss.resIgnoreFlat,
|
||
});
|
||
const floor = ss.atk * CANDLE_MIN_ATK;
|
||
if (mit < floor) { c.dmg.type = 'true'; c.dmg.amount = floor; c.dmg.canDodge = false; }
|
||
}, { owner: unit, priority: -20 });
|
||
battle.on('damaged', (c) => {
|
||
if (!mine(c.target) || !(c.amount > 0) || c.type === 'element') return;
|
||
const o = c.target.mem.candleOf;
|
||
if (o && o.alive) battle.loseHp(o, c.amount, { source: unit });
|
||
}, { owner: unit });
|
||
battle.on('death', (c) => { // the original fell or leaked: its candle goes out
|
||
if (c.unit.side !== 'enemy' || c.unit.mem?.candleOwner) return;
|
||
for (const cd of unit.mem.candles || []) if (cd.alive && cd.mem.candleOf === c.unit) battle.kill(cd, null);
|
||
}, { owner: unit });
|
||
battle.on('beforeAttack', (c) => { // other allies hit the original instead of the candle
|
||
if (c.attacker === unit || c.attacker.side !== 'ally' || !(unit.mem.candles && unit.mem.candles.length)) return;
|
||
if (!c.targets.some(mine)) return;
|
||
const out = [];
|
||
for (const t of c.targets) {
|
||
if (!mine(t)) { if (!out.includes(t)) out.push(t); continue; }
|
||
const o = t.mem.candleOf;
|
||
if (o && o.alive && !out.includes(o) && !c.targets.includes(o)) out.push(o);
|
||
}
|
||
c.targets = out;
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 史尔特尔 — S3 黄昏 (toggle, 持续时间无限): full heal, ATK +, range +2, 3 targets, max HP +5000 (flat), HP loss ramping
|
||
// to 20 % max HP/s over 60 s. T1 熔火: ignores 20 RES. T2 余烬: lethal damage keeps HP ≥ 1 for 8 s, then she withdraws.
|
||
// Module (elite): ASPD +8 while not blocking.
|
||
// S1 烈焰魔剑 (instant, attack SP): next attack atk_scale × ATK; a kill refills all SP at once.
|
||
// S2 熔核巨影 (duration): ATK +, range +1, 2 targets; an attack that hits a single enemy is ×critical atk_scale.
|
||
// Module AFT-Y (elite): the enemies she blocks are 法术脆弱 +10 %.
|
||
chess_char_5_07_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const sid = selectedId(chess, def);
|
||
const iv = Math.max(0.05, num(bb.interval, 0.2)), peak = num(bb.hp_ratio), ramp = Math.max(0.1, num(bb.duration, 60));
|
||
const maxHp = num(bb.max_hp);
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_surtr_1: () => ({
|
||
kind: instantKind(chess, def),
|
||
attack: {
|
||
atkScale: num(bb.atk_scale, 1),
|
||
onHit({ unit, target }) {
|
||
// 将目标击倒则立即恢复所有技力 (the skill is still pending here: the refill is the next charge)
|
||
if (target && target.side === 'enemy' && !target.alive && unit.skill) unit.skill.gainSp(unit.skill.spCost, 'skill');
|
||
},
|
||
},
|
||
}),
|
||
skchr_surtr_2: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
|
||
targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }),
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'toggle',
|
||
mods: mods({ atkPct: num(bb.atk), ...(Math.abs(maxHp) > 5 ? { hpFlat: maxHp } : { hpPct: maxHp }) }),
|
||
targeting: mods({ rangeExtend: Math.round(num(bb.ability_range_forward_extend)), maxTargets: num(bb['attack@max_target']) }),
|
||
onStart({ battle, unit }) {
|
||
unit.mem.twilightT = 0;
|
||
unit.mem.twilightAcc = 0;
|
||
battle.heal(unit, unit, unit.s.maxHp, { self: true });
|
||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'surtr' });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
if (!(peak > 0)) return;
|
||
unit.mem.twilightT += dt;
|
||
unit.mem.twilightAcc += dt;
|
||
while (unit.mem.twilightAcc >= iv && unit.alive) {
|
||
unit.mem.twilightAcc -= iv;
|
||
const rate = peak * Math.min(1, unit.mem.twilightT / ramp);
|
||
if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * iv, { source: unit, silent: true });
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { permBuff(battle, unit, 'surtr:magma', { resIgnoreFlat: num(t0.magic_resist_penetrate_fixed) }); } },
|
||
{ install(battle, unit) { // 余烬
|
||
const wait = num(t1['surtr_t_2[withdraw].interval'], 8);
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit });
|
||
battle.on('fatal', (c) => {
|
||
if (c.unit !== unit || c.prevented) return;
|
||
c.prevented = true;
|
||
if (unit.mem.ember) return;
|
||
unit.mem.ember = true;
|
||
const dep = unit.deploySeq;
|
||
battle.fx('ember', { x: unit.x, y: unit.y, id: unit.id });
|
||
battle.after(wait, () => { if (unit.alive && unit.deploySeq === dep) battle.retreat(unit, { reason: 'retreat' }); }, { owner: unit });
|
||
}, { owner: unit, priority: -60 });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
const aspd = num(tb.attack_speed);
|
||
if (aspd) {
|
||
whileOn(battle, unit, 0.2, () => {
|
||
if (!unit.blocking.length) battle.addBuff(unit, { key: 'surtr:module', duration: 0.3, mods: { aspd } });
|
||
else battle.removeBuff(unit, 'surtr:module');
|
||
});
|
||
}
|
||
const fragile = num(tb.damage_scale, 1) - 1; // AFT-Y: 自身阻挡的敌人受到10%的法术脆弱效果
|
||
if (fragile > 0) {
|
||
whileOn(battle, unit, 0.2, () => {
|
||
for (const e of unit.blocking) if (e.alive) battle.applyStatus(e, 'artsFragile', { duration: 0.3, value: fragile, source: unit });
|
||
});
|
||
}
|
||
if (sid === 'skchr_surtr_2') { // S2: 仅攻击到一个敌人时对其攻击力提升至140%
|
||
const solo = num(bb['attack@surtr_s_2[critical].atk_scale'], 1);
|
||
battle.on('beforeAttack', (c) => { if (c.attacker === unit) unit.mem.surtrSolo = !!unit.skill?.active && c.targets.filter((t) => t && t.alive).length === 1; }, { owner: unit, priority: -100 });
|
||
battle.on('hit', (c) => {
|
||
if (c.source === unit && unit.mem.surtrSolo && unit.skill?.active && c.dmg.isAttack && !c.dmg.isSplash) c.dmg.amount *= solo;
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 号角 — S3 终极防线 (24 s in total, a two-segment gauge — PRTS 备注: "技能进行到一半时触发额外效果"): ATK +, BAT −1.2 s
|
||
// for the first 12 s, then 过载 for the remaining damage_duration (12) s: ATK changes to +50 %, HP loss ramping to 12 %
|
||
// max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field.
|
||
// T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %.
|
||
// Module (elite): ×1.1 vs blocked enemies.
|
||
// S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also
|
||
// splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose
|
||
// 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the
|
||
// ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the
|
||
// auto battle). Module FOR-Y (elite): ASPD +10 while not blocking.
|
||
chess_char_5_08_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
|
||
const sid = selectedId(chess, def);
|
||
const s2Ammo = Math.max(1, num(bb['attack@s2.trigger_time'], 10));
|
||
const total = Math.max(0.1, num(def?.skill?.duration, num(chess?.skill?.duration, 24)));
|
||
const ovAtk = num(bb['horn_s_3[overload_start].atk'], num(bb.atk));
|
||
const ovIv = Math.max(0.05, num(bb['horn_s_3[overload_start].interval'], 0.2));
|
||
const ovPeak = num(bb['horn_s_3[overload_start].hp_ratio']);
|
||
const ovRamp = Math.max(0.1, Math.min(total, num(bb['horn_s_3[overload_start].damage_duration'], total / 2)));
|
||
const main = total - ovRamp;
|
||
const blockedMul = num(tb.atk_scale);
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_horn_1: () => ({
|
||
kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略)
|
||
attack: {
|
||
atkScale: num(bb.atk_scale, 1),
|
||
onHit({ battle, unit, x, y }) {
|
||
if (!unit.mem.hornFlare) return;
|
||
unit.mem.hornFlare = false;
|
||
(unit.mem.flares ??= []).push({ x, y, until: battle.time + num(bb.projectile_delay_time, 6) });
|
||
battle.fx('zone', { x, y, id: unit.id, r: num(bb.projectile_range, 1.7), duration: num(bb.projectile_delay_time, 6) });
|
||
},
|
||
},
|
||
}),
|
||
skchr_horn_2: () => ({
|
||
kind: 'ammo', ammo: s2Ammo,
|
||
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) },
|
||
onStart({ unit }) { unit.mem.hornS2Over = false; },
|
||
onEnd({ unit }) { unit.mem.hornS2Over = false; },
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration', duration: total,
|
||
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
|
||
onStart({ unit }) { unit.mem.hornT = 0; unit.mem.hornAcc = 0; unit.mem.overload = false; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.hornT += dt;
|
||
if (!unit.mem.overload && unit.mem.hornT >= main - 1e-9) {
|
||
unit.mem.overload = true;
|
||
const delta = ovAtk - num(bb.atk);
|
||
battle.addBuff(unit, { key: 'horn:overload', visible: true, mods: mods({ atkPct: delta }) });
|
||
battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
|
||
}
|
||
if (!unit.mem.overload || !(ovPeak > 0)) return;
|
||
unit.mem.hornAcc += dt;
|
||
while (unit.mem.hornAcc >= ovIv && unit.alive) {
|
||
unit.mem.hornAcc -= ovIv;
|
||
const rate = ovPeak * Math.min(1, (unit.mem.hornT - main) / ovRamp);
|
||
if (rate > 0) battle.loseHp(unit, unit.s.maxHp * rate * ovIv, { source: unit, silent: true });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) { unit.mem.overload = false; battle.removeBuff(unit, 'horn:overload'); },
|
||
},
|
||
trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined,
|
||
talents: [
|
||
{ install(battle, unit) { // 军事要塞
|
||
const atk = num(t0.atk);
|
||
if (!atk) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
for (const a of battle.allies()) if (isOp(a) && a.def?.profession === 'TANK') battle.addBuff(a, { key: 'horn:fortress', duration: AURA_DUR, mods: { atkPct: atk } });
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 血战
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.bloodBattle = false; }, { owner: unit });
|
||
battle.on('fatal', (c) => {
|
||
if (c.unit !== unit || c.prevented || unit.mem.bloodBattle) return;
|
||
c.prevented = true;
|
||
unit.mem.bloodBattle = true;
|
||
battle.addBuff(unit, { key: 'horn:bloodBattle', visible: true, mods: mods({ hpMul: 1 - num(t1.max_hp), aspd: num(t1.attack_speed), defPct: num(t1.def) }) });
|
||
unit.hp = Math.max(1, unit.hp);
|
||
battle.heal(unit, unit, unit.s.maxHp * num(t1.hp_ratio, 1), { self: true });
|
||
battle.fx('bloodBattle', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit, priority: -40 });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10
|
||
if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); });
|
||
if (sid === 'skchr_horn_1') {
|
||
// the pending shot: a ranged (unblocked) attack widens its splash and lights the impact
|
||
battle.on('beforeAttack', (c) => {
|
||
if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return;
|
||
unit.mem.hornFlare = !c.profile._fortressMelee;
|
||
if (unit.mem.hornFlare) c.profile.splashRadius = Math.max(num(c.profile.splashRadius), num(bb.projectile_range, 1.7));
|
||
}, { owner: unit, priority: -100 });
|
||
battle.every(AURA_IV, () => {
|
||
const fl = unit.mem.flares;
|
||
if (!fl || !fl.length) return;
|
||
unit.mem.flares = fl.filter((f) => f.until > battle.time + 1e-9);
|
||
const r = num(bb.projectile_range, 1.7);
|
||
for (const f of unit.mem.flares) for (const e of battle.enemies) {
|
||
if (e.alive && !e.hidden && bodyInRadius(e, f.x, f.y, r)) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
|
||
}
|
||
}, { owner: unit });
|
||
}
|
||
if (sid === 'skchr_horn_2') {
|
||
const magic = num(bb['attack@s2.magic_atk_scale']);
|
||
battle.on('beforeAttack', (c) => { // 过载: the second half of the ammo
|
||
if (c.attacker !== unit || !unit.skill?.active || !c.profile) return;
|
||
const shot = s2Ammo - num(unit.skill.ammoLeft) + 1;
|
||
const over = shot > s2Ammo * HORN_S2_OVERLOAD_AT + 1e-9;
|
||
if (over && !unit.mem.hornS2Over) battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
|
||
unit.mem.hornS2Over = over;
|
||
if (!over || !(magic > 0)) return;
|
||
// decided at the shot (a shell still in flight when the skill ends keeps it): the effective profile of this
|
||
// attack (a per-attack copy while the skill runs) carries the extra arts splash on every enemy it hits
|
||
const prev = c.profile.onEachHit;
|
||
c.profile.onEachHit = (b, u, victim, hc) => {
|
||
if (prev) prev(b, u, victim, hc);
|
||
if (victim && victim.alive && victim.side === 'enemy') b.dealDamage(unit, victim, { amount: unit.s.atk * magic, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'hornOverload'] });
|
||
};
|
||
}, { owner: unit, priority: -100 });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 魔王 — bard (aura heal 10 % ATK/s). S3 编织重构现世 (30 s): skill range, aura 65 %, motes never vanish, other allies in
|
||
// range get 鼓舞 = +65 % of her max HP, HP of everyone in range is equalised every 2 s.
|
||
// T1 过往尘埃: 3 motes orbit her; one colliding with an operator vanishes and gives it ×1.5 aura healing for 6 s, then
|
||
// respawns after 6 s.
|
||
// T2 魔王残响: allies take −10 % damage from Sarkaz enemies. Module (elite): ≥2 other ops in range → ATK +8 %.
|
||
chess_char_5_09_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
|
||
const baseRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1);
|
||
const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], baseRatio);
|
||
const inspire = num(bb.max_hp);
|
||
const shareIv = Math.max(0.1, num(bb['attack@cetsyr_s_3[cal_hp_ratio].interval'], 2));
|
||
const inspireAll = (battle, unit) => {
|
||
if (!(inspire > 0)) return;
|
||
const hp = unit.s.maxHp * inspire;
|
||
for (const a of battle.alliesInGrid(unit)) {
|
||
if (a !== unit) battle.addBuff(a, { key: 'cetsyr:inspire', duration: AURA_DUR, visible: true, status: 'inspire', mods: { hpFlat: hp }, data: { src: unit } });
|
||
}
|
||
};
|
||
return {
|
||
skill: {
|
||
kind: 'duration',
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
onStart({ battle, unit }) {
|
||
if (unit.profile) unit.profile.auraRatio = skillRatio;
|
||
unit.mem.shareAcc = 0;
|
||
unit.mem.inspireAcc = 0;
|
||
inspireAll(battle, unit);
|
||
battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.inspireAcc += dt;
|
||
if (unit.mem.inspireAcc >= AURA_IV) { unit.mem.inspireAcc = 0; inspireAll(battle, unit); }
|
||
unit.mem.shareAcc += dt;
|
||
if (unit.mem.shareAcc < shareIv - 1e-9) return;
|
||
unit.mem.shareAcc -= shareIv;
|
||
const group = battle.alliesInGrid(unit).filter((a) => a.alive && a.kind !== 'device');
|
||
let hp = 0, max = 0;
|
||
for (const a of group) { hp += a.hp; max += a.s.maxHp; }
|
||
if (!(max > 0) || group.length < 2) return;
|
||
const ratio = hp / max;
|
||
for (const a of group) a.hp = Math.max(1, Math.min(a.s.maxHp, a.s.maxHp * ratio));
|
||
battle.fx('hpShare', { x: unit.x, y: unit.y, id: unit.id, ratio: Math.round(ratio * 1000) / 1000 });
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
if (unit.profile) unit.profile.auraRatio = baseRatio;
|
||
for (const a of battle.allyUnits) {
|
||
const b = a.findBuff('cetsyr:inspire');
|
||
if (b && b.data.src === unit) battle.removeBuff(a, b);
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 过往尘埃: cnt motes orbit her (radius range_radius, dynamic_spd °/s, PRTS hit radius 0.4)
|
||
const cnt = Math.max(0, Math.floor(num(t0.cnt, 3))), orbit = num(t0.range_radius, 1.15);
|
||
const spd = (num(t0.dynamic_spd, 30) * Math.PI) / 180;
|
||
const cd = num(t0.cooldown, 6), dur = num(t0.talent_duration, 6), mul = num(t0['attack@trait_mul'], 1.5);
|
||
const motes = new Array(cnt).fill(0); // per orbit slot: the time its mote is back (vanished → respawn after cd)
|
||
const reach = orbit + MOTE_HIT_RADIUS + 0.01;
|
||
battle.on('deploy', (c) => { if (c.unit === unit) motes.fill(0); }, { owner: unit });
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
if (!cnt) return;
|
||
const keep = !!unit.skill?.active; // S3: "微尘"不再消失
|
||
// "微尘"碰撞友方干员: operators only; a mote never collides with an operator already under a mote effect
|
||
const cands = battle.alliesInRadius(unit.x, unit.y, reach, null).filter((a) => a !== unit && isOp(a) && !a.findBuff('cetsyr:mote'));
|
||
if (!cands.length) return;
|
||
const t = battle.time - num(unit.deployedAt);
|
||
for (let k = 0; k < cnt; k++) {
|
||
if (motes[k] > battle.time + 1e-9) continue;
|
||
const ang = spd * t + (2 * Math.PI * k) / cnt;
|
||
// the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand
|
||
const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir);
|
||
const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang));
|
||
let hit = null, hd = Infinity;
|
||
for (const a of cands) {
|
||
if (a.findBuff('cetsyr:mote')) continue;
|
||
const d = Math.hypot(a.x - mx, a.y - my);
|
||
if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; }
|
||
}
|
||
if (!hit) continue;
|
||
battle.addBuff(hit, { key: 'cetsyr:mote', duration: dur, visible: true, data: { src: unit, mul } });
|
||
if (!keep) motes[k] = battle.time + cd;
|
||
battle.fx('mote', { x: hit.x, y: hit.y, id: hit.id });
|
||
}
|
||
});
|
||
battle.on('heal', (c) => {
|
||
if (c.source !== unit || !c.opts?.aura) return;
|
||
const b = c.target.findBuff('cetsyr:mote');
|
||
if (b) c.amount *= num(b.data.mul, 1);
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 魔王残响
|
||
const dr = num(t1.damage_resistance);
|
||
if (!(dr > 0)) return;
|
||
battle.on('hit', (c) => {
|
||
if (!c.target || c.target.side !== 'ally' || !c.source || c.source.side !== 'enemy') return;
|
||
if (!(c.source.def?.tags || []).includes('sarkaz') || leaderOf(battle, unit) !== unit) return;
|
||
c.dmg.mul *= 1 - dr;
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) { // module: ≥ cnt other ops in the (base) range → ATK +8 %
|
||
const atk = num(tm.atk), need = num(tm.cnt, 2);
|
||
if (!chess?.isGolden || !atk) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
const keys = new Set(unit.baseRangeKeys || []);
|
||
let n = 0;
|
||
for (const a of battle.allies()) if (a !== unit && isOp(a) && keys.has(a.tileR * COLS + a.tileC)) n++;
|
||
if (n >= need) battle.addBuff(unit, { key: 'cetsyr:module', duration: AURA_DUR, mods: { atkPct: atk } });
|
||
});
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 铃兰 — S3 狐火渺然 (27/29 s): no attacks, skill range, every enemy in range is sluggish, allies in range heal 9/11 %
|
||
// ATK per second, T2 ×scale_delta_to_one. T1 技力光环·辅助: Supporters +0.4 SP/s (highest wins).
|
||
// T2 画地为牢: sluggish enemies in range also take +20 % damage for the same time. Module (elite): +0.2 SP/s with an
|
||
// enemy in range.
|
||
chess_char_5_10_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
|
||
const boost = num(bb.scale_delta_to_one, 1);
|
||
const healRatio = num(bb['attack@atk_to_hp_recovery_ratio']);
|
||
const fragile = num(t1.damage_scale, 1) - 1;
|
||
return {
|
||
// S1 全力以赴 (duration): ATK +, ASPD +. S2 儿时的舞乐 (toggle, 持续时间无限): ATK +, 2 targets.
|
||
skills: lazySkills({
|
||
skchr_lisa_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }),
|
||
skchr_lisa_2: () => ({ kind: 'toggle', mods: mods({ atkPct: num(bb.atk) }), targeting: { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) } }),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: { noAttack: true },
|
||
onStart({ unit }) { unit.mem.foxSlow = AURA_IV; unit.mem.foxHeal = 0; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.foxSlow += dt;
|
||
if (unit.mem.foxSlow >= AURA_IV) {
|
||
unit.mem.foxSlow = 0;
|
||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.applyStatus(e, 'sluggish', { duration: AURA_DUR, source: unit });
|
||
}
|
||
unit.mem.foxHeal += dt;
|
||
if (unit.mem.foxHeal >= 1) {
|
||
unit.mem.foxHeal -= 1;
|
||
if (!(healRatio > 0)) return;
|
||
for (const a of battle.alliesInGrid(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk * healRatio, { aura: true });
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), (a) => isOp(a) && a.def?.profession === 'SUPPORT'); } },
|
||
{ install(battle, unit) { // 画地为牢 — PRTS 备注: an aura; every enemy in her range that is 停顿 (whoever caused it)
|
||
// carries the talent's 脆弱 while it stays 停顿 and in range. It is the standard 脆弱 status (same-name: highest wins).
|
||
if (!(fragile > 0)) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
const v = fragile * (unit.skill?.active ? boost : 1);
|
||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) {
|
||
if (e.alive && e.findBuff('sluggish')) battle.applyStatus(e, 'fragile', { duration: AURA_DUR, value: v, source: unit });
|
||
}
|
||
});
|
||
} },
|
||
],
|
||
install(battle, unit) { // module
|
||
const sp = num(tm.sp_recovery_per_sec);
|
||
if (!chess?.isGolden || !(sp > 0)) return;
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) battle.addBuff(unit, { key: 'lisa:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
|
||
});
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is
|
||
// injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals.
|
||
// Module (elite): heals on allies below 50 % ×1.15.
|
||
// S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP):
|
||
// that attack is instead a heal of the lowest such ally for heal_scale × ATK.
|
||
// S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal
|
||
// attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %.
|
||
// Module GUA-Y (elite): damage taken −15 %.
|
||
chess_char_5_11_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const sid = selectedId(chess, def);
|
||
const grid = skillGrid(chess, def) ?? [[0, 0]];
|
||
const zone = (battle, unit) => {
|
||
const at = unit.tileR * COLS + unit.tileC;
|
||
if (unit.mem.sariaAt !== at) { unit.mem.sariaAt = at; unit.mem.sariaKeys = new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)); }
|
||
const keys = unit.mem.sariaKeys;
|
||
return battle.allies().filter((a) => keys.has(a.tileR * COLS + a.tileC) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal)));
|
||
};
|
||
const healable = (a, unit) => a.kind !== 'device' && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal));
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_demkni_1: () => ({
|
||
// 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次
|
||
// 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic
|
||
// rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮,
|
||
// no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack)
|
||
kind: instantKind(chess, def),
|
||
trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP },
|
||
targeting: { rangeGrid: grid },
|
||
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
|
||
onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); },
|
||
}),
|
||
skchr_demkni_3: () => ({
|
||
kind: 'duration',
|
||
onStart({ unit }) { unit.mem.calcAcc = 0; unit.mem.calcAura = AURA_IV; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.calcAura += dt;
|
||
if (unit.mem.calcAura >= AURA_IV - 1e-9) {
|
||
unit.mem.calcAura = 0;
|
||
const m = { artsTakenMul: num(bb['demkni_s_3.damage_scale'], 1), moveMul: Math.max(0, 1 + num(bb['demkni_s_3.move_speed'])) };
|
||
for (const e of battle.unitsInGrid(unit, grid, { side: 'enemy' })) battle.addBuff(e, { key: 'saria:calcify', duration: AURA_DUR, visible: true, mods: m });
|
||
}
|
||
unit.mem.calcAcc += dt;
|
||
if (unit.mem.calcAcc < 1 - 1e-9) return;
|
||
unit.mem.calcAcc -= 1;
|
||
const amt = unit.s.atk * num(bb['attack@heal_scale']);
|
||
if (amt > 0) for (const a of battle.unitsInGrid(unit, grid, { side: 'ally' })) if (healable(a, unit) && a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt);
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'instant', heal: true, trigger: NEVER,
|
||
onStart({ battle, unit }) {
|
||
const amt = unit.s.atk * num(bb.heal_scale, 1);
|
||
for (const a of zone(battle, unit)) if (a.hp < a.s.maxHp - 1e-6) battle.heal(unit, a, amt);
|
||
battle.fx('healAoe', { x: unit.x, y: unit.y, id: unit.id, r: 2.5 });
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 莱茵充能护服
|
||
const iv = Math.max(1, num(t0.interval, 20)), max = Math.max(1, num(t0.max_stack_cnt, 5));
|
||
whileOn(battle, unit, 1, () => {
|
||
const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv));
|
||
const cur = unit.findBuff('saria:suit');
|
||
if (n > 0 && (!cur || cur.stacks !== n)) battle.addBuff(unit, { key: 'saria:suit', stacks: n, maxStacks: max, mods: mods({ atkPct: num(t0.atk), defPct: num(t0.def) }) });
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 精神回复
|
||
const sp = num(t1.sp);
|
||
if (!(sp > 0)) return;
|
||
battle.on('heal', (c) => {
|
||
if (c.source !== unit || c.opts?.regen || !(c.amount > 0) || !c.target.skill) return;
|
||
c.target.skill.gainSp(sp, 'talent');
|
||
}, { owner: unit, priority: -10 });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
|
||
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
|
||
lowHpHealUp(battle, unit, tb);
|
||
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 夕 — S1 工笔入化 (2 charges): next attack 180 % ATK with an expanded splash. T1 化境: a kill by 夕 or 小自在 → ATK +2 %
|
||
// (×15). T2 点睛: first attack after deploying summons 小自在 (25 s) on the target's tile (deployable ground).
|
||
// Module (elite): 攻击范围扩大.
|
||
// S2 泼墨淋漓 (duration): skill range, ATK +, ASPD +, hits every enemy in range (one hit each: no splash on top);
|
||
// arts damage ×damage_scale on enemies below hp_ratio. S3 写意胜形 (duration): skill range, BAT +0.4 s, prefers
|
||
// unblocked enemies, splash 1.7, ATK +; every attack summons 小自在 on the target's tile (deployable ground) or moves /
|
||
// refreshes the one already out (deployLimit 1: "召唤/刷新一个"), 25 s (the 点睛 token duration).
|
||
chess_char_5_12_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1);
|
||
const sid = selectedId(chess, def);
|
||
const tokenId = talentRec(chess, 1)?.tokenKey ?? (chess?.tokens || [])[0] ?? 'token_10015_dusk_drgn';
|
||
const tokenDur = num(t1['attack@tokenduration'], 25);
|
||
const freeGround = (battle, r, c) => battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c);
|
||
// 小自在 expiry follows tk.mem.duskUntil (a refresh pushes it back)
|
||
const expire = (battle, tk) => {
|
||
if (!tk.alive) return;
|
||
const left = num(tk.mem.duskUntil) - battle.time;
|
||
if (left > 1e-6) battle.after(left, () => expire(battle, tk), { owner: tk });
|
||
else battle.retreat(tk, { reason: 'expired', permanent: true });
|
||
};
|
||
/** Summon 小自在 on (r, c), or move / refresh hers when one is out. */
|
||
const summon = (battle, unit, r, c) => {
|
||
const mine = battle.allyUnits.find((x) => x.kind === 'token' && x.ownerUnit === unit && x.defId === tokenId && x.alive && x.deployed);
|
||
if (mine) {
|
||
if (mine.tileR !== r || mine.tileC !== c) {
|
||
const fromX = mine.x, fromY = mine.y;
|
||
if (!freeGround(battle, r, c) || !battle.relocate(mine, r, c)) return null;
|
||
battle.fx('teleport', { x: mine.x, y: mine.y, id: mine.id, fromX, fromY });
|
||
}
|
||
mine.mem.duskUntil = battle.time + tokenDur;
|
||
return mine;
|
||
}
|
||
if (!freeGround(battle, r, c)) return null;
|
||
const tk = battle.spawnToken(unit, tokenId, r, c);
|
||
if (!tk) return null;
|
||
tk.mem.duskUntil = battle.time + tokenDur;
|
||
battle.after(tokenDur, () => expire(battle, tk), { owner: tk });
|
||
battle.fx('summon', { x: tk.x, y: tk.y, id: tk.id, token: tokenId });
|
||
return tk;
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_dusk_2: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
|
||
targeting: skillRange(chess, def, { allInRange: true }),
|
||
attack: { splashRadius: 0 },
|
||
}),
|
||
skchr_dusk_3: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillRange(chess, def),
|
||
attack: { splashRadius: DUSK_S3_SPLASH },
|
||
}),
|
||
}),
|
||
skill: { kind: 'charges', attack: { atkScale: num(bb.atk_scale, 1), splashRadius: DUSK_SPLASH_RADIUS } },
|
||
talents: [
|
||
{ install(battle, unit) { // 化境
|
||
battle.on('kill', (c) => {
|
||
const k = c.killer;
|
||
if (!k || c.victim.side !== 'enemy' || !(k === unit || (k.kind === 'token' && k.ownerUnit === unit)) || !on(unit)) return;
|
||
battle.addBuff(unit, { key: 'dusk:realm', refresh: 'stack', maxStacks: Math.max(1, num(t0.max_stack_cnt, 15)), mods: { atkPct: num(t0.atk) } });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 点睛
|
||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.duskSummoned = false; }, { owner: unit });
|
||
battle.on('attack', (c) => {
|
||
if (c.attacker !== unit || unit.mem.duskSummoned) return;
|
||
const t = c.targets.find((x) => x.side === 'enemy');
|
||
if (!t) return;
|
||
unit.mem.duskSummoned = true;
|
||
// "在目标位置(可部署地面)召唤": only on the target's own tile when it is free deployable ground — PRTS 备注:
|
||
// otherwise nothing is summoned, and the talent is spent for this deployment either way
|
||
summon(battle, unit, Math.round(t.y), Math.round(t.x));
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
moduleRangeUp(battle, unit, chess);
|
||
if (sid === 'skchr_dusk_2') {
|
||
const cut = num(bb.hp_ratio, HALF_HP), mul = num(bb.damage_scale, 1);
|
||
battle.on('hit', (c) => {
|
||
if (c.source !== unit || !unit.skill?.active || c.dmg.type !== 'arts' || !c.target || c.target.side !== 'enemy') return;
|
||
if (c.target.hpRatio < cut) c.dmg.mul *= mul;
|
||
}, { owner: unit });
|
||
}
|
||
if (sid === 'skchr_dusk_3') {
|
||
// 优先攻击未阻挡的敌人
|
||
battle.on('beforeAttack', (c) => {
|
||
if (c.attacker !== unit || !unit.skill?.active || !c.targets.length || !c.targets[0].blockedBy) return;
|
||
const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
|
||
sortEnemyTargets(battle, unit, list, null);
|
||
const alt = list.find((e) => !e.blockedBy);
|
||
if (alt) c.targets = [alt, ...c.targets.slice(1).filter((x) => x !== alt)];
|
||
}, { owner: unit, priority: 10 });
|
||
battle.on('attack', (c) => {
|
||
if (c.attacker !== unit || !unit.skill?.active) return;
|
||
const t = c.targets.find((x) => x.side === 'enemy');
|
||
if (t) summon(battle, unit, Math.round(t.y), Math.round(t.x));
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 归溟幽灵鲨 — dollkeeper. S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1; afterwards she counts as knocked out
|
||
// (→ substitute, or death if already one). T1 拥抱自我: the substitute slows nearby enemies −40 % and deals 40 % ATK
|
||
// arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only). T2 阿戈尔的深邃: Abyssal Hunters in the team
|
||
// max HP +20 %. Module (elite): substitute ATK +15 %.
|
||
// S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio,
|
||
// ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio
|
||
// is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP.
|
||
// Module PUM-Y (elite): substitute max HP +20 %.
|
||
chess_char_5_13_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const sid = selectedId(chess, def);
|
||
const aroundGrid = chess?.trait?.rangeGrid ?? [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_ghost2_1: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk) }),
|
||
onStart({ battle, unit }) {
|
||
const t = battle.unitsInGrid(unit, skillGrid(chess, def) ?? aroundGrid, { side: 'ally' }).filter((a) => a !== unit && isOp(a) && a.hp > 0)
|
||
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0];
|
||
if (!t) return;
|
||
const mine = unit.hpRatio, theirs = t.hpRatio;
|
||
unit.hp = Math.max(1, Math.min(unit.s.maxHp, unit.s.maxHp * theirs));
|
||
t.hp = Math.max(1, Math.min(t.s.maxHp, t.s.maxHp * mine));
|
||
battle.fx('hpShare', { x: t.x, y: t.y, id: unit.id, to: t.id });
|
||
},
|
||
}),
|
||
skchr_ghost2_3: () => ({
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), hpPct: num(bb.max_hp), batPct: batPct(bb.base_attack_time, chess) }),
|
||
attack: {
|
||
hitAllBlocked: true,
|
||
onEachHit({ battle, unit, target, kind }) {
|
||
if (kind !== 'main' || !target || target.side !== 'enemy') return;
|
||
if (unit.mem.ghostHeavy?.get(target)) {
|
||
if (target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb['attack@atk_scale_ex']), type: 'phys', isSkill: true, tags: ['skill', 'ghost2Weight'] });
|
||
} else if (unit.alive) battle.loseHp(unit, unit.s.maxHp * num(bb['attack@hp_ratio']), { source: unit });
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
|
||
onEnd({ battle, unit, reason }) {
|
||
if (reason === 'death' || !unit.alive || !unit.deployed) return;
|
||
battle.fx('knockout', { x: unit.x, y: unit.y, id: unit.id });
|
||
battle.loseHp(unit, unit.hp + 1, { source: unit });
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 拥抱自我
|
||
const slow = num(t0.move_speed), scale = num(t0.atk_scale);
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
if (!unit.trait.doll || !slow) return;
|
||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
|
||
});
|
||
whileOn(battle, unit, 1, () => {
|
||
if (!unit.trait.doll || !(scale > 0)) return;
|
||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 阿戈尔的深邃
|
||
const hp = num(t1.max_hp);
|
||
if (!hp) return;
|
||
battle.on('battleStart', () => {
|
||
for (const a of battle.getPlayer(unit.ownerId)?.units ?? []) if (isOp(a) && isAbyssal(a)) battle.addBuff(a, { key: 'ghost2:abyss', mods: { hpPct: hp }, persist: true, allowDead: true });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// S2: HP never below 1 while it runs
|
||
if (!sid || sid === 'skchr_ghost2_2') battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 });
|
||
if (sid === 'skchr_ghost2_3') { // S3: 生命比例高于或等于自身 — judged when the attack starts
|
||
battle.on('beforeAttack', (c) => {
|
||
if (c.attacker !== unit || !unit.skill?.active) return;
|
||
unit.mem.ghostHeavy = new Map(c.targets.filter(Boolean).map((t) => [t, t.hpRatio >= unit.hpRatio - 1e-9]));
|
||
}, { owner: unit, priority: -100 });
|
||
}
|
||
const hpUp = num(tb.max_hp); // PUM-Y: 替身…生命值提升
|
||
if (hpUp) whileOn(battle, unit, 0.2, () => { if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:moduleY', duration: 0.3, mods: { hpPct: hpUp } }); });
|
||
const atk = num(tb.atk);
|
||
if (atk) {
|
||
whileOn(battle, unit, 0.2, () => {
|
||
if (unit.trait.doll) battle.addBuff(unit, { key: 'ghost2:module', duration: 0.3, mods: { atkPct: atk } });
|
||
});
|
||
}
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 凛御银灰 — S2 御敌的锋锐 (2 charges; PRTS 备注 "可对空"): the 6 front-most enemies in the skill range take 260 % ATK
|
||
// phys, cold + reveal for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right
|
||
// side guard/caster/sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when
|
||
// they redeploy (≤2).
|
||
// T1 开放性开局: ops left of the eye and himself: +4 SP at deployment, redeploy time −20 %.
|
||
// T2 雪境先驱: Kjerag ops freeze-immune, DEF +60, 1.5 % max HP/s regen; doubled after 15 s on the field.
|
||
// The eye follows the selected skill (its overrideTokenKey: S1 eagle1 cost 16, S2 eagle2 14, S3 eagle3 19).
|
||
// S1 周旋的谋略 (instant): +cost DP at once; the waiting op nearest to the eye gets −[deck].cost redeploy cost (same pick
|
||
// as S2) and a barrier of [deck].shield × his max HP when it redeploys.
|
||
// S3 变革已至 (duration 48 s): skill range; enemies in it lose 隐匿; every attack hits every enemy of the range on the
|
||
// target's line (his facing) for bird_atk_scale × ATK phys + 脆弱 (damage_scale − 1, weak[limit] s [ASSUMED duration]);
|
||
// +[start_cost] DP at once, then +[cost].cost DP every [cost].interval s; the first activation of the battle swaps the
|
||
// base costs of the most (guard/caster/sniper first) and least expensive waiting ops. "风雪之眼变为可部署" is not
|
||
// modelled (nobody deploys by hand in the auto battle).
|
||
chess_char_5_14_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1);
|
||
const grid = skillGrid(chess, def) ?? [[0, 0], [0, 1]];
|
||
const eyeId = chess?.skill?.overrideTokenKey ?? talentRec(chess, 0)?.tokenKey ?? 'token_10057_svash2_eagle2';
|
||
const n = Math.max(1, num(bb.max_target, 6)), maxStacks = Math.max(1, num(bb.max_stack_cnt, 2));
|
||
const PREF = ['WARRIOR', 'CASTER', 'SNIPER'];
|
||
/** Waiting (knocked-out) ops of his player split around the eye cost, and the one nearest to the eye (right side first). */
|
||
const waitingArea = (battle, unit) => {
|
||
const eye = unit.mem.eyeCost ?? 0;
|
||
const waiting = battle.allyUnits.filter((a) => isOp(a) && a !== unit && a.ownerId === unit.ownerId && !a.alive && !a.removed);
|
||
const right = waiting.filter((a) => a.base.cost >= eye).sort((a, b) => a.base.cost - b.base.cost || a.id - b.id);
|
||
const left = waiting.filter((a) => a.base.cost < eye).sort((a, b) => b.base.cost - a.base.cost || a.id - b.id);
|
||
const pick = right.find((a) => PREF.includes(a.def?.profession)) ?? right[0] ?? left[0] ?? null;
|
||
return { waiting, right, left, pick };
|
||
};
|
||
const cutCost = (a, v) => {
|
||
if (!a || !(v > 0)) return;
|
||
a.mem.svashCostBase ??= a.base.cost;
|
||
a.base.cost = Math.max(0, a.base.cost - v);
|
||
};
|
||
const slash = (battle, caster, atk) => {
|
||
// PRTS 备注: the slash ignores 隐匿 and hits flying enemies (and its reveal makes them targetable afterwards)
|
||
for (const e of enemiesInGrid(battle, caster, grid, n, { ignoreStealth: true })) {
|
||
battle.dealDamage(caster, e, { amount: atk * num(bb.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'svash2'] });
|
||
if (!e.alive) continue;
|
||
battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: caster });
|
||
battle.applyStatus(e, 'reveal', { duration: num(bb.cold), source: caster });
|
||
}
|
||
battle.fx('aoe', { x: caster.x + caster.fwd[1] * 1.5, y: caster.y + caster.fwd[0] * 1.5, id: caster.id, r: 2, skill: 'svash2' });
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
|
||
skchr_svash2_1: () => ({
|
||
kind: instantKind(chess, def),
|
||
trigger: 'SP_FULL',
|
||
onStart({ battle, unit }) {
|
||
battle.addDp(unit.ownerId, num(bb.cost));
|
||
const { pick } = waitingArea(battle, unit);
|
||
cutCost(pick, num(bb['svash2_s_1[deck].cost']));
|
||
if (pick) pick.mem.svashShield = unit.s.maxHp * num(bb['svash2_s_1[deck].shield']);
|
||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'svash2Plan' });
|
||
},
|
||
}),
|
||
skchr_svash2_3: () => ({
|
||
kind: 'duration',
|
||
targeting: skillRange(chess, def),
|
||
attack: {
|
||
atkScale: num(bb.bird_atk_scale, 1),
|
||
onEachHit({ battle, unit, target }) {
|
||
if (!target || !target.alive || target.side !== 'enemy') return;
|
||
const v = num(bb.damage_scale, 1) - 1;
|
||
if (v > 0) battle.applyStatus(target, 'fragile', { duration: num(bb['weak[limit]'], 2), value: v, source: unit });
|
||
},
|
||
},
|
||
onStart({ battle, unit }) {
|
||
battle.addDp(unit.ownerId, num(bb['svash2_s_3[start_cost].cost']));
|
||
unit.mem.svashDpAcc = 0;
|
||
unit.mem.svashReveal = AURA_IV;
|
||
if (!unit.mem.svashSwapped) { // 首次开启时交换…基础部署费用
|
||
unit.mem.svashSwapped = true;
|
||
const { waiting } = waitingArea(battle, unit);
|
||
const baseCost = (a) => a.mem.svashCostBase ?? a.base.cost;
|
||
const byCost = waiting.slice().sort((a, b) => baseCost(b) - baseCost(a) || a.id - b.id);
|
||
const hi = byCost.find((a) => PREF.includes(a.def?.profession)) ?? byCost[0];
|
||
const lo = byCost.filter((a) => a !== hi).pop();
|
||
if (hi && lo && baseCost(hi) !== baseCost(lo)) {
|
||
const ch = baseCost(hi), cl = baseCost(lo);
|
||
for (const [a, v] of [[hi, cl], [lo, ch]]) {
|
||
if (a.mem.svashCostBase != null) { a.base.cost = Math.max(0, v - (a.mem.svashCostBase - a.base.cost)); a.mem.svashCostBase = v; } else a.base.cost = v;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const iv = Math.max(0.1, num(bb['svash2_s_3[cost].interval'], 2));
|
||
unit.mem.svashDpAcc += dt;
|
||
while (unit.mem.svashDpAcc >= iv - 1e-9) { unit.mem.svashDpAcc -= iv; battle.addDp(unit.ownerId, num(bb['svash2_s_3[cost].cost'], 1)); }
|
||
unit.mem.svashReveal += dt;
|
||
if (unit.mem.svashReveal < AURA_IV - 1e-9) return;
|
||
unit.mem.svashReveal = 0;
|
||
for (const e of enemiesInGrid(battle, unit, grid, 0, { ignoreStealth: true })) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'charges', // DEFAULT (data): cast right before an attack on an enemy in his initial (melee) range
|
||
onStart({ battle, unit }) {
|
||
slash(battle, unit, unit.s.atk);
|
||
// cost cut: nearest to the eye, right side guard/caster/sniper preferred
|
||
const { left, pick } = waitingArea(battle, unit);
|
||
cutCost(pick, num(bb.cost));
|
||
for (const a of left) a.mem.svashCasts = Math.min(maxStacks, num(a.mem.svashCasts) + 1);
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 开放性开局
|
||
const sp = num(t0.sp), mul = num(t0.respawn_time, 1);
|
||
const left = (a) => isOp(a) && a.ownerId === unit.ownerId && (a === unit || a.base.cost < num(unit.mem.eyeCost));
|
||
battle.on('battleStart', () => {
|
||
if (!on(unit) || !(sp > 0)) return;
|
||
for (const a of battle.allies(unit.ownerId)) if (left(a) && a.skill) a.skill.gainSp(sp, 'talent');
|
||
}, { owner: unit });
|
||
battle.on('deploy', (c) => {
|
||
const a = c.unit;
|
||
if (c.initial || !left(a) || !(a === unit || on(unit))) return;
|
||
if (sp > 0 && a.skill) a.skill.gainSp(sp, 'talent');
|
||
}, { owner: unit });
|
||
battle.on('death', (c) => {
|
||
const a = c.unit;
|
||
if (!left(a) || a.removed || !(a === unit || on(unit)) || !Number.isFinite(a.respawnAt) || !(mul > 0 && mul < 1)) return;
|
||
a.respawnAt = a.deathAt + (a.respawnAt - a.deathAt) * mul;
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 雪境先驱
|
||
const defv = num(t1.def), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), after = num(t1.interval, 15);
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
const m = battle.time - unit.deployedAt >= after - 1e-9 ? 2 : 1;
|
||
for (const a of battle.allies()) {
|
||
if (isOp(a) && isKjerag(a)) battle.addBuff(a, { key: 'svash2:snow', duration: AURA_DUR, mods: mods({ defFlat: defv * m, hpRegenRatio: regen * m }) });
|
||
}
|
||
});
|
||
battle.on('beforeStatus', (c) => {
|
||
if (c.status === 'freeze' && c.target.side === 'ally' && c.target.findBuff('svash2:snow')) c.cancel = true;
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
const tok = battle.tokenDef(eyeId, unit); // the owner's skill variant (DESIGN §16)
|
||
unit.mem.eyeCost = num(tok?.stats?.cost, 14);
|
||
if (selectedId(chess, def) === 'skchr_svash2_3') {
|
||
// S3: every attack hits the enemies of the skill range on the target's line (his facing frame): the line of
|
||
// the target's position; another enemy is on it when its body is (a huge one: any tile — body.js)
|
||
battle.on('beforeAttack', (c) => {
|
||
if (c.attacker !== unit || !unit.skill?.active || !c.targets.length) return;
|
||
const main = c.targets[0];
|
||
const lat = (r, cc) => toLocal(r - unit.tileR, cc - unit.tileC, unit.dir)[0];
|
||
const l0 = lat(Math.round(main.y), Math.round(main.x));
|
||
const onLine = (e) => bodyKeys(e).some((k) => lat(Math.floor(k / COLS), k % COLS) === l0);
|
||
const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && onLine(e));
|
||
if (line.length) c.targets = [main, ...line];
|
||
}, { owner: unit, priority: 10 });
|
||
}
|
||
// waiting-area effects resolve when the op (re)deploys
|
||
battle.on('deploy', (c) => {
|
||
const a = c.unit;
|
||
if (!isOp(a) || a.ownerId !== unit.ownerId) return;
|
||
if (a.mem.svashCostBase != null) { a.base.cost = a.mem.svashCostBase; a.mem.svashCostBase = null; }
|
||
if (num(a.mem.svashShield) > 0 && !c.initial) { // S1: 部署后获得…屏障
|
||
battle.addBuff(a, { key: 'svash2:barrier', shield: a.mem.svashShield, visible: true });
|
||
a.mem.svashShield = 0;
|
||
}
|
||
const casts = num(a.mem.svashCasts);
|
||
if (casts > 0 && !c.initial) {
|
||
a.mem.svashCasts = 0;
|
||
for (let i = 0; i < casts; i++) slash(battle, a, a.s.atk);
|
||
}
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies
|
||
// around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts
|
||
// along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range.
|
||
// T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out.
|
||
// S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for
|
||
// projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal
|
||
// hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active
|
||
// (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s
|
||
// the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take
|
||
// atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps.
|
||
chess_char_5_15_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
|
||
const sid = selectedId(chess, def);
|
||
const baseDur = num(bb.projectile_delay_time, num(bb.remaining_time, 12));
|
||
const r0 = num(bb.projectile_range, 1.1), grow = num(bb.value), speed = num(bb.projectile_move_speed);
|
||
const healMul = Math.max(0, num(bb.heal_scale, 1));
|
||
const extend = (battle, unit) => (battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0);
|
||
// S3 ramp: value after `steps` intervals, clamped towards the max ("效果逐渐提升…15秒后达到最大")
|
||
const ramp = (base, per, max, steps) => {
|
||
const v = num(base) + num(per) * steps;
|
||
return num(max) < num(base) ? Math.max(num(max), v) : Math.min(num(max, v), v);
|
||
};
|
||
/** One tick (AURA_IV) of an S1 guard zone / S3 sea zone. */
|
||
const tickSpecial = (battle, unit, z, seaHits) => {
|
||
if (z.type === 'guard') {
|
||
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
|
||
const allies = battle.alliesFor(unit).filter(inZone);
|
||
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
|
||
if (z.acc >= 1 - 1e-9) {
|
||
z.acc -= 1;
|
||
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio);
|
||
if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
|
||
}
|
||
return;
|
||
}
|
||
// sea: follows its operator while it stands on the field; the first burn lands with the unit, then one per
|
||
// `interval` (so the 15th step — the max — is reached 15 s after the throw, for the debuff and the damage alike)
|
||
if (z.anchor && z.anchor.alive && z.anchor.deployed) { z.x = z.anchor.x; z.y = z.anchor.y; }
|
||
const iv = Math.max(0.1, num(bb.interval, 1));
|
||
const steps = Math.min(Math.max(0, num(bb.max_stack_cnt, 15)), Math.floor((z.t - AURA_IV + 1e-9) / iv));
|
||
const foes = battle.enemiesInRadius(z.x, z.y, RING1);
|
||
for (const e of foes) seaHits.set(e, Math.max(seaHits.get(e) ?? -1, steps));
|
||
if (z.acc >= iv - 1e-9) {
|
||
z.acc -= iv;
|
||
const scale = ramp(bb.atk_scale, bb.atk_scale_per_interval, bb.max_atk_scale, steps);
|
||
z.burn = { foes, scale }; // dealt after the debuffs of this tick are on
|
||
}
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_thorn2_1: () => ({
|
||
kind: instantKind(chess, def),
|
||
onStart({ battle, unit }) {
|
||
const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0];
|
||
if (!t) return;
|
||
const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) };
|
||
(unit.mem.zones ??= []).push(z);
|
||
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur });
|
||
},
|
||
}),
|
||
skchr_thorn2_3: () => ({
|
||
kind: instantKind(chess, def),
|
||
onStart({ battle, unit }) {
|
||
const ops = battle.allies(unit.ownerId).filter((a) => isOp(a) && a.hp > 0)
|
||
.sort((a, b) => a.s.blockCnt - b.s.blockCnt || dist(a, unit) - dist(b, unit) || a.id - b.id)
|
||
.slice(0, Math.max(1, num(bb.max_target_token, 3)));
|
||
const dur = num(bb.projectile_delay_time, 19) + extend(battle, unit);
|
||
for (const a of ops) {
|
||
(unit.mem.zones ??= []).push({ type: 'sea', anchor: a, x: a.x, y: a.y, t: 0, acc: Math.max(0.1, num(bb.interval, 1)) - AURA_IV, dur });
|
||
battle.fx('zone', { x: a.x, y: a.y, id: unit.id, r: RING1, duration: dur });
|
||
}
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant',
|
||
onStart({ battle, unit }) {
|
||
const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
|
||
sortEnemyTargets(battle, unit, list, null);
|
||
let t = list.find((e) => !e.isFlying) ?? list[0];
|
||
if (!t) {
|
||
// PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range
|
||
let best = null;
|
||
for (const k of unit.rangeKeys || []) {
|
||
const r = Math.floor(k / COLS), c = k % COLS;
|
||
if (!battle.grid.inRect(r, c)) continue;
|
||
const [lat, d] = toLocal(r - unit.tileR, c - unit.tileC, unit.dir);
|
||
if (lat !== 0) continue;
|
||
if (d > 0 && (!best || d > best.d)) best = { d, x: c, y: r };
|
||
}
|
||
if (!best) return;
|
||
t = best;
|
||
}
|
||
const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0;
|
||
// it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向")
|
||
const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1;
|
||
const z = { x: t.x, y: t.y, vx: (dx / len) * speed, vy: (dy / len) * speed, t: 0, acc: 0, dur: baseDur + extra };
|
||
(unit.mem.zones ??= []).push(z);
|
||
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: r0, duration: z.dur });
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { permBuff(battle, unit, 'thorn2:mind', { atkPct: num(t0.atk) }); } },
|
||
{ install(battle, unit) { // 视界
|
||
const cnt = num(t1.cnt, 6);
|
||
const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra);
|
||
whileOn(battle, unit, AURA_IV, () => {
|
||
for (const a of battle.alliesFor(unit)) {
|
||
const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0);
|
||
if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
|
||
}
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden) continue;
|
||
const v = foe + (straightRun(battle, Math.round(e.y), Math.round(e.x)) >= cnt ? foeX : 0);
|
||
if (v) battle.addBuff(e, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
|
||
}
|
||
});
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
const sp = num(tm.sp_recovery_per_sec);
|
||
if (sid === 'skchr_thorn2_3') { // S3 被动效果:攻击范围扩大 (her own range, so also the DEFAULT trigger range)
|
||
const g = skillGrid(chess, def);
|
||
if (g) { unit.rangeGrid = g; battle.refreshRange(unit); }
|
||
}
|
||
// alchemy units keep working after she falls (they are already thrown)
|
||
battle.every(AURA_IV, () => {
|
||
const zones = unit.mem.zones;
|
||
if (!zones || !zones.length) return;
|
||
const R = battle.rect;
|
||
const seaHits = new Map();
|
||
for (const z of zones) {
|
||
z.t += AURA_IV;
|
||
z.acc += AURA_IV;
|
||
if (z.type) { tickSpecial(battle, unit, z, seaHits); continue; }
|
||
z.x = Math.max(R.c0, Math.min(R.c1, z.x + z.vx * AURA_IV));
|
||
z.y = Math.max(R.r0, Math.min(R.r1, z.y + z.vy * AURA_IV));
|
||
const r = r0 + grow * z.t;
|
||
const foes = battle.enemiesInRadius(z.x, z.y, r).filter((e) => !e.isFlying);
|
||
for (const e of foes) battle.addBuff(e, { key: 'thorn2:rot', duration: AURA_DUR, mods: { healingTakenMul: healMul } });
|
||
if (z.acc >= 1 - 1e-9) {
|
||
z.acc -= 1;
|
||
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
|
||
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr);
|
||
if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
|
||
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t) });
|
||
}
|
||
}
|
||
for (const [e, steps] of seaHits) { // S3 debuff: 不叠加 — the strongest zone wins
|
||
if (!e.alive) continue;
|
||
battle.addBuff(e, { key: 'thorn2:sea', duration: AURA_DUR, visible: true, mods: mods({
|
||
atkPct: ramp(bb.atk, bb.atk_per_interval, bb.max_atk, steps),
|
||
defPct: ramp(bb.def, bb.def_per_interval, bb.max_def, steps),
|
||
resMul: 1 + ramp(bb.magic_resistance, bb.magic_resistance_per_interval, bb.max_magic_resistance, steps),
|
||
}) });
|
||
}
|
||
for (const z of zones) {
|
||
if (!z.burn) continue;
|
||
for (const e of z.burn.foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * z.burn.scale, type: 'arts', isSkill: true, tags: ['skill', 'mySea'] });
|
||
z.burn = null;
|
||
}
|
||
unit.mem.zones = zones.filter((z) => z.t < z.dur - 1e-9);
|
||
if (chess?.isGolden && sp > 0 && on(unit) && unit.mem.zones.length) battle.addBuff(unit, { key: 'thorn2:module', duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 白面鸮 — ringhealer. S2 脑啡肽 (33/36 s): skill range, BAT −1.8/−1.9 s. T1 技力光环: all allies +0.3 SP/s (highest
|
||
// wins). Module (elite): 攻击范围扩大.
|
||
chess_char_5_16_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0);
|
||
return {
|
||
skill: {
|
||
kind: 'duration', heal: true,
|
||
mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
},
|
||
talents: [{ install(battle, unit) { spAura(battle, unit, num(t0.sp_recovery_per_sec), () => true); } }],
|
||
install(battle, unit) { moduleRangeUp(battle, unit, chess); },
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 山 — S2 横扫架势 (stance toggle): DEF −30 %, range = own tile, ATK +35 %, block +1, hits every blocked enemy, 4 % max HP/s
|
||
// regen. T1 巨力重拳: 20 % chance ×1.6 ATK and target ATK −15 % for 3 s. T2 强壮肉体: DEF +10 %, 15 % phys dodge.
|
||
// Module (elite): ASPD +10 above 50 % HP.
|
||
// S1 左勾扫拳 (instant, attack SP): next attack atk_scale × ATK on max_target enemies. S3 震地碎岩击 (duration): skill
|
||
// range, BAT +0.7 s, ATK +, double hits on up to attack@max_target enemies, each pushed (中等力度); T1 chance →
|
||
// talent@prob. Module FGT-X (elite): 15 % physical dodge.
|
||
chess_char_5_17_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const sid = selectedId(chess, def);
|
||
const critScale = num(t0.atk_scale, 1);
|
||
const critProb = (unit) => (sid === 'skchr_f12yin_3' && unit.skill?.active ? num(bb['talent@prob'], num(t0.prob)) : num(t0.prob));
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_f12yin_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }),
|
||
skchr_f12yin_3: () => ({
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 3)) }),
|
||
attack: {
|
||
hits: 2,
|
||
onEachHit({ battle, unit, target, kind }) {
|
||
if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return;
|
||
// "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by
|
||
// the official 力度 − 重量 distance (Battle.push)
|
||
battle.push(target, num(bb['attack@force'], 1), { from: unit });
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'toggle',
|
||
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: { hitAllBlocked: true },
|
||
},
|
||
trait: {
|
||
dmgMul: (b, u) => (u.mem.crit ? critScale : 1),
|
||
afterHit: (b, u, t) => {
|
||
if (!u.mem.crit || !t || !t.alive || !num(t0.atk)) return;
|
||
b.addBuff(t, { key: 'f12yin:punch', duration: num(t0.duration, 3), mods: { atkPct: num(t0.atk) }, visible: true, status: 'weaken' });
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 巨力重拳: one roll per attack
|
||
battle.on('beforeAttack', (c) => {
|
||
if (c.attacker !== unit) return;
|
||
const p = critProb(unit);
|
||
unit.mem.crit = p > 0 && battle.rng.chance(p);
|
||
if (unit.mem.crit) battle.fx('crit', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { permBuff(battle, unit, 'f12yin:body', { defPct: num(t1.def), dodgePhys: num(t1.prob) }); } },
|
||
],
|
||
install(battle, unit) {
|
||
// FGT-X: 拥有15%的物理闪避 (an own dodge source: rolls independently of 强壮肉体)
|
||
if (num(tb.prob) > 0) permBuff(battle, unit, 'f12yin:moduleX', { dodgePhys: num(tb.prob) });
|
||
const aspd = num(tb.attack_speed);
|
||
if (!aspd) return;
|
||
whileOn(battle, unit, 0.2, () => { if (unit.hpRatio > HALF_HP) battle.addBuff(unit, { key: 'f12yin:module', duration: 0.3, mods: { aspd } }); });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 百炼嘉维尔 — S2 链锯强袭 (33/36 s): skill range, ATK/DEF +; hits on unblocked enemies drag them in front of her.
|
||
// T1 战地巨斧: ATK/DEF +10 %, +4 % per extra blocked enemy. T2 医学背景: healing received +20 % (+40 % below 50 %).
|
||
// Module (elite): ×1.1 vs blocked enemies.
|
||
chess_char_5_18_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||
const force = num(bb['attack@force'], 1);
|
||
const blockedMul = num(tb.atk_scale);
|
||
return {
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: {
|
||
onHit({ battle, unit, target }) {
|
||
if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return;
|
||
battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront)
|
||
},
|
||
},
|
||
},
|
||
trait: blockedMul > 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedMul : 1) } : undefined,
|
||
talents: [
|
||
{ install(battle, unit) { // 战地巨斧
|
||
const apply = () => {
|
||
const extra = Math.max(0, unit.blocking.length - 1);
|
||
if (unit.mem.axeN === extra && unit.findBuff('gvial2:axe')) return;
|
||
unit.mem.axeN = extra;
|
||
battle.addBuff(unit, { key: 'gvial2:axe', mods: mods({ atkPct: num(t0.atk) + num(t0.atk_add) * extra, defPct: num(t0.def) + num(t0.def_add) * extra }) });
|
||
};
|
||
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.axeN = -1; apply(); } }, { owner: unit });
|
||
whileOn(battle, unit, 0.2, apply);
|
||
} },
|
||
{ install(battle, unit) { // 医学背景
|
||
battle.on('heal', (c) => {
|
||
if (c.target !== unit) return;
|
||
c.amount *= unit.hpRatio < num(t1.hp_ratio, 0.5) ? num(t1.heal_scale_2, 1) : num(t1.heal_scale_1, 1);
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 玛恩纳 — librator (ramp to +200 % ATK while idle). S3 未照耀的荣光 (26 s, CUSTOM_RANGE): skill range, trait ×2, 5
|
||
// targets at 125/150 % ATK phys, air units too (PRTS 备注 "※可对空"; player report B4 after 0.1.0: on a flying wave the
|
||
// cast hit nothing for 26 s); enemies in range take +10/11 % of his ATK true damage from every Kazimierz attack. Per
|
||
// PRTS 备注 the trait bonus drops by 10 % (per_kill_reduce, absolute: +400 % → +390 %) for each enemy knocked out by
|
||
// his own attack (or the damage it carries — not 无动于衷's reflection, not a mark another operator's attack set off),
|
||
// settled after that attack, never below +0 %. T1 游侠: ×1.1 ATK on attacks (×1.15 and −15 % damage taken with ≥3
|
||
// enemies around). T2 无动于衷: taunt +1, Kazimierz ops reflect 15 % of his ATK as true damage when attacked.
|
||
// S1 未声张的怒火 (duration, SEARCH): attacks attack@atk_scale × ATK, DEF +. S2 未宽解的悲哀 (duration): skill range,
|
||
// BAT +0.3 s, attacks attack@atk_scale × ATK twice; a kill of his own attacks during the skill keeps the trait ramp
|
||
// when it ends. S1 / S2 have no 对空 note: ground only, like his trait.
|
||
chess_char_5_19_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1);
|
||
const sid = selectedId(chess, def);
|
||
const up = num(bb.trait_up, 1), perKill = num(bb.per_kill_reduce);
|
||
// S3: the trait bonus = ramp × trait_up + per_kill_reduce × kills, ≥ 0 — the trait's own ramp buff stays, the
|
||
// difference goes into `mlynar:traitUp` (negative once the kills take the bonus below the ramp)
|
||
const applyUp = (battle, unit) => {
|
||
const ramp = num(unit.trait.ramp);
|
||
const extra = unit.mem.mlyUp ? Math.max(0, ramp * up + perKill * num(unit.mem.mlyKills)) - ramp : 0;
|
||
if (extra) battle.addBuff(unit, { key: 'mlynar:traitUp', mods: { atkPct: extra } });
|
||
else battle.removeBuff(unit, 'mlynar:traitUp');
|
||
};
|
||
/**
|
||
* "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy whose
|
||
* last damage from him came from his attack or the mark that attack set off (tagged 'mlynarOwn') while his skill
|
||
* runs; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算").
|
||
*/
|
||
const ownKills = (battle, unit, onKill, onAttack = null) => {
|
||
let last = null;
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit) return;
|
||
last = c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn') ? c.target : null;
|
||
}, { owner: unit, priority: 1000 });
|
||
battle.on('kill', (c) => { if (c.killer === unit && c.victim === last && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit });
|
||
battle.on('attack', (c) => { if (c.attacker !== unit) return; last = null; if (onAttack) onAttack(); }, { owner: unit });
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_mlynar_1: () => ({ kind: 'duration', mods: mods({ defPct: num(bb.def) }), attack: { atkScale: num(bb['attack@atk_scale'], 1) } }),
|
||
skchr_mlynar_2: () => ({
|
||
kind: 'duration', mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillRange(chess, def),
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: 2 },
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
targeting: { ...(skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : {}), canHitFly: true },
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1), maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) },
|
||
onStart({ battle, unit }) {
|
||
unit.mem.mlyUp = true; unit.mem.mlyKills = 0; unit.mem.mlyPending = 0;
|
||
applyUp(battle, unit);
|
||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 2, skill: 'mlynar' });
|
||
},
|
||
onEnd({ battle, unit }) { unit.mem.mlyUp = false; unit.mem.mlyPending = 0; battle.removeBuff(unit, 'mlynar:traitUp'); },
|
||
},
|
||
trait: { dmgMul: (b, u) => (num(u.mem.mlyNear) >= num(t0.cnt, 3) ? num(t0.atk_scale_up, 1) : num(t0.atk_scale_base, 1)) },
|
||
talents: [
|
||
{ install(battle, unit) { // 游侠
|
||
const dr = num(t0.damage_resistance);
|
||
whileOn(battle, unit, 0.2, () => {
|
||
unit.mem.mlyNear = battle.enemiesInRadius(unit.x, unit.y, AROUND_RADIUS).length;
|
||
if (dr > 0 && unit.mem.mlyNear >= num(t0.cnt, 3)) battle.addBuff(unit, { key: 'mlynar:ranger', duration: 0.3, mods: { dmgTakenMul: 1 - dr } });
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 无动于衷
|
||
permBuff(battle, unit, 'mlynar:unmoved', { taunt: num(t1.taunt_level) });
|
||
const scale = num(t1.atk_scale);
|
||
if (!(scale > 0)) return;
|
||
battle.on('damaged', (c) => {
|
||
const a = c.target, src = c.source;
|
||
if (a.side !== 'ally' || !isOp(a) || !isKazimierz(a) || !src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return;
|
||
if (!on(unit) || leaderOf(battle, unit) !== unit) return;
|
||
battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'true', canDodge: false, tags: ['talent', 'reflect'] });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_mlynar_2') {
|
||
// 技能期间若击倒敌人,技能结束时特性效果不重置: the librator trait resets the ramp on skillEnd (priority 0) —
|
||
// remember it before and put it back after
|
||
battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.mlyKeep = false; }, { owner: unit });
|
||
ownKills(battle, unit, () => { unit.mem.mlyKeep = true; });
|
||
battle.on('skillEnd', (c) => {
|
||
if (c.unit === unit) unit.mem.mlyRamp = unit.mem.mlyKeep && c.reason !== 'death' ? num(unit.trait.ramp) : null;
|
||
}, { owner: unit, priority: 100 });
|
||
battle.on('skillEnd', (c) => {
|
||
if (c.unit !== unit || unit.mem.mlyRamp == null) return;
|
||
unit.trait.ramp = unit.mem.mlyRamp;
|
||
unit.mem.mlyRamp = null;
|
||
if (unit.trait.ramp > 0 && unit.alive) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
|
||
}, { owner: unit, priority: -100 });
|
||
}
|
||
if (sid && sid !== 'skchr_mlynar_3') return;
|
||
ownKills(battle, unit, () => { unit.mem.mlyPending = num(unit.mem.mlyPending) + 1; }, () => {
|
||
if (!unit.mem.mlyPending || !unit.mem.mlyUp) return;
|
||
unit.mem.mlyKills = num(unit.mem.mlyKills) + unit.mem.mlyPending;
|
||
unit.mem.mlyPending = 0;
|
||
applyUp(battle, unit);
|
||
});
|
||
const extra = num(bb.atk_scale);
|
||
if (!(extra > 0)) return;
|
||
battle.on('damaged', (c) => {
|
||
const src = c.source, e = c.target;
|
||
if (!src || src.side !== 'ally' || !isOp(src) || !isKazimierz(src) || !c.dmg?.isAttack || c.type === 'element') return;
|
||
if (e.side !== 'enemy' || !e.alive || !unit.skill?.active || !on(unit) || !inRange(unit, e)) return;
|
||
const tags = src === unit ? ['skill', 'mlynarMark', 'mlynarOwn'] : ['skill', 'mlynarMark'];
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'true', canDodge: false, isSkill: true, tags });
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 安洁莉娜 — slower (no normal attack while the skill is off). S3 秘杖·反重力模式 (14/18 s, SEARCH): every enemy on the
|
||
// field is weightless, skill range, ATK +, 4/5 targets. T1 加速力场: all allies ASPD +7. T2 兼职工作: while the skill is
|
||
// off, all allies regenerate 20 HP/s. Module (elite): longer sluggish (trait bb, profession tunables).
|
||
// S1 秘杖·速充模式 (duration, attack SP): ATK +; she attacks normally with S1 ("技能未开启时无法普通攻击" is only in the
|
||
// S2/S3 texts). S2 秘杖·微粒模式 (duration, SEARCH): attack interval ×base_attack_time ("极大幅度缩短": a positive value
|
||
// described as a shortening is the new interval ratio, tier1 batMod convention), each attack damage_scale × ATK arts.
|
||
// Module DEC-X (elite): SP +0.2/s with an enemy in range.
|
||
chess_char_5_20_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tm = talent(chess, -1);
|
||
const sid = selectedId(chess, def);
|
||
const ratioBat = (v) => (num(v) > 0 && num(v) < 1 ? num(v) - 1 : batPct(v, chess));
|
||
const weightless = (battle, unit) => {
|
||
const left = Math.max(0.1, num(unit.skill?.timeLeft, 1));
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.findBuff('aglina:weightless')) continue;
|
||
// 失重: weight −1 level (engine mod `massFlat`; massLevel never drops below 0)
|
||
battle.addBuff(e, { key: 'aglina:weightless', status: 'weightless', visible: true, duration: left, data: { src: unit }, mods: { massFlat: -WEIGHTLESS_MASS } });
|
||
}
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_aglina_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }),
|
||
skchr_aglina_2: () => ({ kind: 'duration', mods: mods({ batPct: ratioBat(bb.base_attack_time) }), attack: { atkScale: num(bb.damage_scale, 1) } }),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ atkPct: num(bb.atk) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: { maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) },
|
||
onStart({ battle, unit }) { unit.mem.aglAcc = 0; weightless(battle, unit); battle.fx('weightless', { x: unit.x, y: unit.y, id: unit.id }); },
|
||
onTick({ battle, unit, dt }) { unit.mem.aglAcc += dt; if (unit.mem.aglAcc >= AURA_IV) { unit.mem.aglAcc = 0; weightless(battle, unit); } },
|
||
onEnd({ battle, unit }) {
|
||
for (const e of battle.enemies) { const b = e.findBuff('aglina:weightless'); if (b && b.data.src === unit) battle.removeBuff(e, b); }
|
||
},
|
||
},
|
||
trait: sid === 'skchr_aglina_1' ? undefined : { noAttackUnlessSkill: true },
|
||
install(battle, unit) { enemySpUp(battle, unit, 'aglina:module', num(tm.sp_recovery_per_sec)); },
|
||
talents: [
|
||
{ install(battle, unit) { // 加速力场
|
||
const aspd = num(t0.attack_speed);
|
||
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
|
||
} },
|
||
{ install(battle, unit) { // 兼职工作
|
||
const hp = num(t1.hp_recovery_per_sec);
|
||
// an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性,
|
||
// 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not
|
||
// selected (Battle.alliesFor)
|
||
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
|
||
} },
|
||
],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 寒檀 — S2 “女巫之泪” (15 s): skill range, BAT −2.4 s; attacks become icicles on random tiles of the range (cycling left
|
||
// row → right row → own row): 65/75 % ATK arts + 1 s cold to every enemy within 1.5 of the impact. T1 生于冰寒: after 20 s on the field ATK +15 % and 抵抗 (negative
|
||
// statuses on her last half as long).
|
||
chess_char_5_21_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0);
|
||
const scale = num(bb['attack@atk_scale'], 1), cold = num(bb['attack@cold']);
|
||
const resist = Math.min(1, Math.max(0, -num(t0.one_minus_status_resistance))); // share of the duration removed
|
||
return {
|
||
// S1 迅捷打击·γ型 (duration): ATK +, ASPD +.
|
||
skills: lazySkills({
|
||
'skcom_quickattack[3]': () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: { noAttack: true },
|
||
onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.iceCd -= dt;
|
||
if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
|
||
if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return;
|
||
unit.mem.iceCd = unit.s.interval;
|
||
// PRTS 备注: icicles fall on a random tile of ① the row on her left, ② the row on her right, ③ her own row, …
|
||
let k = null;
|
||
for (let i = 0; i < ICICLE_ROWS.length && k == null; i++) {
|
||
const idx = (num(unit.mem.iceRow) + i) % ICICLE_ROWS.length;
|
||
const lat = ICICLE_ROWS[idx];
|
||
const keys = (unit.rangeKeys || []).filter((x) => toLocal(Math.floor(x / COLS) - unit.tileR, x % COLS - unit.tileC, unit.dir)[0] === lat);
|
||
if (!keys.length) continue;
|
||
k = battle.rng.pick(keys);
|
||
unit.mem.iceRow = (idx + 1) % ICICLE_ROWS.length;
|
||
}
|
||
if (k == null) return;
|
||
const r = Math.floor(k / COLS), c = k % COLS;
|
||
unit.lastAttackAt = battle.time;
|
||
unit.stats.attacks++; // "攻击变为…召唤冰凌": every icicle is one of her attacks
|
||
battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS });
|
||
battle.after(ICICLE_DELAY, () => {
|
||
const hit = [];
|
||
for (const e of battle.enemiesInRadius(c, r, ICICLE_RADIUS)) {
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] });
|
||
if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit });
|
||
if (e.alive) hit.push(e);
|
||
}
|
||
// an icicle is an attack: "攻击时" effects (items, bonds) see it with the enemies it hit
|
||
if (hit.length && battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: hit, isSkill: true });
|
||
}, { owner: unit });
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) { // 生于冰寒
|
||
const after = num(t0.interval, 20);
|
||
// + 抵抗 (engine `resist` status: new control statuses last half as long, never compounding with other sources)
|
||
whileOn(battle, unit, 0.5, () => {
|
||
if (battle.time - unit.deployedAt < after - 1e-9 || unit.findBuff('sntlla:born')) return;
|
||
battle.addBuff(unit, { key: 'sntlla:born', visible: true, mods: mods({ atkPct: num(t0.atk) }) });
|
||
if (resist > 0) battle.applyStatus(unit, 'resist', { value: resist, source: unit });
|
||
});
|
||
} }],
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 妮芙 — S2 怵然震爆 (1/2 charges): next attack 270/300 % ATK arts + 4/5 s fear on the target and an equal arts splash
|
||
// (projectile_range), every hit adding 18/22 % of its damage as apoptosis; targets in an apoptosis burst raise T1 to 70/85 %.
|
||
// T1 失魂: hitting an enemy in an apoptosis burst → 40 % ATK elemental damage per second until the burst ends.
|
||
// T2 窥心钥: an apoptosis burst in range → ATK +2 % (×10). Module (elite): ×damage_scale vs enemies in a burst.
|
||
// S1 笞心击 (duration): ATK +; attacks add attack@ep_damage_ratio of their damage as 凋亡损伤 and, on a target in an
|
||
// apoptosis burst, attack@extra_ep_damage_scale × ATK elemental damage. S3 心防溃决 (duration): skill range, ATK +,
|
||
// ASPD +, 2 targets; attacks on a target in an apoptosis burst deal elemental damage (split_atk_scale × ATK, no arts).
|
||
// Module PRI-Y (elite): SP +0.2/s while an enemy in range is in an element burst.
|
||
chess_char_5_22_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess), tm = talent(chess, -1);
|
||
const sid = selectedId(chess, def);
|
||
const ep = num(bb.ep_damage_ratio, num(bb['attack@ep_damage_ratio'])), skillSoul = num(bb.element_atk_scale), soulBase = num(t0.element_atk_scale);
|
||
const soul = (battle, unit, e, scale) => {
|
||
const burst = e.findBuff('apoptosisBurst');
|
||
if (!burst || !(scale > 0)) return;
|
||
const key = `nymph:soul:${unit.id}`;
|
||
const cur = e.findBuff(key);
|
||
if (cur) { cur.data.scale = Math.max(num(cur.data.scale), scale); return; }
|
||
battle.addBuff(e, {
|
||
key, duration: Math.max(0.1, burst.timeLeft), interval: 1, data: { scale },
|
||
onTick: ({ unit: x, buff }) => {
|
||
if (!x.findBuff('apoptosisBurst')) { battle.removeBuff(x, buff); return; }
|
||
elementHit(battle, unit, x, unit.s.atk * num(buff.data.scale), 'nymphSoul', 'apoptosis');
|
||
},
|
||
});
|
||
battle.fx('soul', { x: e.x, y: e.y, id: e.id });
|
||
};
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_nymph_1: () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk) }) }),
|
||
skchr_nymph_3: () => ({
|
||
kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
|
||
targeting: skillRange(chess, def, { maxTargets: Math.max(1, num(bb['attack@max_target'], 2)) }),
|
||
attack: { atkScale: num(bb['attack@split_atk_scale'], 1) },
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant',
|
||
attack: {
|
||
atkScale: num(bb.atk_scale, 1), splashRadius: num(bb.projectile_range, 1.5),
|
||
onHit({ battle, unit, target, x, y }) {
|
||
if (target && target.alive) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit });
|
||
battle.fx('aoe', { x, y, id: unit.id, r: num(bb.projectile_range, 1.5), skill: 'nymph' });
|
||
},
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 失魂 (+ the skill's apoptosis rider and 70 % upgrade)
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || c.type === 'element' || c.target.side !== 'enemy' || !c.target.alive || !c.dmg) return;
|
||
// (S1 has no attack override: its attacks are the skill's while it runs)
|
||
const skillAtk = c.dmg.isSkill || (sid === 'skchr_nymph_1' && !!unit.skill?.active);
|
||
// (not on a killing blow: the target is at 0 HP here — no burst on the corpse)
|
||
if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0 && hasHp(c.target)) {
|
||
battle.dealDamage(unit, c.target, { type: 'element', element: 'apoptosis', amount: c.amount * ep, tags: ['skill', 'nymph'] });
|
||
}
|
||
if (c.dmg.isAttack && c.target.alive) soul(battle, unit, c.target, c.dmg.isSkill ? Math.max(skillSoul, soulBase) : soulBase);
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 窥心钥
|
||
battle.on('elementBurst', (c) => {
|
||
if (c.element !== 'apoptosis' || c.target.side !== 'enemy' || !on(unit) || !inRange(unit, c.target)) return;
|
||
battle.addBuff(unit, { key: 'nymph:key', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 10)), mods: { atkPct: num(t1.atk) } });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
burstDamageUp(battle, unit, num(tb.damage_scale));
|
||
burstSpUp(battle, unit, 'nymph:module', num(tm.sp_recovery_per_sec));
|
||
if (sid === 'skchr_nymph_1') { // S1: extra elemental damage on burst targets (the 凋亡 rider is T1's `ep`)
|
||
const extra = num(bb['attack@extra_ep_damage_scale']);
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || !unit.skill?.active || !c.dmg?.isAttack || c.type === 'element' || c.type === 'elemental') return;
|
||
const e = c.target;
|
||
if (e.side !== 'enemy' || !e.alive || !e.findBuff('apoptosisBurst') || !(extra > 0)) return;
|
||
elementHit(battle, unit, e, unit.s.atk * extra, 'nymphLash', 'apoptosis');
|
||
}, { owner: unit });
|
||
}
|
||
if (sid === 'skchr_nymph_3') { // S3: attacks on a burst target become elemental damage
|
||
battle.on('hit', (c) => {
|
||
if (c.source !== unit || !unit.skill?.active || !c.dmg.isAttack || !c.target || !c.target.findBuff('apoptosisBurst')) return;
|
||
if (c.dmg.type !== 'arts' && c.dmg.type !== 'phys') return;
|
||
c.dmg.type = 'elemental';
|
||
c.dmg.element = 'apoptosis';
|
||
c.dmg.canDodge = false;
|
||
(c.dmg.tags ||= []).push('nymphBreak');
|
||
}, { owner: unit, priority: -10 });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 录武官 — S2 一点关窍 (25 s): ATK +; healed allies get a 10 s buff healing 80/120 HP on every hit taken.
|
||
// T1 学成于聚: an op in range starting a skill → +1 SP and ASPD +16 for 8 s. Module (elite): heals below 50 % ×1.15.
|
||
// S1 触类旁通 (2/3 charges): the next heal restores heal_scale × ATK and heals max_target allies.
|
||
chess_char_5_23_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0), tb = traitBb(chess);
|
||
const dur = num(bb['attack@buff_duration'], 10), value = num(bb['attack@fixed_heal_value']);
|
||
return {
|
||
skills: lazySkills({
|
||
skchr_reckpr_1: () => ({ kind: instantKind(chess, def), heal: true, attack: { healScale: num(bb.heal_scale, 1), maxTargets: Math.max(1, num(bb.max_target, 2)) } }),
|
||
}),
|
||
skill: {
|
||
kind: 'duration', heal: true,
|
||
mods: mods({ atkPct: num(bb.atk) }),
|
||
onHit({ battle, unit, target }) {
|
||
// "治疗干员后为其施加一个增益": operators only (summons are 友方单位, not 干员)
|
||
if (!target || !target.alive || !isOp(target) || !(value > 0)) return;
|
||
battle.addBuff(target, { key: 'reckpr:guard', duration: dur, visible: true, data: { src: unit, value } });
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) { // 学成于聚
|
||
battle.on('skillStart', (c) => {
|
||
const a = c.unit;
|
||
if (a === unit || !isOp(a) || !on(unit) || !inRange(unit, a)) return;
|
||
if (num(t0.prob, 1) < 1 && !battle.rng.chance(num(t0.prob, 1))) return;
|
||
unit.skill?.gainSp(num(t0.sp), 'talent');
|
||
if (num(t0.attack_speed)) battle.addBuff(unit, { key: 'reckpr:learn', duration: num(t0.duration, 8), mods: { aspd: num(t0.attack_speed) } });
|
||
}, { owner: unit });
|
||
} }],
|
||
install(battle, unit) {
|
||
battle.on('damaged', (c) => {
|
||
const a = c.target;
|
||
if (a.side !== 'ally' || !(c.amount > 0) || a.hp <= 0 || c.type === 'element') return;
|
||
const b = a.findBuff('reckpr:guard');
|
||
if (b && b.data.src === unit) battle.heal(unit, a, num(b.data.value));
|
||
}, { owner: unit });
|
||
lowHpHealUp(battle, unit, tb);
|
||
},
|
||
};
|
||
},
|
||
};
|
||
|
||
export default KITS;
|