Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-haruka.js
T

230 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/kits/ops/op-haruka.js — 遥 (char_4202_haruka) 自选 operator kit: 6★ 护佑者 (辅助), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module (BLS-Y 迟来的纪念) at every form. Kit
// contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4202_haruka): 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) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 遥 (浮光泡影 备注
// "进行普通攻击判定时,若范围内存在有效目标,则在本次攻击/治疗后同时触发一次赋予浮泡行为;若无有效目标,且范围内存在可赋予
// 浮泡的单位,则本次普通攻击改为赋予浮泡…赋予浮泡时,仅选择不具有浮泡的我方单位,优先选择其中生命比例最低者…触发浮泡破碎的
// 行动节点为“成功受到伤害后”;仅来源于敌方,且造成了生命值真实降低的伤害可触发浮泡破碎;触发后,浮泡将在0.5秒延迟后破碎";
// 扶摇花火 备注 "治疗量的计算使用遥赋予浮泡时的缓存攻击力"; 夜啼彩羽 备注 "浮泡破碎后优先给予生命比例最低的单位浮泡"; 幽隙栖萤
// 备注 "伤害溅射半径1.7,可对空…治疗量为0时将不会造成法术伤害…每次部署时,重新计算该技能的使用次数"; 夏末游鳞 备注 "触发浮空
// 效果与触发浮泡破碎的判定采用相同逻辑;即将破碎的浮泡仍能对伤害来源制造浮空效果…无法对持有无敌/不可选中/浮空免疫的来源生效
// (伤害绑定于该浮空效果上)"), and the client's battle data read from the local install — charpack char_4202_haruka (every
// mode's attack = AttackAndShield when it has a target, else ShieldOnly; the shield selector: allies, no haruka_t_1 holder,
// lowest HP share first, max_target_shield; S2 adds attack@max_target_heal_add / max_target_shield_add to the trait's and the
// talent's counts) and buff_template_data haruka_t_1 / haruka_t_1_delay / haruka_s_2 / haruka_s_2[damage] / haruka_s_3_scale
// / haruka_s_3_debuff_delay / haruka_s_3_debuff.
// - Trait (护佑者) "攻击造成法术伤害,技能开启后改为治疗友方单位(治疗量相当于75%攻击力)": ranged arts bolts (y-6, hits air
// units), blocks 1, ground enemies target her; while any skill runs every attack heals the most injured allies of her
// range (attack@max_target_heal of them, heal_scale × ATK). BLS-Y “迟来的纪念” "攻击对2个目标…改为治疗2个友方单位"
// (attack@max_target / attack@max_target_heal 2). Every skill keeps the data trigger: a 辅助's basic strategy casts on
// an enemy she is about to attack (S1 / S2 DEFAULT), S3 ACTIVE_RANGE on its y-8 (the owner's rule of 2026-10-05).
// - T1 浮光泡影 "每过一个攻击间隔,使范围内一名友方单位获得30%庇护的浮泡,浮泡在受到敌方伤害的短暂延迟后破碎": with each
// attack or heal she gives a bubble to max_target_shield allies of her current range that hold none (operators and summons,
// 禁疗 ones too — ignoreHealFree; never a 孤立 one; herself included), the lowest HP share first; with no target to attack
// or heal but such an ally, the attack itself becomes the bubble (it takes the attack interval); with neither she waits.
// A bubble (no time limit, it outlasts her [ASSUMED: the ability's buff, not removed on her leaving]) gives 庇护
// damage_resistance (phys / arts taken ×(1 − v); the client's override key damage_resistance[bonus] — not the common 庇护
// damage_resistance[inf] of other operators, so it multiplies with those) — while her S3 runs ×damage_resistance_scale
// (haruka_s_3_scale re-reads every bubble of hers at its start and end). Damage from an enemy (a `source` on the enemy side)
// that lowered its holder's HP sets it off: 0.5 s (bb interval) later it breaks — once, later hits change nothing.
// - T2 扶摇花火 "浮泡破碎时,为所在的单位治疗相当于遥攻击力25%的生命值": the break heals the holder heal_scale × her ATK as it was
// when she gave the bubble (a 治疗 — her heal, also after she left). BLS-Y stage 2+ "并有20%的概率回复1点技力": then prob to
// give sp SP to the holder (the client's ModifySp on the BUFF_OWNER).
// - S1 夜啼彩羽 (MANUAL, DEFAULT, 19 / 20 s): ASPD +attack_speed; heals; while it runs a breaking bubble at once gives a new one
// to the ally of the 8 tiles around its holder that holds none, the lowest HP share first (haruka_t_1_delay → shield_s1).
// - S2 幽隙栖萤 (MANUAL, DEFAULT, 22 / 23 s): heals (attack@max_target_heal_add more targets), max_target_shield_add more
// bubbles per attack (rank 7: +1 / +1); every heal of hers meanwhile (the trait's and T2's — ON_AFTER_OUTPUT_HEAL, the heal's
// value after the healing multipliers; none under 禁疗) deals that value × atk_scale_extra as arts damage to
// max_target_extra enemies within ability_range_radius (1.7) of the healed unit, air units too — the nearest first
// [ASSUMED: the ExtraSelect order]. "第二次及以后使用时攻击力+25%,且持续时间无限": the uses of the deployment are counted
// (PRTS); from the second on ATK +atk and it never ends (haruka_s_2[first].atk = 0 for the first).
// - S3 夏末游鳞 (MANUAL, ACTIVE_RANGE, 40 s): ATK +atk, range y-8, attack interval +base_attack_time s (−0.4 / −0.6 on 1.6),
// heals, her bubbles ×damage_resistance_scale; an enemy whose damage sets one of her bubbles off (the same test; also a
// bubble already breaking) is 浮空 levitate_duration s (the catalogue status: immunity, 抵抗, half on 重量 > 3; not an
// invulnerable / untargetable one) and while it stays levitated takes atk_scale × her ATK arts damage per second (the
// first after 0.5 s, then every `interval` s; 持续伤害, not dodgeable; 无来源 at her last ATK once she left).
import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_haruka_1';
const S2 = 'skchr_haruka_2';
const S3 = 'skchr_haruka_3';
/** A bubble (haruka_t_1): one per unit, whoever gave it — the selector skips holders. */
export const BUBBLE = 'haruka:bubble';
/** The S3 levitation damage (haruka_s_3_debuff). */
export const FLOAT_DOT = 'haruka:float';
/** 浮光泡影: the break delay when the talent carries no `interval`. */
const BREAK_DELAY = 0.5;
/** S3's levitation damage: the first tick (haruka_s_3_debuff firstTriggerInterval). */
const FLOAT_FIRST = 0.5;
/** 幽隙栖萤: the splash radius when the blackboard carries none (PRTS 备注 1.7). */
const S2_RADIUS = 1.7;
/** The 8 tiles around a unit and its own (夜啼彩羽's shield_s1 area; the holder itself is left out). */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const tileKey = (r, c) => r * COLS + c;
const skillOn = (u, id) => !!(u && u.skill && u.skill.id === id && u.skill.active);
/** Allies that may get a bubble from `by` on tile keys `keys`: no bubble yet, not 孤立; the lowest HP share first. */
function bubbleCands(battle, by, keys, except = null) {
const out = battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && a !== except
&& keys.has(tileKey(a.tileR, a.tileC)) && !a.findBuff(BUBBLE) && battle.allySelectable(a, by));
out.sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq || a.id - b.id);
return out;
}
export default {
char_4202_haruka: (bb, chess) => {
const tb = traitBb(chess);
const t1 = talentBb(chess, 0), t2 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const atkTargets = Math.max(1, Math.floor(num(tb['attack@max_target'], 1)));
const healTargets = Math.max(1, Math.floor(num(tb['attack@max_target_heal'], 1)));
const healAttack = (n) => Object.freeze({
dmgType: 'heal', heal: n > 1 ? { mode: 'multi', count: n } : { mode: 'single' }, healScale: num(tb.heal_scale, 0.75),
maxTargets: n, projectile: 'none',
});
const shelter = num(t1.damage_resistance, 0.3), breakDelay = num(t1.interval, BREAK_DELAY);
const bubbles = Math.max(1, Math.floor(num(t1.max_target_shield, 1)));
const t2Heal = num(t2.heal_scale), t2Prob = num(t2.prob), t2Sp = num(t2.sp);
const s3Scale = num(b3.damage_resistance_scale, 1);
/** Bubbles per grant: the talent's, + S2's add while it runs. */
const grantCount = (u) => bubbles + (skillOn(u, S2) ? Math.max(0, Math.floor(num(b2.max_target_shield_add))) : 0);
/** A bubble's 庇护 now: ×damage_resistance_scale while her S3 runs. */
const shelterNow = (u) => shelter * (skillOn(u, S3) ? s3Scale : 1);
/** Give `a` a bubble from `u` (its 庇护, her ATK of now for the T2 heal). */
function bubble(battle, u, a) {
const v = shelterNow(u);
battle.addBuff(a, {
key: BUBBLE, source: u, visible: true, mods: protectMods(v), tags: ['talent'],
data: { v, atk: u.s.atk, heal: t2Heal, prob: t2Prob, sp: t2Sp, boom: false },
});
battle.fx('shield', { x: a.x, y: a.y, id: a.id, kind: 'bubble' });
}
/** One bubble grant of `u` on her current range: up to grantCount allies. Returns how many got one. */
function grant(battle, u) {
const list = bubbleCands(battle, u, u.rangeKeySet || new Set(u.rangeKeys || []));
const n = Math.min(grantCount(u), list.length);
for (let i = 0; i < n; i++) bubble(battle, u, list[i]);
return n;
}
/** Re-read the 庇护 of every bubble of `u` (S3's start / end). */
function rescale(battle, u) {
const v = shelterNow(u);
for (const a of battle.allyUnits) {
const b = a.findBuff(BUBBLE);
if (!b || b.source !== u || !b.data || Math.abs(b.data.v - v) < 1e-12) continue;
b.data = { ...b.data, v };
b.mods = protectMods(v);
a.markDirty();
}
}
/** S3: levitate the enemy whose damage set a bubble of `u` off, and its per-second damage while it floats. */
function levitate(battle, u, e) {
if (!e || !e.alive || e.side !== 'enemy' || e.s.flags.invulnerable || e.s.flags.untargetable) return;
const dur = num(b3.levitate_duration);
if (!(dur > 0) || !battle.applyStatus(e, 'levitate', { duration: dur, source: u })) return;
const iv = Math.max(0.1, num(b3.interval, 1)), scale = num(b3.atk_scale);
let lastAtk = u.s.atk;
battle.addBuff(e, {
key: `${FLOAT_DOT}#${u.id}`, duration: dur + 0.05, source: u, tags: ['skill'],
data: { next: battle.time + FLOAT_FIRST },
onTick: ({ battle: b, unit: tgt, buff }) => {
if (!tgt.s.flags.levitate) { b.removeBuff(tgt, buff); return; }
if (b.time + 1e-9 < buff.data.next) return;
buff.data = { next: buff.data.next + iv };
const here = up(u);
if (here) lastAtk = u.s.atk;
b.dealDamage(u, tgt, { amount: lastAtk * scale, type: 'arts', canDodge: false, isSkill: true, sourceless: !here, tags: ['skill', 'dot', 'haruka:float'] });
},
});
battle.fx('levitate', { x: e.x, y: e.y, id: e.id });
}
/** A bubble of `u` on `a` breaks (the delay is over): T2's heal and SP, S1's next bubble. */
function burst(battle, u, a, buff) {
battle.removeBuff(a, buff);
const d = buff.data;
if (d.heal > 0) {
battle.heal(u, a, d.atk * d.heal);
battle.fx('heal', { x: a.x, y: a.y, id: a.id });
if (d.prob > 0 && d.sp > 0 && battle.rng.chance(d.prob)) giveSp(a, d.sp);
}
if (skillOn(u, S1) && up(u)) {
const keys = new Set(absoluteRangeKeys(X4, a.tileR, a.tileC, a.dir || 'RIGHT', 0));
const next = bubbleCands(battle, u, keys, a)[0];
if (next) bubble(battle, u, next);
}
}
return {
trait: atkTargets > 1 ? { maxTargets: atkTargets } : null,
skills: {
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets) },
[S2]: {
kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))),
onStart({ battle, unit }) {
unit.mem.harukaS2Uses = (unit.mem.harukaS2Uses ?? 0) + 1;
unit.mem.harukaS2Endless = unit.mem.harukaS2Uses >= 2;
const atk = unit.mem.harukaS2Endless ? num(b2.atk) : num(b2['haruka_s_2[first].atk']);
if (atk) battle.addBuff(unit, { key: 'haruka:s2:atk', mods: { atkPct: atk }, tags: ['skill'] });
},
// 持续时间无限 (2nd use on): the bar stays full and the skill never ends
onTick({ unit, skill }) { if (unit.mem.harukaS2Endless) skill.timeLeft = Math.max(skill.timeLeft, s2?.duration ?? skill.duration); },
onEnd({ battle, unit }) { battle.removeBuff(unit, 'haruka:s2:atk'); unit.mem.harukaS2Endless = false; },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: s3?.rangeGrid ?? null },
attack: healAttack(healTargets),
onStart({ battle, unit }) { rescale(battle, unit); },
onEnd({ battle, unit }) { rescale(battle, unit); }, // (`active` is already false: ×1 again)
},
},
talents: [
{ install(battle, unit) { // 浮光泡影: a bubble with each attack / heal, or instead of one; the break
battle.on('attack', (c) => { if (c.attacker === unit && up(unit)) grant(battle, unit); }, { owner: unit });
// no target to attack or heal but an ally to bubble: the attack becomes the bubble (ShieldOnly)
battle.on('tick', () => {
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || unit.trait.hadTarget !== false) return;
if (grant(battle, unit) > 0) unit.atkCd = Math.max(unit.atkCd, unit.s.interval);
}, { owner: unit });
battle.on('damaged', (c) => {
const a = c.target, src = c.source;
if (!a || a.side !== 'ally' || !src || src.side !== 'enemy' || !(c.amount > 0) || c.type === 'element') return;
const b = a.findBuff(BUBBLE);
if (!b || b.source !== unit) return;
if (skillOn(unit, S3) && up(unit)) levitate(battle, unit, src);
if (b.data.boom) return;
b.data = { ...b.data, boom: true };
battle.after(breakDelay, () => { if (a.alive && a.findBuff(BUBBLE) === b) burst(battle, unit, a, b); }, { owner: a });
}, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.harukaS2Uses = 0; unit.mem.harukaS2Endless = false; } }, { owner: unit });
} },
{ install(battle, unit) { // 幽隙栖萤: each heal of hers while S2 runs ⇒ arts damage around the healed unit
if (unit.skill?.id !== S2) return;
const scale = num(b2.atk_scale_extra), n = Math.max(0, Math.floor(num(b2.max_target_extra)));
const r = num(b2.ability_range_radius, S2_RADIUS);
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !skillOn(unit, S2) || !(c.amount > 0) || !(scale > 0) || !n) return;
const t = c.target;
const foes = battle.foesInRadius(t.x, t.y, r);
foes.sort((x, y) => Math.hypot(x.x - t.x, x.y - t.y) - Math.hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq);
const amount = c.amount * scale;
for (const e of foes.slice(0, n)) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'haruka:s2'] });
if (foes.length) battle.fx('aoe', { x: t.x, y: t.y, radius: r, id: unit.id, skill: 'haruka:s2' });
}, { owner: unit, priority: -1000 });
} },
],
};
},
};