feedback5: merge O22 — 自选 kits for 淬羽赫默 (with her 夜灯), 遥 and 酒神 (with her 本能的召唤 and 迷狂牢笼); engine (additive): enemyAttackStart hook before 麻痹, blocking noNewBlock flag, holdsUndying reads an undying buff; golden: only diy moved
This commit is contained in:
commit
c9429dc2d4
13 files changed
+2417
-116
No files matched your search
@@ -254,9 +254,14 @@ export function revivedInPlace(u) { if (u && u.mem) u.mem.revives = (u.mem.reviv
|
||||
* on the unit (`mem.undyingUntil`, `mem.undyingAt`) and a battle-level hook holds it (hammerAcquire), so it outlives the
|
||||
* grant that started it — a lend running out mid-window leaves the 不死 for its 8 s [ASSUMED: the 异常效果 outlasts its
|
||||
* source] — and ends with the deployment. 信仰搅拌机 S2 steps aside while it holds (kits/ops/chess_char_4_01-rmixer.js).
|
||||
* A content 不死 window counts too: a buff with the flag `undying` (淬羽赫默 S3 无畏者协议, kits/ops/op-slent2.js — its own
|
||||
* battle-level hook holds it), so the savers that do not spend themselves while a 不死 holds ("_dontConsumeWhenUndeadable":
|
||||
* 左乐, 莱恩哈特, 信仰搅拌机) step aside for it as well.
|
||||
*/
|
||||
export function holdsUndying(battle, u) {
|
||||
return !!u && u.mem.undyingAt != null && battle.time < u.mem.undyingUntil && u.mem.undyingAt === deploymentOf(u);
|
||||
if (!u) return false;
|
||||
if (u.mem.undyingAt != null && battle.time < u.mem.undyingUntil && u.mem.undyingAt === deploymentOf(u)) return true;
|
||||
return !!(u.s && u.s.flags.undying);
|
||||
}
|
||||
/** Effective multiplier of a hammer type on `u` and its params (null when the type does not apply). */
|
||||
function hammerMul(battle, rt, u, hs, type) {
|
||||
|
||||
@@ -191,7 +191,12 @@ it on a legal tile (the token's position / `ownerRange`), loses it with its owne
|
||||
match hands every placed piece to the battle as a PlayerBattleInput token unit `{ kind: 'token', tokenId, ownerUid }` —
|
||||
so a kit finds its pieces before the battle starts as `battle.allyUnits` with `kind === 'token'`, its `defId` and
|
||||
`ownerUnit === unit`, still undeployed (e.g. `op-bgsnow.js` gives its 打字机 pieces their kit in a talent's `install`
|
||||
with `battle._setupUnit(t, kit)`; `op-cgbird.js` counts its 幻影). A kit test fields a piece the same way:
|
||||
with `battle._setupUnit(t, kit)`; `op-cgbird.js` counts its 幻影). A **skill's** summon piece (the variant's `sources` has
|
||||
'skill', not 'talent') is docked by content/tokens.js before the units are set up — it deploys once, free, at the battle
|
||||
start, then takes the field when the kit calls `releaseSkillSummon(battle, unit, tokenId)` — and the dock hooks are owned
|
||||
by the piece: give it its kit with `battle._setupUnit(t, kit)` and **no** `offOwner` (the generic token kit registers no
|
||||
hook), and clear the owner's `mem.summonStock[tokenId]` when the skill takes the summon back (`op-slent2.js` 夜灯,
|
||||
`op-phatm2.js` 本能的召唤). A kit test fields a piece the same way:
|
||||
`units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is
|
||||
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). A 召唤师 whose talent holds a deck of
|
||||
summons ("可以使用5个召唤物(最多同时部署3个)") takes `shared/summoner.js summonDeck` — the holding, a placed piece's return
|
||||
|
||||
@@ -142,6 +142,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-mgllan.js',
|
||||
'op-ling.js',
|
||||
'op-radian.js',
|
||||
'op-slent2.js',
|
||||
'op-haruka.js',
|
||||
'op-phatm2.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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 } 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';
|
||||
const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
|
||||
/** 浮光泡影: 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 });
|
||||
} },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
// server/sim/content/kits/ops/op-phatm2.js — 酒神 (char_1042_phatm2) 自选 operator kit: 6★ 巫役 (辅助), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module (RIT-X 酒神之心) at every form, and the kits of
|
||||
// her summons 本能的召唤 (token_10054_phatm2_encdool) and 迷狂牢笼 (token_10055_phatm2_mndclv). Kit contract and the 自选
|
||||
// rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_1042_phatm2): 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 / token_table / range_table (zh_CN, as built into backups.json),
|
||||
// PRTS 酒神 (形为心役 备注 "溅射半径1.3(中点判定);先造成神经损伤与溅射,随后造成伤害"; 堕梦 备注 "于敌人成功普通攻击前之前触发
|
||||
// (意味着触发的元素爆发可打断当次普攻,且不会触发成功攻击前的效果)"; 群体性谵妄 备注 on 本能的召唤; 空剧场 备注 "技能开始时,
|
||||
// 对攻击范围内所有处于元素损伤爆发中的敌人触发一次第一天赋…尝试为其生成一次迷狂牢笼(不刷新已存在的牢笼)…迷狂牢笼只能生成于
|
||||
// 不存在角色类单位的低地地块…所在地块已存在迷狂牢笼时改为将其刷新1次"), PRTS 本能的召唤 / 迷狂牢笼 (their 备注), and the
|
||||
// client's battle data read from the local install — charpack char_1042_phatm2 (T1: phatm2_t_1 an active buff of every
|
||||
// attack; T2: phatm2_t_2 on the enemies of her range, phatm2_t_2[global] on every enemy, phatm2_t_2[s3] in S3's range; S3:
|
||||
// SpawnToken → phatm2_s_3[token] on the enemies of its range) and buff_template_data phatm2_t_1 / [range], phatm2_t_2 /
|
||||
// [global] / [s3], phatm2_s_1[unmove], phatm2_s_2 / [damage] / [die], phatm2_s2_token_cache, token_phatm2_encdool_* ,
|
||||
// phatm2_s_3[trigger] / [sanity] / [token] / [token][trigger] / [die] / [token_cooldown] / [token_clear_cooldown],
|
||||
// token_phatm2_mndclv[passive].
|
||||
// - Trait (巫役) "攻击造成法术伤害,可以造成元素损伤": ranged arts bolts (y-2, hits air units), blocks 1, ground enemies target
|
||||
// her. RIT-X “酒神之心” (trait bb ep_damage_scale) "对精英和领袖敌人造成的元素损伤提升18%": every element fill she deals
|
||||
// (her summon's too: "来源均为酒神自身") on an ELITE / BOSS / leader enemy ×1.18 — an `elementHit` multiplier (the kits'
|
||||
// convention: 塑心 强弱法).
|
||||
// - T1 形为心役 "攻击附带相当于攻击力30%的神经损伤,并对目标周围其他敌人造成一次相当于攻击力20%的神经损伤": every damage
|
||||
// instance of her normal attacks first gives its target attack@ep_damage_ratio × ATK 神经损伤 and every other selectable
|
||||
// enemy within range_radius (1.3, 中点判定; S2 talent@range_radius 1.5) of it ep_damage_ratio × ATK — before the damage (a
|
||||
// late `hit` handler, after any cancel; a hit dodged afterwards still carried it [ASSUMED], as 塑心 S2). S1's two hits
|
||||
// each carry it [ASSUMED: per damage instance].
|
||||
// - T2 堕梦 "在场时,全场处于神经损伤爆发期间的敌人攻击速度-12;攻击范围内的敌人普通攻击时受到70点神经损伤": while she is on
|
||||
// the field every enemy in a 神经损伤 burst (its 爆发冷却, `neuralBurst`) has ASPD −|attack_speed| (one "同名" effect,
|
||||
// the strongest — priorityBBKeys); an enemy of her attack range starting a normal attack first takes `value` 神经损伤 —
|
||||
// the engine's `enemyAttackStart` hook, before 麻痹 is checked, so a burst it causes interrupts that very attack. RIT-X
|
||||
// stage 2+: −20 / 90 (the talent change).
|
||||
// - S1 暗夜回声 (AUTO, attack SP 3, data DEFAULT — a "next attack"): that attack picks an enemy not in a burst first
|
||||
// (`notBurst`) and hits twice for atk_scale × ATK arts (times), then 束缚 (`bind`) for `unmove` s; while that root holds
|
||||
// (its final duration after 抵抗) the target takes 神经损伤 ×ep_damage_scale from anyone (phatm2_s_1[unmove]
|
||||
// EpDamageScale; one effect per target, the strongest) — the root lands after the two hits [ASSUMED].
|
||||
// - S2 群体性谵妄 (AUTO, 持续时间无限 ⇒ a toggle until she leaves; an AUTO skill acting on herself and her summon: SP_FULL —
|
||||
// the owner's AUTO rule): ASPD +attack_speed, T1's splash radius talent@range_radius; her placed 本能的召唤 piece takes the
|
||||
// field on its tile when the skill starts and again each time it is ready ("退场时返还1个可部署的本能的召唤": its own 25 s
|
||||
// redeploy time) while the skill runs (content/tokens.js releaseSkillSummon; like every placed skill summon it also deploys
|
||||
// once, free, at the battle start — PRTS 卫戍协议/帮助); her leaving withdraws it ("酒神退场时强制撤退场上的本能的召唤").
|
||||
// 本能的召唤 (kit below): untargetable, no attack, blocks nothing; every 0.1 s it 诱导s (the catalogue `attract`, to its
|
||||
// tile, for the rest of its life) the enemies of its x-1 (13 tiles) it can lure — at most max_target in all, ELITE / leader
|
||||
// first [ASSUMED: then the nearest], only "可达" ones (a ground path to its tile; flyers always); it leaves 10 s after its
|
||||
// deployment or as soon as one of them is within ability_range_radius (0.4) of it; whenever it leaves (any reason) every
|
||||
// selectable enemy of its x-1 is 停顿 `sluggish` s and for buff_time s takes, every interval_damage s from 0.1 s on,
|
||||
// ep_damage_ratio_token × ATK 神经损伤 (fixed: "不受目标损伤抗性影响" — the 损伤抵抗 share added back) and atk_scale × ATK arts
|
||||
// (持续伤害, not dodgeable) — her ATK when it was deployed ("缓存攻击力"), credited to her (无来源 once she left).
|
||||
// - S3 空剧场 (MANUAL, ACTIVE_RANGE on y-8, 30 s): range y-8, ATK +atk, an enemy not in a burst first; every enemy she gives
|
||||
// 神经损伤 meanwhile (a real gauge gain) takes ep_damage_ratio × her ATK 神经损伤 0.1 s later and every `interval` s after
|
||||
// until it is in a burst (any element) or the skill ends; enemies of her range in a 神经损伤 爆发冷却 recover
|
||||
// talent@ep_break_recover_speed (+50 %) faster (the cooldown runs 1.5×); an enemy of her range whose 神经损伤 bursts gets a
|
||||
// 迷狂牢笼 on its ground tile (a low tile with no unit on it; an existing 牢笼 there is refreshed instead). At its start every
|
||||
// enemy of her range already in an element burst sets T1's splash off around it once and, in a 神经损伤 burst, gets a 牢笼
|
||||
// (none refreshed); 牢笼 left from an earlier cast stay. When it ends every 牢笼 of hers lasts
|
||||
// phatm2_s_3[token_cooldown].interval (30) s more; her leaving withdraws them.
|
||||
// 迷狂牢笼 (kit below): 孤立 + 禁疗, no attack, 迷彩 (only the enemies it blocks target it), every damage it takes from
|
||||
// one of them is 1, any other damage (无来源 too) none — 3 HP: three hits; at its spawn / refresh it blocks every unblocked
|
||||
// enemy on its tile, ground or air, whatever its capacity, back to full HP, block count = their weight; it takes no other
|
||||
// enemy by contact (flag `noNewBlock`); with nobody blocked it leaves at once.
|
||||
// Statuses: 束缚 (S1), 停顿 + 诱导 (本能的召唤); 神经损伤 bursts (engine: 3 麻痹 + 6000 元素伤害).
|
||||
|
||||
import { num, up, traitBb, talentBb, skillRec, once } from '../shared/tier1.js';
|
||||
import { isElite, onElementHit, elementDmg } from '../shared/tier6.js';
|
||||
import { releaseSkillSummon } from '../../tokens.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp, burstLocked } from '../../../damage.js';
|
||||
import { MIN_DAMAGE_RATIO } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_phatm2_1';
|
||||
const S2 = 'skchr_phatm2_2';
|
||||
const S3 = 'skchr_phatm2_3';
|
||||
export const CALL = 'token_10054_phatm2_encdool';
|
||||
export const CAGE = 'token_10055_phatm2_mndclv';
|
||||
/** phatm2_s_1[unmove]: the root's 神经损伤 amplifier (one per target, the strongest). */
|
||||
export const UNMOVE = 'phatm2:unmove';
|
||||
/** phatm2_t_2[attack_speed]: the ASPD cut of enemies in a 神经损伤 burst (one per enemy, the strongest). */
|
||||
export const DREAM = 'phatm2:dream';
|
||||
/** The client's trigger period of her field / range effects. */
|
||||
const IV = 0.1;
|
||||
const HOLD = IV + 0.05;
|
||||
/** 本能的召唤 / 迷狂牢笼 numbers the token records may lack (PRTS 本能的召唤: 10 s, 0.4; the S3 cooldown 30 s). */
|
||||
const CALL_LIFE = 10, CALL_ARRIVE = 0.4, CAGE_AFTER = 30;
|
||||
/** 本能的召唤's area when its record carries no grid: x-1 (range_table: the 13-tile diamond). */
|
||||
const X1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
|
||||
/** Selection profile of her effects on enemies: ground and air. */
|
||||
const ANY = Object.freeze({ canHitFly: true });
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const skillOn = (u, id) => !!(u && u.skill && u.skill.id === id && u.skill.active);
|
||||
const keysAt = (grid, u) => new Set(absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, 0));
|
||||
|
||||
/**
|
||||
* 神经损伤 of a fixed value that ignores the target's 损伤抵抗 (PRTS 群体性谵妄 备注 "不受目标损伤抗性影响": the client's
|
||||
* ApplyFixedElementDamage): the share the engine takes off (damage.js elementIntake) is added back first.
|
||||
*/
|
||||
function fixedNeural(battle, src, e, amount, sourceless = false) {
|
||||
if (!(amount > 0) || !hasHp(e)) return 0;
|
||||
const res = Math.min(1, Math.max(0, (Number(e.def?.epResistance) || 0) / 100));
|
||||
const k = Math.max(MIN_DAMAGE_RATIO, 1 - res);
|
||||
return battle.dealDamage(src, e, { type: 'element', element: 'neural', amount: amount / k, sourceless, tags: ['skill', 'phatm2:fixed'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* 本能的召唤's kit (the skill-summon piece of her S2: a fresh one per piece). `owner` = 酒神, `b2` = her S2 blackboard
|
||||
* (fallback for the token's own skill blackboard).
|
||||
*/
|
||||
function callKit(t, owner, b2) {
|
||||
const sb = t.def?.skill?.bb ?? {};
|
||||
const pick = (k, d) => num(sb[k], num(b2[k], d));
|
||||
const life = num((t.def?.talents ?? []).find((x) => x && x.bb && x.bb.duration != null)?.bb?.duration, pick('interval', CALL_LIFE));
|
||||
const cap = Math.max(1, Math.floor(pick('max_target', 4))), arrive = pick('ability_range_radius', CALL_ARRIVE);
|
||||
const grid = t.def?.rangeGrid ?? X1;
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
install(battle, tok) {
|
||||
let lured = [], endAt = 0, atk = 0;
|
||||
const mine = (e) => { const a = e.findBuff('attract'); return a && a.source === tok ? a : null; };
|
||||
battle.on('deploy', (c) => {
|
||||
if (c.unit !== tok) return;
|
||||
lured = [];
|
||||
endAt = battle.time + life;
|
||||
atk = owner.s.atk; // 缓存攻击力: her ATK at the deployment (PRTS 备注)
|
||||
const seq = tok.deploySeq;
|
||||
battle.after(life, () => { if (tok.alive && tok.deploySeq === seq) battle.retreat(tok, { reason: 'expired', permanent: true }); }, { owner: tok });
|
||||
}, { owner: tok });
|
||||
// 诱导 the enemies entering its x-1 (every 0.1 s, at most `cap` in all)
|
||||
battle.every(IV, () => {
|
||||
if (!up(tok) || lured.length >= cap) return;
|
||||
const keys = keysAt(grid, tok);
|
||||
const cands = battle.enemiesInKeys([...keys], tok, ANY).filter((e) => !lured.includes(e) && !e.s.flags.attract
|
||||
&& (e.motion === 'FLY' || !!battle.grid.waypoints(Math.round(e.y), Math.round(e.x), tok.tileR, tok.tileC)));
|
||||
cands.sort((a, b) => (isElite(b) - isElite(a)) || (Math.hypot(a.x - tok.x, a.y - tok.y) - Math.hypot(b.x - tok.x, b.y - tok.y)) || a.spawnSeq - b.spawnSeq);
|
||||
for (const e of cands) {
|
||||
if (lured.length >= cap) break;
|
||||
if (battle.applyStatus(e, 'attract', { duration: Math.max(0.05, endAt - battle.time), source: tok, point: [tok.tileR, tok.tileC] })) {
|
||||
lured.push(e);
|
||||
battle.fx('lure', { x: e.x, y: e.y, id: e.id, src: tok.id });
|
||||
}
|
||||
}
|
||||
}, { owner: tok });
|
||||
// the first lured enemy within `arrive` of it: it leaves
|
||||
battle.on('tick', () => {
|
||||
if (!up(tok)) return;
|
||||
if (lured.some((e) => e.alive && mine(e) && Math.hypot(e.x - tok.x, e.y - tok.y) <= arrive + 1e-9)) battle.retreat(tok, { reason: 'expired', permanent: true });
|
||||
}, { owner: tok });
|
||||
// leaving (any reason): 停顿 + the damage over time on every selectable enemy of its x-1; the 诱导 ends
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== tok || battle.finished) return;
|
||||
for (const e of lured) { const a = e.alive ? mine(e) : null; if (a) battle.removeBuff(e, a); }
|
||||
lured = [];
|
||||
const keys = keysAt(grid, tok);
|
||||
const slow = pick('sluggish', 6), dur = pick('buff_time', 6), iv = Math.max(0.1, pick('interval_damage', 0.5));
|
||||
const ep = pick('ep_damage_ratio_token', 0), scale = pick('atk_scale', 1), a0 = atk;
|
||||
battle.fx('aoe', { x: tok.x, y: tok.y, radius: 2, id: tok.id, skill: 'phatm2:call' });
|
||||
for (const e of battle.enemiesInKeys([...keys], tok, ANY)) {
|
||||
if (slow > 0) battle.applyStatus(e, 'sluggish', { duration: slow, source: owner });
|
||||
battle.addBuff(e, {
|
||||
key: `phatm2:delirium#${owner.id}`, duration: dur, source: owner, tags: ['skill'], data: { next: battle.time + IV },
|
||||
onTick: ({ battle: b, unit: tgt, buff }) => {
|
||||
if (b.time + 1e-9 < buff.data.next) return;
|
||||
buff.data = { next: buff.data.next + iv };
|
||||
const gone = !owner.alive;
|
||||
fixedNeural(b, owner, tgt, a0 * ep, gone);
|
||||
if (tgt.alive) b.dealDamage(owner, tgt, { amount: a0 * scale, type: 'arts', canDodge: false, isSkill: true, sourceless: gone, tags: ['skill', 'dot', 'phatm2:delirium'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
}, { owner: tok, priority: 10 });
|
||||
// "退场时返还1个可部署的本能的召唤": while her S2 runs it comes back after its own redeploy time (the dock's, −10, first)
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit === tok && !battle.finished && skillOn(owner, S2) && up(owner)) releaseSkillSummon(battle, owner, CALL);
|
||||
}, { owner: tok, priority: -20 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 迷狂牢笼's kit (spawned by her S3: a fresh one per cage). */
|
||||
function cageKit() {
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
install(battle, cage) {
|
||||
battle.addBuff(cage, { key: 'trait:cage', flags: { isolated: true, noHeal: true, camou: true, noNewBlock: true }, persist: true, allowDead: true });
|
||||
// only the enemies it blocks hurt it, 1 per hit
|
||||
battle.on('hit', (c) => { if (c.target === cage && (!c.source || !cage.blocking.includes(c.source))) c.dmg.cancel = true; }, { owner: cage, priority: 1000 });
|
||||
battle.on('hpDamage', (c) => { if (c.target === cage && c.amount > 1) c.amount = 1; }, { owner: cage });
|
||||
battle.on('tick', () => {
|
||||
if (!up(cage)) return;
|
||||
if (!cage.blocking.length || (cage.mem.cageUntil != null && battle.time + 1e-9 >= cage.mem.cageUntil)) battle.retreat(cage, { reason: 'expired', permanent: true });
|
||||
}, { owner: cage });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A 牢笼 blocks every unblocked enemy on its tile (spawn / refresh): full HP again, block count = their weight. */
|
||||
function cageBlock(battle, cage) {
|
||||
let added = 0;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || e.blockedBy || Math.round(e.y) !== cage.tileR || Math.round(e.x) !== cage.tileC) continue;
|
||||
const f = e.s.flags;
|
||||
if (f.unblockable || f.levitate || f.fear) continue;
|
||||
e.blockedBy = cage;
|
||||
cage.blocking.push(e);
|
||||
e.moving = false;
|
||||
added++;
|
||||
if (battle.hasHook('blocked')) battle.emit('blocked', { blocker: cage, enemy: e });
|
||||
}
|
||||
if (added) {
|
||||
let w = 0;
|
||||
for (const e of cage.blocking) w += e.blockWeight ?? 1;
|
||||
battle.addBuff(cage, { key: 'cage:block', mods: { blockCnt: w }, persist: true, allowDead: true });
|
||||
cage.hp = cage.s.maxHp;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/** 空剧场: a 牢笼 on enemy `e`'s ground tile — or the one already there refreshed (`refresh`). */
|
||||
function cageAt(battle, owner, e, refresh) {
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
if (!battle.grid.inRect(r, c)) return null;
|
||||
const there = battle.allyUnits.find((t) => t.alive && t.deployed && t.kind === 'token' && t.defId === CAGE && t.tileR === r && t.tileC === c);
|
||||
if (there) { if (refresh) cageBlock(battle, there); return there; }
|
||||
if (!battle.grid.isLow(r, c) || battle.unitAt(r, c)) return null;
|
||||
const cage = battle.spawnToken(owner, CAGE, r, c, { kit: cageKit() });
|
||||
if (!cage) return null;
|
||||
if (!skillOn(owner, S3)) cage.mem.cageUntil = battle.time + CAGE_AFTER;
|
||||
cageBlock(battle, cage);
|
||||
battle.fx('summon', { x: cage.x, y: cage.y, id: cage.id, token: CAGE });
|
||||
return cage;
|
||||
}
|
||||
|
||||
const callsOf = (battle, owner) => battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === CALL && t.ownerUnit === owner);
|
||||
const cagesOf = (battle, owner) => battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === CAGE && t.ownerUnit === owner && t.alive);
|
||||
|
||||
export default {
|
||||
char_1042_phatm2: (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 s3 = skillRec(chess, S3);
|
||||
const eliteScale = num(tb.ep_damage_scale, 1);
|
||||
const mainRatio = num(t1['attack@ep_damage_ratio']), splashRatio = num(t1.ep_damage_ratio);
|
||||
const splashR = num(t1.range_radius, 1.3), s2R = num(b2['talent@range_radius'], splashR);
|
||||
const dreamAspd = Math.abs(num(t2.attack_speed)), dreamValue = num(t2.value);
|
||||
const s3Ratio = num(b3.ep_damage_ratio), s3Iv = Math.max(0.1, num(b3.interval, 1));
|
||||
const s3Recover = num(b3['talent@ep_break_recover_speed']), s3After = num(b3['phatm2_s_3[token_cooldown].interval'], CAGE_AFTER);
|
||||
const s3DotKey = (u) => `phatm2:s3dot#${u.id}`;
|
||||
/** T1's splash around `t`: every other selectable enemy within the radius (中点判定). */
|
||||
const splash = (battle, unit, t, r) => {
|
||||
if (!(splashRatio > 0)) return;
|
||||
for (const e of battle.foesInRadius(t.x, t.y, r, true)) if (e !== t) elementDmg(battle, unit, e, 'neural', unit.s.atk * splashRatio, ['talent']);
|
||||
};
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'instant',
|
||||
targeting: { priority: 'notBurst' },
|
||||
attack: {
|
||||
atkScale: num(b1.atk_scale, 1), hits: Math.max(1, Math.floor(num(b1.times, 2))),
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target || !target.alive || !battle.applyStatus(target, 'bind', { duration: num(b1.unmove), source: unit })) return;
|
||||
const root = target.buffs.find((b) => b.status === 'bind');
|
||||
const dur = root && Number.isFinite(root.timeLeft) ? root.timeLeft : num(b1.unmove);
|
||||
battle.applyStrongest(target, UNMOVE, { duration: dur, value: num(b1.ep_damage_scale, 1), mods: () => ({}), source: unit });
|
||||
},
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'toggle', trigger: 'SP_FULL', mods: { aspd: num(b2.attack_speed) },
|
||||
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, CALL); },
|
||||
onEnd({ battle, unit }) { withdrawCalls(battle, unit); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? null, priority: 'notBurst' },
|
||||
onStart({ battle, unit }) {
|
||||
for (const c of cagesOf(battle, unit)) c.mem.cageUntil = null; // phatm2_s_3[token_clear_cooldown]
|
||||
for (const e of [...battle.enemies]) {
|
||||
if (!e.alive || e.hidden || !e.s.flags.burstLock || !bodyInKeys(e, unit.rangeKeySet)) continue;
|
||||
splash(battle, unit, e, splashR);
|
||||
if (e.alive && e.findBuff('neuralBurst')) cageAt(battle, unit, e, false);
|
||||
}
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
if (!(s3Recover > 0)) return;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue;
|
||||
const lock = e.findBuff('neuralBurst');
|
||||
if (lock && Number.isFinite(lock.timeLeft)) lock.timeLeft -= dt * s3Recover;
|
||||
}
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
const key = s3DotKey(unit);
|
||||
for (const e of battle.enemies) if (e.findBuff(key)) battle.removeBuff(e, key);
|
||||
for (const c of cagesOf(battle, unit)) c.mem.cageUntil = battle.time + s3After;
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 形为心役: each hit of her attacks — its target, then the enemies around it
|
||||
battle.on('hit', (c) => {
|
||||
const t = c.target, d = c.dmg;
|
||||
if (c.source !== unit || !t || t.side !== 'enemy' || !d || d.cancel || !d.isAttack || d.isSplash || !hasHp(t)) return;
|
||||
if (mainRatio > 0) elementDmg(battle, unit, t, 'neural', unit.s.atk * mainRatio, ['talent']);
|
||||
splash(battle, unit, t, skillOn(unit, S2) ? s2R : splashR);
|
||||
}, { owner: unit, priority: -1000 });
|
||||
} },
|
||||
{ install(battle, unit) { // 堕梦: ASPD cut in a 神经损伤 burst (the field); 神经损伤 on a normal attack (her range)
|
||||
if (dreamAspd > 0) {
|
||||
const slow = (e) => battle.applyStrongest(e, DREAM, { duration: HOLD, value: dreamAspd, mods: (v) => ({ aspd: -v }), source: unit });
|
||||
battle.every(IV, () => {
|
||||
if (!up(unit)) return;
|
||||
for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('neuralBurst')) slow(e);
|
||||
}, { owner: unit, immediate: true });
|
||||
battle.on('elementBurst', (c) => { if (up(unit) && c.element === 'neural' && c.target && c.target.side === 'enemy' && c.target.alive) slow(c.target); }, { owner: unit });
|
||||
}
|
||||
if (dreamValue > 0) {
|
||||
battle.on('enemyAttackStart', (c) => {
|
||||
const e = c.enemy;
|
||||
if (!up(unit) || !e || !hasHp(e) || !bodyInKeys(e, unit.rangeKeySet)) return;
|
||||
elementDmg(battle, unit, e, 'neural', dreamValue, ['talent']);
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 本能的召唤: her placed pieces run its kit; her leaving withdraws it and every 迷狂牢笼
|
||||
for (const t of callsOf(battle, unit)) {
|
||||
if (t.alive || t.deployed) continue;
|
||||
// (no offOwner for a piece set up before her: its hooks are content/tokens.js's dock hooks — the generic token
|
||||
// kit registers none — and they must stay)
|
||||
battle._setupUnit(t, callKit(t, unit, b2));
|
||||
}
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
withdrawCalls(battle, unit);
|
||||
for (const t of cagesOf(battle, unit)) battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// RIT-X: her element damage on elite / leader enemies ×ep_damage_scale
|
||||
if (eliteScale > 1) onElementHit(battle, unit, (c) => (c.source === unit && c.target.side === 'enemy' && isElite(c.target) ? eliteScale : 1));
|
||||
// S1's root: 神经损伤 taken ×its value while it holds (one battle-wide handler, whoever rooted)
|
||||
once(battle, 'phatm2:unmove', () => {
|
||||
battle.on('elementHit', (c) => {
|
||||
const d = c.dmg, t = c.target;
|
||||
if (!d || d.type !== 'element' || d.element !== 'neural' || !t) return;
|
||||
const v = t.findBuff(UNMOVE)?.data?.value;
|
||||
if (v > 1) d.mul *= v;
|
||||
});
|
||||
});
|
||||
if (unit.skill?.id !== S3) return;
|
||||
// 空剧场: her 神经损伤 starts the per-second 神经损伤 (until a burst); a 神经损伤 burst in her range: a 牢笼
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (!skillOn(unit, S3) || c.source !== unit || c.type !== 'element' || c.dmg?.element !== 'neural' || !(c.amount > 0)) return;
|
||||
if (!e || e.side !== 'enemy' || !e.alive || e.findBuff(s3DotKey(unit)) || burstLocked(e)) return;
|
||||
battle.addBuff(e, {
|
||||
key: s3DotKey(unit), source: unit, tags: ['skill'], data: { next: battle.time + IV },
|
||||
onTick: ({ battle: b, unit: tgt, buff }) => {
|
||||
if (burstLocked(tgt)) { b.removeBuff(tgt, buff); return; }
|
||||
if (b.time + 1e-9 < buff.data.next) return;
|
||||
buff.data = { next: buff.data.next + s3Iv };
|
||||
elementDmg(b, unit, tgt, 'neural', unit.s.atk * s3Ratio, ['skill', 'dot', 'phatm2:theatre']);
|
||||
if (tgt.alive && burstLocked(tgt)) b.removeBuff(tgt, buff);
|
||||
},
|
||||
});
|
||||
}, { owner: unit });
|
||||
battle.on('elementBurst', (c) => {
|
||||
const e = c.target;
|
||||
if (c.element !== 'neural' || !skillOn(unit, S3) || !up(unit) || !e || e.side !== 'enemy' || !e.alive || !bodyInKeys(e, unit.rangeKeySet)) return;
|
||||
cageAt(battle, unit, e, true);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Her S2's end or her leaving: the 本能的召唤 in stock is gone and the one on the field leaves. */
|
||||
function withdrawCalls(battle, unit) {
|
||||
const stock = unit.mem.summonStock;
|
||||
if (stock) stock[CALL] = 0;
|
||||
for (const t of callsOf(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// server/sim/content/kits/ops/op-slent2.js — 淬羽赫默 (char_1031_slent2) 自选 operator kit: 6★ 护佑者 (辅助), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (BLS-X 难得清醒, BLS-Y 可控动量) at every
|
||||
// form, and the kit of her summon 夜灯 (token_10029_slent2_protrb). Kit contract and the 自选 rules: ../README.md ("How to add
|
||||
// an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_1031_slent2): 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%~70%,以1%为最小单位)线性提升…每当庇护数值变化时,重新为该友方单位附加庇护BUFF"; 丰润羽翼 备注
|
||||
// on the BLS-Y barrier; 俯瞰视界 备注 "技能期间撤回夜灯时,夜灯立刻返回待部署区并进入再部署时间"; 无畏者协议 备注 "只在没有持有
|
||||
// 不死的我方干员正在应用致命伤害时…触发…该效果不会因淬羽赫默的离场消失"), PRTS 夜灯, PRTS 衍生生命 (致命伤害: "无畏者协议提供的
|
||||
// 不死效果仅在目标没有不死效果时才会触发"), and the client's battle data read from the local install — charpack
|
||||
// char_1031_slent2 (talent abilities: slent2_t_1 on the allies of her range every 0.1 s, removed when they leave; slent2_t_2
|
||||
// likewise, its selector ignoring 禁疗 — ignoreHealFree; the S1 / S2 / S3 modes: attacks become heals of max_target injured
|
||||
// allies, the lowest HP share first) and buff_template_data slent2_t_1 / slent2_t_2 / slent2_s_2 / slent2_s_3 /
|
||||
// slent2_e_002[max_hp] / slent2_e_003_t2 (+ [shield]); the template atk_to_hp_recovery is the one PRTS documents as
|
||||
// 「生命回复速度」 for 絮雨 天赋 and 琴柳 S2 ("生命恢复的提供方式为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响").
|
||||
// - Trait (护佑者) "攻击造成法术伤害,技能开启后改为治疗友方单位(治疗量相当于75%攻击力)": ranged arts bolts (the SUPPORT
|
||||
// default; 3×3-and-more y-6, hits air units), blocks 1, ground enemies target her (no flag); while ANY of her skills runs
|
||||
// every attack heals the most injured ally of her range instead (trait heal_scale × ATK; allies = operators and summons).
|
||||
// BLS-X “难得清醒”: heal_scale 1 (100 %). BLS-Y “可控动量” "攻击对2个目标造成法术伤害,技能开启后改为治疗2个友方单位"
|
||||
// (trait attack@max_target 2): two targets / two heals.
|
||||
// Her skills are no heal skills for the 技能策略: a 辅助's basic strategy casts on an enemy she is about to attack (PRTS
|
||||
// 卫戍协议/帮助: only 医疗干员 attack the units needing a heal), so every skill keeps the data's DEFAULT.
|
||||
// - T1 无声砥柱 "使攻击范围内的友军获得10%的庇护…(低于30%生命时获得最大24%的庇护)": every ally of her range (herself
|
||||
// included) holds 庇护 (ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)"; the client's damage_resistance[inf],
|
||||
// the 庇护 the other kits share as `protect` — battle.applyStrongest) of damage_resistance_base × (1 + resistance_scale × n),
|
||||
// n = its lost HP in whole hp_ratio (1 %) steps, at most 1 − min_hp_ratio (70 steps ⇒ 10 % × 2.4 = 24 %), re-read every
|
||||
// 0.1 s (the client's trigger) and gone at once when it leaves her range (a lower value takes over within one refresh).
|
||||
// S3 / the 夜灯 scale damage_resistance_base (the client's `_scaleCertainKeyList`), i.e. the whole value.
|
||||
// - T2 丰润羽翼 "攻击范围内生命低于50%的友军每秒恢复相当于淬羽赫默攻击力5%的生命,【莱茵生命】干员的恢复效果翻倍": while an
|
||||
// ally of her range is below hp_ratio of its max HP it gains 生命回复速度 (an hpRegen buff, checklist 11 — the template
|
||||
// atk_to_hp_recovery, see above: 禁疗 does not stop it, no 治疗加成) of atk_to_hp_recovery_ratio × her ATK, from 1 s after it
|
||||
// dropped below (waitFirstTriggerInterval) and re-read every 1 s; a 【莱茵生命】 operator (character_table groupId "rhine")
|
||||
// gets it twice (the client's [normal] + [rhine] buffs). One buff per 淬羽赫默 (independentCharacterSource).
|
||||
// BLS-X stage 2+ (hidden max_hp; slent2_e_002[max_hp]) "攻击范围内友军的最大生命值+10%": allies of her range max HP
|
||||
// +max_hp (MAX_HP MULTIPLIER = Σpct), ×2 for a 【莱茵生命】 operator; and 7 % ATK per second (the talent change's ratio).
|
||||
// BLS-Y stage 2+ (hidden hp_ratio / scale; slent2_e_003_t2) "其中每个干员低于50%生命时仅一次立刻获得淬羽赫默生命上限50%的屏障":
|
||||
// an operator of her range gets a 屏障 (absorbs every type) of scale × her max HP (×2 for 【莱茵生命】, the client's
|
||||
// multi 2) once per deployment of hers (the card mark, cleared when she leaves): when it enters her range below hp_ratio, or
|
||||
// on a damage instance that would leave it below hp_ratio of its max HP ("在每次“受到伤害前”进行预判"): the barrier takes
|
||||
// that very hit (an `hpDamage` handler run first, after the other shields — PRTS: "能以更早的时间点触发…一定程度抵挡可能致命
|
||||
// 的伤害"). Summons get no barrier ("其中每个干员") [ASSUMED: the text; the client data shows one validator].
|
||||
// - S1 进取之心 (MANUAL, DEFAULT, 25 s): ATK +atk; heals (trait).
|
||||
// - S2 俯瞰视界 (MANUAL, DEFAULT, 12 s): ASPD +attack_speed; heals; "技能期间可以使用一个辅助无人机…技能结束或淬羽赫默离场时
|
||||
// 销毁" (slent2_s_2: WithdrawTokens at its end, the token count recharged): her placed 夜灯 piece takes the field on its tile
|
||||
// when the skill starts (content/tokens.js releaseSkillSummon; like every placed skill summon it also deploys once, free, at
|
||||
// the battle start — PRTS 卫戍协议/帮助) and is withdrawn when the skill ends (into its 5 s redeploy time, PRTS 备注) or when
|
||||
// she leaves the field; a piece already up when the skill starts serves as its drone [ASSUMED: the start deploy lasts until
|
||||
// the first end]. 夜灯 (token kit below): untargetable ("不会受到攻击", data), no attack (ATK 0), blocks nothing; its passive
|
||||
// "使周围八格友军享受2.1倍的淬羽赫默第一天赋效果": the allies of its x-4 (its own tile and the 8 around) hold her T1 庇护 ×
|
||||
// damage_resistance_scale (its skill blackboard: 2.1 / 2.4) — the same shared 庇护, so the stronger value holds.
|
||||
// - S3 无畏者协议 (MANUAL, DEFAULT, 60 s, skill_max_trigger_time 2 uses per battle — the 3rd never comes: no SP after the
|
||||
// 2nd; "可主动关闭" is a manual stop the 技能策略 never makes, so it runs its full time): ATK +atk, T1 ×talent_scale, heals;
|
||||
// once per cast the first operator of her range taking lethal damage (not its own — CheckEntitySuicide; not while it already
|
||||
// holds 不死 — "_dontConsumeWhenUndeadable") keeps ≥ 1 HP and holds 不死 for grave_duration s (slent2_shallow_grave, buff
|
||||
// flag `undying`; it outlasts her leaving). Order of the `fatal` step (items/battle.js): after the kits' own savers, a
|
||||
// 傀儡师's switch and 坚固维式重锤's lock (PRIO_REVIVE −100), before the 复活 (PRIO_RESPAWN −101); the window it opens is
|
||||
// held like the hammer's (PRIO_UNDYING_HELD −99).
|
||||
// Statuses: none applied to enemies. Damage: arts normal attacks only.
|
||||
|
||||
import { num, up, traitBb, moduleBb, talentBb, skillRec, once } from '../shared/tier1.js';
|
||||
import { releaseSkillSummon } from '../../tokens.js';
|
||||
import { holdsUndying, PRIO_UNDYING_HELD, PRIO_REVIVE, PRIO_RESPAWN } from '../../items/battle.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_slent2_1';
|
||||
const S2 = 'skchr_slent2_2';
|
||||
const S3 = 'skchr_slent2_3';
|
||||
export const LAMP = 'token_10029_slent2_protrb';
|
||||
/** 庇护 (ba.protect, the client's damage_resistance[inf]): one "同名效果取最高" effect per unit (battle.applyStrongest). */
|
||||
export const PROTECT = 'protect';
|
||||
const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
|
||||
/** The client's trigger period of slent2_t_1 / slent2_t_2 (talent abilities, triggerInterval 0.1). */
|
||||
export const TALENT_IV = 0.1;
|
||||
/** Lifetime of a refreshed effect: a little longer than the refresh period, so it never lapses while it holds. */
|
||||
const HOLD = TALENT_IV + 0.05;
|
||||
/** atk_to_hp_recovery: its trigger interval (1 s, the first after one interval). */
|
||||
const REGEN_IV = 1;
|
||||
/** BLS-X stage 2+ max HP (slent2_e_002[max_hp]): one "同名" effect per unit. */
|
||||
export const MAXHP_KEY = 'slent2:maxhp';
|
||||
/** S3's 不死 (slent2_shallow_grave). */
|
||||
export const GRAVE_KEY = 'slent2:grave';
|
||||
/** S3's place in the `fatal` step: after the items' 不死 lock (−100), before their 复活 (−101) — see the header. */
|
||||
const PRIO_GRAVE = (PRIO_REVIVE + PRIO_RESPAWN) / 2;
|
||||
/** 夜灯's area when its record carries no grid: its tile and the 8 around (x-4). */
|
||||
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||||
/** 【莱茵生命】 (character_table groupId "rhine": every member, so a 自选 pick counts too). */
|
||||
export const RHINE = Object.freeze(new Set([
|
||||
'char_128_plosis', 'char_135_halo', 'char_242_otter', 'char_108_silent', 'char_249_mlyss', 'char_134_ifrit',
|
||||
'char_1047_halo2', 'char_248_mgllan', 'char_1031_slent2', 'char_4212_nasti', 'char_4048_doroth', 'char_202_demkni',
|
||||
]));
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const tileKey = (r, c) => r * COLS + c;
|
||||
/** An operator of the 莱茵生命 group (summons are no 干员). */
|
||||
export const isRhine = (a) => !!a && a.kind === 'op' && RHINE.has(a.def?.charId ?? a.defId);
|
||||
const inKeys = (keys, a) => !!keys && keys.has(tileKey(a.tileR, a.tileC));
|
||||
|
||||
/**
|
||||
* 无声砥柱's 庇护 for an ally at its HP: base × scale × (1 + rs × n), n = its lost HP in whole `step` steps (at most
|
||||
* 1 − min_hp_ratio) — the client's HpRatioTrigger count, PRTS 备注 "以1%为最小单位".
|
||||
*/
|
||||
export function shelterOf(t, ally, scale = 1) {
|
||||
const base = num(t.damage_resistance_base) * scale;
|
||||
if (!(base > 0)) return 0;
|
||||
const step = num(t.hp_ratio, 0.01) || 0.01;
|
||||
const lost = Math.min(Math.max(0, 1 - ally.hpRatio), Math.max(0, 1 - num(t.min_hp_ratio, 0.3)));
|
||||
const n = Math.floor(lost / step + 1e-9);
|
||||
return Math.min(0.95, base * (1 + num(t.resistance_scale) * n));
|
||||
}
|
||||
|
||||
/** The allies (operators and summons; never a 孤立 one) on absolute tile keys `keys`. */
|
||||
function alliesOn(battle, by, keys) {
|
||||
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && inKeys(keys, a) && battle.allySelectable(a, by));
|
||||
}
|
||||
|
||||
/**
|
||||
* 夜灯's kit: untargetable ("不会受到攻击": the def's flag, Battle._setupUnit), no attack (ATK 0 ⇒ resolveProfile noAttack),
|
||||
* no skill to cast; while it stands, every TALENT_IV s the allies of its x-4 hold her T1 庇护 × damage_resistance_scale.
|
||||
* `owner` = 淬羽赫默, `t1` = her T1 blackboard (fallback when the token def carries none).
|
||||
*/
|
||||
function lampKit(t, owner, t1) {
|
||||
const def = t.def;
|
||||
const tal = (def?.talents ?? []).find((x) => x && x.bb && x.bb.damage_resistance_base != null) ?? null;
|
||||
const tb = tal?.bb ?? t1;
|
||||
const grid = tal?.rangeGrid ?? def?.rangeGrid ?? X4;
|
||||
const scale = num(def?.skill?.bb?.damage_resistance_scale, num(owner.skill?.bb?.damage_resistance_scale, 1));
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
install(battle, lamp) {
|
||||
battle.every(TALENT_IV, () => {
|
||||
if (!up(lamp)) return;
|
||||
const keys = new Set(absoluteRangeKeys(grid, lamp.tileR, lamp.tileC, lamp.dir, 0));
|
||||
for (const a of alliesOn(battle, lamp, keys)) {
|
||||
const v = shelterOf(tb, a, scale);
|
||||
if (v > 0) battle.applyStrongest(a, PROTECT, { duration: HOLD, value: v, mods: protectMods, source: lamp });
|
||||
}
|
||||
}, { owner: lamp, immediate: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
char_1031_slent2: (bb, chess) => {
|
||||
const tb = traitBb(chess);
|
||||
const t1 = talentBb(chess, 0), t2 = talentBb(chess, 1), hidden = moduleBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const targets = Math.max(1, Math.floor(num(tb['attack@max_target'], 1)));
|
||||
const healAttack = Object.freeze({
|
||||
dmgType: 'heal', heal: targets > 1 ? { mode: 'multi', count: targets } : { mode: 'single' },
|
||||
healScale: num(tb.heal_scale, 0.75), projectile: 'none',
|
||||
});
|
||||
const regenRatio = num(t2.atk_to_hp_recovery_ratio), regenBelow = num(t2.hp_ratio, 0.5);
|
||||
const maxHp = num(hidden.max_hp); // BLS-X stage 2+
|
||||
const barrierScale = num(hidden.scale), barrierBelow = num(hidden.hp_ratio, 0.5); // BLS-Y stage 2+
|
||||
const graveDur = num(b3.grave_duration), s3Uses = Math.max(1, Math.floor(num(b3.skill_max_trigger_time, 2)));
|
||||
const talentScale = num(b3.talent_scale, 1);
|
||||
return {
|
||||
trait: targets > 1 ? { maxTargets: targets } : null,
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: healAttack },
|
||||
[S2]: {
|
||||
kind: 'duration', mods: { aspd: num(b2.attack_speed) }, attack: healAttack,
|
||||
onStart({ battle, unit }) {
|
||||
// a 夜灯 already up (the battle-start deploy) is this skill's drone; else her placed piece takes the field
|
||||
if (!battle.allyUnits.some((t) => t.kind === 'token' && t.defId === LAMP && t.ownerUnit === unit && t.alive)) releaseSkillSummon(battle, unit, LAMP);
|
||||
},
|
||||
onEnd({ battle, unit }) { withdrawLamps(battle, unit); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration', mods: { atkPct: num(b3.atk) }, attack: healAttack,
|
||||
onStart({ unit }) { unit.mem.slent2Grave = true; }, // the one 不死 of this cast (slent2_shallow_grave[self])
|
||||
onEnd({ unit }) { unit.mem.slent2Grave = false; },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 无声砥柱 (+ S3 ×talent_scale) and 丰润羽翼 (+ BLS-X max HP, BLS-Y barrier on entering)
|
||||
const regenKey = `talent:slent2:regen#${unit.id}`;
|
||||
const regen = new Map(); // ally → { next: battle time of its next atk_to_hp_recovery trigger }
|
||||
let prevIn = new Set(); // the allies of her range at the previous scan (slent2_t_2 started on them)
|
||||
const stopRegen = (a) => { regen.delete(a); if (a.findBuff(regenKey)) battle.removeBuff(a, regenKey); };
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.slent2Marks = new Set(); prevIn = new Set(); } }, { owner: unit });
|
||||
battle.every(TALENT_IV, () => {
|
||||
if (!up(unit)) {
|
||||
for (const a of [...regen.keys()]) stopRegen(a);
|
||||
prevIn = new Set();
|
||||
return;
|
||||
}
|
||||
const scale = unit.skill?.id === S3 && unit.skill.active ? talentScale : 1;
|
||||
const allies = alliesOn(battle, unit, unit.rangeKeySet);
|
||||
const now = new Set(allies);
|
||||
for (const a of allies) {
|
||||
const v = shelterOf(t1, a, scale);
|
||||
if (v > 0) battle.applyStrongest(a, PROTECT, { duration: HOLD, value: v, mods: protectMods, source: unit });
|
||||
const mult = isRhine(a) ? 2 : 1;
|
||||
if (maxHp > 0) battle.applyStrongest(a, MAXHP_KEY, { duration: HOLD, value: maxHp * mult, mods: (x) => ({ hpPct: x }), source: unit });
|
||||
if (regenRatio > 0 && a.hpRatio < regenBelow - 1e-9) {
|
||||
const st = regen.get(a) ?? { next: battle.time + REGEN_IV };
|
||||
regen.set(a, st);
|
||||
if (battle.time + 1e-9 >= st.next) {
|
||||
st.next += REGEN_IV;
|
||||
battle.addBuff(a, { key: regenKey, mods: { hpRegen: unit.s.atk * regenRatio * mult }, source: unit, tags: ['talent'] });
|
||||
}
|
||||
} else if (regen.has(a)) stopRegen(a);
|
||||
if (barrierScale > 0 && !prevIn.has(a) && a.hpRatio < barrierBelow - 1e-9) giveBarrier(battle, unit, a, null);
|
||||
}
|
||||
for (const a of [...regen.keys()]) if (!now.has(a) || !a.alive) stopRegen(a);
|
||||
prevIn = now;
|
||||
}, { owner: unit, immediate: true });
|
||||
// BLS-Y: a damage instance that would leave an operator of her range below barrierBelow — the barrier takes it
|
||||
if (barrierScale > 0) {
|
||||
battle.on('hpDamage', (c) => {
|
||||
const a = c.target;
|
||||
if (!up(unit) || !a || a.side !== 'ally' || !(c.amount > 0) || !inKeys(unit.rangeKeySet, a)) return;
|
||||
if (a.hp - c.amount < a.s.maxHp * barrierBelow - 1e-9) giveBarrier(battle, unit, a, c);
|
||||
}, { owner: unit, priority: 100 });
|
||||
}
|
||||
/** BLS-Y barrier on operator `a` (once per deployment of hers); `c` = the hpDamage ctx it takes, if any. */
|
||||
function giveBarrier(b, u, a, c) {
|
||||
const marks = u.mem.slent2Marks ?? (u.mem.slent2Marks = new Set());
|
||||
if (a.kind !== 'op' || marks.has(a.id)) return;
|
||||
marks.add(a.id);
|
||||
let cap = u.s.maxHp * barrierScale * (isRhine(a) ? 2 : 1);
|
||||
if (c) { const took = Math.min(cap, c.amount); c.amount -= took; cap -= took; }
|
||||
if (cap > 1e-6) b.addBuff(a, { key: `slent2:barrier#${u.id}`, shield: cap, source: u, visible: true, tags: ['talent'] });
|
||||
b.fx('shield', { x: a.x, y: a.y, id: a.id });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 夜灯: her placed pieces run the drone's kit; withdrawn when she leaves the field
|
||||
for (const t of battle.allyUnits) {
|
||||
if (t.kind !== 'token' || t.defId !== LAMP || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||||
// (no offOwner for a piece set up before her: its hooks are content/tokens.js's dock hooks — the generic token
|
||||
// kit registers none — and they must stay)
|
||||
battle._setupUnit(t, lampKit(t, unit, t1));
|
||||
}
|
||||
battle.on('death', (c) => { if (c.unit === unit) withdrawLamps(battle, unit); }, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
if (unit.skill?.id !== S3) return;
|
||||
// 同一次作战中最多使用2次: after the last use no SP comes back (activations count every deployment of the battle)
|
||||
battle.on('spGain', (c) => {
|
||||
if (c.unit === unit && !unit.skill.active && unit.skill.activations >= s3Uses) c.amount = 0;
|
||||
}, { owner: unit });
|
||||
// the one 不死 of each cast: the first operator of her range taking lethal damage (see the header)
|
||||
battle.on('fatal', (c) => {
|
||||
const a = c.unit;
|
||||
if (c.prevented || !unit.mem.slent2Grave || !unit.skill?.active || !up(unit)) return;
|
||||
if (!a || a.side !== 'ally' || a.kind !== 'op' || !inKeys(unit.rangeKeySet, a)) return;
|
||||
if (c.source === a || c.credit === a || holdsUndying(battle, a)) return;
|
||||
c.prevented = true;
|
||||
unit.mem.slent2Grave = false;
|
||||
battle.addBuff(a, { key: GRAVE_KEY, duration: graveDur, flags: { undying: true }, source: unit, visible: true, tags: ['skill'] });
|
||||
battle.fx('undying', { x: a.x, y: a.y, id: a.id, duration: graveDur });
|
||||
}, { owner: unit, priority: PRIO_GRAVE });
|
||||
// the 不死 window, held whoever opened it (one battle-wide hook, like 坚固维式重锤's running windows)
|
||||
once(battle, 'slent2:grave', () => {
|
||||
battle.on('fatal', (c) => { if (!c.prevented && c.unit && c.unit.findBuff(GRAVE_KEY)) c.prevented = true; }, { priority: PRIO_UNDYING_HELD });
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** 俯瞰视界's end or her leaving: her 夜灯 leave the field (into their redeploy time) and the drone in stock is gone. */
|
||||
function withdrawLamps(battle, unit) {
|
||||
const stock = unit.mem.summonStock;
|
||||
if (stock) stock[LAMP] = 0;
|
||||
for (const t of battle.allyUnits) {
|
||||
if (t.kind === 'token' && t.defId === LAMP && t.ownerUnit === unit && t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user