249 lines
16 KiB
JavaScript
249 lines
16 KiB
JavaScript
// server/sim/content/kits/ops/op-zuole.js — 左乐 (char_4121_zuole) 自选 operator kit: 6★ 武者 (近卫), an owned-6★ pick of
|
||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||
//
|
||
// Forms (data/backups.json units.char_4121_zuole, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Full potential (the owner's decision of 2026-10-07).
|
||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 左乐 (S2 / S3
|
||
// 备注); the client's buff templates (buff_template_data `zuole_trait`, `zuole_t_2`, `zuole_s_2[shield]` /
|
||
// `[shield_core]`, `zuole_s_3` / `[shield]`, `zuole_e_002[resistance]`, `zuole_e_003_talent`, `zuole_e_003_trait`);
|
||
// Arknights Terra Wiki "Zuo Le" (the barrier caps count each skill's own barrier only).
|
||
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (musha: no heal from
|
||
// others; zuole_trait heals on every damage he outputs — the profession heals `value` per damage instance, PRTS 分支特性信息
|
||
// 武者, so S1's extra strikes heal too).
|
||
// - T1 秉烛照影 "在场时,自身获得最高+50攻击速度和技力自然回复速度+2/秒的坚忍(损失70%生命值时达到最大加成)" (bb
|
||
// min_attack_speed / min_sp_recovery_per_sec / min_hp_ratio): 坚忍 (ba.berserk "根据已损失的生命值获得相应比例的属性
|
||
// 加成") — ASPD and SP recovery × min(1, lost HP ÷ (1 − min_hp_ratio)) while he is deployed. SBL-X stage 3: +70 /
|
||
// +2.3 / at 50 % lost (the module talent's bb).
|
||
// - T2 守正自明 "攻击时有20%的概率获得1点技力,生命低于50%时概率变为70%" (full potential: 23 % / 75 %; bb prob_1 / prob_2 /
|
||
// hp_ratio / sp): zuole_t_2 rolls on every damage he outputs (ON_OUTPUT_DAMAGE — skill damage too), prob_2 below hp_ratio.
|
||
// SBL-Y stage 3 (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%" (full potential: 85 %): a
|
||
// success also scales that damage instance by atk_scale.
|
||
// - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's
|
||
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)": the shared effect of
|
||
// every source, tier1.js holdProtect) while HP < hp_ratio
|
||
// (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注:
|
||
// 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his
|
||
// barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first.
|
||
// - Module SBL-Y “秉烛人的记录” (trait bb hp_ratio): "被击倒时不撤退且回复30%生命(单次部署只触发1次)" — the first lethal
|
||
// hit of a deployment is prevented and he heals hp_ratio × max HP (zuole_e_003_trait[heal] HealViaMaxHpRatio, 无视禁疗);
|
||
// not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, items/battle.js holdsUndying).
|
||
// - S1 破虏 (AUTO, 可充能 2 次): the next attack at atk_scale × ATK; "自身生命低于80%时额外攻击1次,低于50%时额外攻击2次"
|
||
// (hp_ratio_double / hp_ratio_tripple): one / two more strikes on that target at the same scale, separate damage
|
||
// instances [ASSUMED: the same target — nothing when it fell; the HP read when the skill fires, before the strike's
|
||
// own trait heal]. The data's DEFAULT trigger (an AUTO "next attack").
|
||
// - S2 行险 (MANUAL, 12 s): "立即流失50%当前生命" (zuole_s_2[shield] DamageViaCurHpRatio, undeadable: never below 1 HP;
|
||
// a 流失), then a barrier of scale × max HP (ba.barrier "可以吸收一定数值的伤害") added to the one he still has, capped at
|
||
// max_scale × max HP at each gain (PRTS "上限于每次获取屏障时实时发生变化"; Terra: the cap counts this skill's barrier
|
||
// only); ATK +atk, block +block_cnt, every blocked enemy struck at once. Once the skill is over the barrier loses
|
||
// −shield_decrease every second (PRTS "技能结束时屏障以每秒-200点的速度流失"; zuole_s_2[shield_core] ON_BUFF_TRIGGER).
|
||
// - S3 佑序有炎 (MANUAL, data SKILL_RANGE on its 3-2): 7 slashes ("立刻…": all in the cast instant [ASSUMED: the kits'
|
||
// instant convention — his skeleton's 2 s Skill_3 clip spreads its 7 OnAttack events over 0.2 … 1.2 s, and the engine
|
||
// models no animation lock]), each on at most 3 enemies of the skill range (the 3 is the text's: no
|
||
// blackboard key; air units too [ASSUMED: no 对空 note — docs/SIM.md §8]) for atk_scale × ATK physical, the last
|
||
// ×last_atk_bonus and 晕眩 `stun` s (zuole_s_3: AtkScaleUp once spell_times reaches times). Meanwhile the trait heals
|
||
// nothing (zuole_trait "CheckContainsBuff zuole_s_3 → IfNot → heal") and every damage instance he outputs adds
|
||
// value × shield_scale to a barrier (zuole_s_3 ON_OUTPUT_DAMAGE; PRTS "屏障获取不经过治疗流程,不受治疗倍率等影响"), capped
|
||
// at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续
|
||
// 时间为15s").
|
||
|
||
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec, holdProtect, PROTECT, PROTECT_TICK_HOLD } from '../shared/tier1.js';
|
||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||
import { isHpLoss } from '../../../damage.js';
|
||
import { holdsUndying } from '../../items/battle.js';
|
||
|
||
const S1 = 'skchr_zuole_1';
|
||
const S2 = 'skchr_zuole_2';
|
||
const S3 = 'skchr_zuole_3';
|
||
/** S3 "每次对最多3名敌人" — in the text only (no blackboard key). */
|
||
const S3_TARGETS = 3;
|
||
/** PRTS S2 备注 "庇护BUFF于技能开启0.1s后重新判定并生效". */
|
||
const PROTECT_RESET = 0.1;
|
||
/** zuole_s_2[shield_core] decays once per trigger: "每秒-200点" (PRTS S2 备注). */
|
||
const S2_DECAY_EVERY = 1;
|
||
const S2_BARRIER = 'skill:zuole:barrier2';
|
||
const S3_BARRIER = 'skill:zuole:barrier3';
|
||
const BERSERK = 'talent:zuole:berserk';
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
/** The barrier of his skill he holds (one skill per pick), or null. */
|
||
const barrierOf = (u) => u.findBuff(S2_BARRIER) || u.findBuff(S3_BARRIER);
|
||
|
||
export default {
|
||
char_4121_zuole: (bb, chess) => {
|
||
const tb = traitBb(chess);
|
||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
|
||
const healValue = num(tb.value, 70);
|
||
// SBL-X 庇护 (merged into the first talent's bb) — its state is shared with S2's reset
|
||
const protectRatio = num(t0.hp_ratio), protectCut = t0.hp_ratio != null ? num(t0.damage_resistance) : 0;
|
||
const P = { since: null, blockedUntil: -Infinity };
|
||
const s2Decay = ({ battle, unit, buff }) => {
|
||
const sk = unit.skill;
|
||
if (sk && sk.active && sk.id === S2) return;
|
||
buff.shield = Math.max(0, buff.shield + num(b2.shield_decrease, -200));
|
||
unit.markDirty();
|
||
if (!(buff.shield > 0)) battle.removeBuff(unit, buff);
|
||
};
|
||
const gainS3Barrier = (battle, unit) => {
|
||
const cap = unit.s.maxHp * num(b3.max_scale, 2);
|
||
const cur = unit.findBuff(S3_BARRIER);
|
||
const val = Math.min(cap, (cur ? cur.shield : 0) + healValue * num(b3.shield_scale));
|
||
if (!(val > 0)) return;
|
||
battle.addBuff(unit, { key: S3_BARRIER, shield: val, duration: num(b3.shield_duration, 15), refresh: 'replace', visible: true,
|
||
tags: ['skill'], data: { createdAt: cur?.data?.createdAt ?? battle.time } });
|
||
};
|
||
return {
|
||
skills: {
|
||
[S1]: {
|
||
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||
// the HP that decides the extra strikes: when the skill fires, before its strike lands (the trait heals at each
|
||
// damage instance — professions.js musha — so after the first strike he would read healed) [ASSUMED]
|
||
onStart({ unit }) { unit.mem.zuoleS1Hp = unit.hpRatio; },
|
||
attack: {
|
||
atkScale: num(b1.atk_scale, 1),
|
||
onHit({ battle, unit, target }) {
|
||
const r = unit.mem.zuoleS1Hp ?? unit.hpRatio;
|
||
unit.mem.zuoleS1Hp = null;
|
||
const extra = (r < num(b1.hp_ratio_double, 0.8) ? 1 : 0) + (r < num(b1.hp_ratio_tripple, 0.5) ? 1 : 0);
|
||
for (let i = 0; i < extra && target && target.alive; i++) {
|
||
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||
}
|
||
},
|
||
},
|
||
},
|
||
[S2]: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(b2.atk), blockCnt: num(b2.block_cnt) },
|
||
attack: { hitAllBlocked: true },
|
||
onStart({ battle, unit }) {
|
||
const loss = Math.min(unit.hp * num(b2.hp_ratio), unit.hp - 1);
|
||
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
|
||
const cap = unit.s.maxHp * num(b2.max_scale, 2);
|
||
const add = Math.min(cap, unit.s.maxHp * num(b2.scale));
|
||
const cur = unit.findBuff(S2_BARRIER);
|
||
if (cur) { cur.shield = Math.min(cap, cur.shield + add); unit.markDirty(); }
|
||
else if (add > 0) {
|
||
battle.addBuff(unit, { key: S2_BARRIER, shield: add, interval: S2_DECAY_EVERY, onTick: s2Decay, visible: true, tags: ['skill'],
|
||
data: { createdAt: battle.time } });
|
||
}
|
||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
|
||
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier) — his own held 庇护
|
||
// goes now (another source's comes back with its next refresh)
|
||
P.since = null;
|
||
P.blockedUntil = battle.time + PROTECT_RESET;
|
||
const held = unit.findBuff(PROTECT);
|
||
if (held && held.source === unit) battle.removeBuff(unit, held);
|
||
},
|
||
},
|
||
[S3]: {
|
||
kind: 'instant',
|
||
onStart({ battle, unit }) {
|
||
const grid = s3?.rangeGrid?.length ? s3.rangeGrid : [[0, 0], [0, 1], [0, 2], [0, 3]];
|
||
const keys = absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
|
||
const times = Math.max(1, Math.floor(num(b3.times, 7)));
|
||
battle.fx('aoe', { x: unit.x + 1.5 * unit.fwd.x, y: unit.y + 1.5 * unit.fwd.y, radius: 1, id: unit.id, skill: 'zuole:slash' });
|
||
unit.mem.zuoleS3 = true;
|
||
try {
|
||
for (let i = 1; i <= times && up(unit); i++) {
|
||
const last = i === times;
|
||
const foes = battle.enemiesInKeys(keys, unit, { canHitFly: true });
|
||
sortEnemyTargets(battle, unit, foes, null);
|
||
const amount = unit.s.atk * num(b3.atk_scale, 1) * (last ? num(b3.last_atk_bonus, 1) : 1);
|
||
for (const e of foes.slice(0, S3_TARGETS)) {
|
||
if (!e.alive) continue;
|
||
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
|
||
if (last && e.alive) battle.applyStatus(e, 'stun', { duration: num(b3.stun), source: unit });
|
||
}
|
||
}
|
||
} finally {
|
||
unit.mem.zuoleS3 = false;
|
||
}
|
||
},
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 秉烛照影 (坚忍)
|
||
const maxAs = num(t0.min_attack_speed), maxSp = num(t0.min_sp_recovery_per_sec), full = num(t0.min_hp_ratio, 0.3);
|
||
if (!(maxAs > 0 || maxSp > 0) || !(full < 1)) return;
|
||
let last = -1;
|
||
const update = () => {
|
||
if (!up(unit)) { last = -1; return; }
|
||
const f = Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - full)));
|
||
if (Math.abs(f - last) < 1e-9) return;
|
||
last = f;
|
||
const cur = unit.findBuff(BERSERK);
|
||
if (!(f > 0)) { if (cur) battle.removeBuff(unit, cur); return; }
|
||
const mods = { aspd: maxAs * f, spRecoveryFlat: maxSp * f };
|
||
if (cur) { cur.mods = mods; unit.markDirty(); } else battle.addBuff(unit, { key: BERSERK, mods, tags: ['talent'] });
|
||
};
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) { last = -1; update(); } }, { owner: unit });
|
||
battle.on('damaged', (c) => { if (c.target === unit) update(); }, { owner: unit });
|
||
battle.on('tick', update, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 守正自明 (+ SBL-Y stage 3: ×atk_scale on a success)
|
||
const p1 = num(t1.prob_1), p2 = num(t1.prob_2), thr = num(t1.hp_ratio, 0.5), sp = num(t1.sp), scale = num(t1.atk_scale, 1);
|
||
if (!(sp > 0)) return;
|
||
const roll = () => battle.rng.chance(unit.hpRatio < thr ? p2 : p1);
|
||
if (scale !== 1) {
|
||
onHitBy(battle, unit, ({ dmg }) => { if (dmg.type !== 'element' && roll()) { giveSp(unit, sp); dmg.amount *= scale; } });
|
||
} else {
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
|
||
if (roll()) giveSp(unit, sp);
|
||
}, { owner: unit });
|
||
}
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
unit.mem.zuoleProtect = P;
|
||
// S3: the trait's heal turns into barrier while the slashes run (every damage instance he outputs)
|
||
battle.on('heal', (c) => {
|
||
if (unit.mem.zuoleS3 && c.source === unit && c.target === unit && c.opts && c.opts.self && !c.opts.tags) c.amount = 0;
|
||
}, { owner: unit });
|
||
battle.on('damaged', (c) => {
|
||
if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
|
||
gainS3Barrier(battle, unit);
|
||
}, { owner: unit });
|
||
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险 — the shared 庇护 (holdProtect: the
|
||
// strongest of every source holds), refreshed every tick and at each hit on him. When the 庇护 held is his own and his
|
||
// barrier is older than it, his cut comes after the barrier (the hpDamage hook) instead of before it
|
||
if (protectCut > 0 && protectCut < 1) {
|
||
const active = () => {
|
||
const now = battle.time;
|
||
if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; }
|
||
if (unit.hpRatio < protectRatio) { if (P.since == null) P.since = now; return true; }
|
||
P.since = null;
|
||
return false;
|
||
};
|
||
const keep = () => { if (active()) holdProtect(battle, unit, protectCut, PROTECT_TICK_HOLD, unit); };
|
||
const own = () => { const b = unit.findBuff(PROTECT); return !!b && b.source === unit && b.data?.value === protectCut; };
|
||
const afterBarrier = new WeakSet();
|
||
battle.on('tick', keep, { owner: unit });
|
||
onHitOn(battle, unit, ({ dmg }) => {
|
||
keep();
|
||
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || P.since == null || !own()) return;
|
||
const bar = barrierOf(unit);
|
||
// (the held buff's mods cut this hit before the barrier: undo that and cut what passes the barrier instead)
|
||
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) { dmg.mul /= 1 - protectCut; afterBarrier.add(dmg); }
|
||
});
|
||
battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit });
|
||
}
|
||
// SBL-Y: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
|
||
const revive = num(tb.hp_ratio);
|
||
if (revive > 0) {
|
||
let usedSeq = -1;
|
||
battle.on('fatal', (c) => {
|
||
if (c.unit !== unit || c.prevented || usedSeq === unit.deploySeq || holdsUndying(battle, unit)) return;
|
||
c.prevented = true;
|
||
usedSeq = unit.deploySeq;
|
||
battle.heal(unit, unit, unit.s.maxHp * revive, { self: true, ignoreHealFree: true, tags: ['trait'] });
|
||
battle.fx('revive', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit, priority: -60 });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|