feedback5: merge O1 — 自选 kits for 伊芙利特, 谬因 (with her 中继器) and 林; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 00:22:52 +08:00
commit fef4ea3d4b
8 files changed
+1936 -120

No files matched your search

+3
View File
@@ -100,6 +100,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-huang.js',
'op-shwaz.js',
'op-bgsnow.js',
'op-ifrit.js',
'op-aphris.js',
'op-lin.js',
]);
/**
+294
View File
@@ -0,0 +1,294 @@
// server/sim/content/kits/ops/op-aphris.js — 谬因 (char_4229_aphris) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module and her summon “中继器” at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4229_aphris, 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). Potential 0 [ASSUMED: no account].
// Summon: tokens.token_10070_aphris_pc “中继器” (talent 链路协议; a hand piece the player places with a facing — data
// `placeable`, a talent summon: it deploys with the board, docs/SIM.md §1.1 token pieces).
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 谬因 (链路协议 备注:
// her range "以自己所在格为起点向部署方向延伸,直至达到延伸距离上限" — 5 tiles from E1 —, "若延伸到了自身的中继器所在的地块,会令延伸
// 距离上限增加(并非增加谬因的攻击距离属性)并改变接下来的延伸方向", "尝试延伸到已延伸到的地块或地图边界外时延伸会提前终止", "索敌与攻击
// 始终采用格子判定", "攻击力增加效果不可叠加"; 取样优化 备注 "无视法抗效果为增加受益者的法术穿透属性(直接加算)"; 连续映射 备注
// "中继器持续时间结束时不再进行自动撤退"; 临界瞬爆 备注 "技能持续期间,自身的攻击范围可无限延伸直至无法继续延伸", "持有孤立的我方干员
// 也可令谬因攻击范围延伸效果转向"; 混沌的本质 备注 "技能开启后3秒内持有强制缴械", "“停顿时间延长”指的是增加本次攻击对该敌人施加的
// 停顿的时间", "技能开始/结束时强制撤回所有中继器", 修正 "清空再部署"); PRTS 中继器 (备注: 无敌, 禁疗, 孤立, 阻挡数 0, 再部署策略
// 队列); PRTS 卫戍协议/帮助 §作战阶段 ("若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"); the client's
// battle data (charpack char_4229_aphris — the range selectors, the talent abilities; battle/prefabs [uc]skills skchr_aphris_1/2/3;
// buff_template_data aphris_token_add_atk_tohost, aphris_pc_t[with-draw], aphris_sk1(_token), aphris_sk2_switch_mode4,
// aphris_sk3 / [token-withdraw] / [on_finish], die_to_kill_token, charge_token[born]).
// - Range (链路协议; her selectors `_extendable`, `_extendValidator` = the 中继器, `_extendLengthEveryValidateChar` 2): a path,
// not her data grid — from her tile along her facing, one tile at a time, up to her 5-1 length; reaching her own 中继器
// adds 2 to the length still allowed and turns the path to that piece's facing; it stops at a tile it already holds or at
// the map's edge. The path is her range (`unit.rangeGrid`, rebuilt when a piece or an operator comes or goes, at her
// skill's start / end, and checked every tick): her attacks, the DEFAULT trigger, 取样优化 and the detail card all read it.
// [ASSUMED] a huge enemy counts on every tile it covers (the engine's selection; PRTS: 格子判定 by its position), her hits
// land at once (the client spaces them `_hitInterval` 0.05 s along the beam) and a 攻击距离 bonus (none exists for her in
// this mode) would extend it as the engine extends any range.
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her path at once,
// air units too. Module BLA-Y “第三相态”: 部署费用 −8 (the attribute; stats 34 → 26), HP / ATK in the stats, no trait change;
// stage 3 changes T1 (35 s piece, 再部署时间变短 — the piece's byModule respawnTime 30 → 20 —, ATK +35 %).
// - T1 链路协议: one 中继器 lasting the token talent's duration (25 s; BLA-Y stage 3: its hidden module talent, 35 s) from each
// deployment (aphris_pc_t[with-draw]: it withdraws when that time is up unless it holds aphris_sk1_token — given to every
// piece of hers when 连续映射 starts, and to a piece taking the field while 连续映射 or 混沌的本质 runs). Back on its tile its
// respawnTime (30 / 20 s) after it left, paying its 3 DP — the 卫戍 auto redeploy of a placed summon, as 斯卡蒂的海嗣 —, and
// only while 谬因 stands: her leaving the field withdraws it (die_to_kill_token), her (re)deployment makes it ready at once
// (charge_token[born]). ATK +atk while one of hers stands (aphris_token_add_atk_tohost: one buff aphris_t1[atk], "不可叠加").
// The piece "不会受到攻击" (data: untargetable) and holds 无敌, 禁疗 and 孤立 (PRTS 中继器; [ASSUMED] given by this kit — the
// data's `abnormal` lacks them).
// - T2 取样优化 "攻击范围内的友方干员攻击时无视敌方10点法术抗性" (aphris[t2]: attribute 27, 法术穿透, on allied operators — profession
// mask 639 — of her attack range): resIgnoreFlat +magic_resist_penetrate_fixed on every allied operator standing on her path,
// her own tile included [ASSUMED: the selector does not drop its owner], refreshed every AURA_IV s; several sources keep the
// strongest.
// - S1 连续映射 (AUTO, 持续时间无限): ATK +atk (aphris_sk1) and the 中继器 marks above. An AUTO skill acting on herself fires at
// full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md checklist 5); `toggle`.
// - S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): mode 4 (aphris_sk2_switch_mode4) — her normal attack is replaced by the beam
// ability (`_preDelay` 0.167 = the Skill_2_Begin clip, then `_triggerDelta` 0.5; MAGICAL atk_scale): from BEAM_FIRST on,
// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path
// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied
// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too),
// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's
// ACTIVE_RANGE rule cannot see it (reported, not hand-coded).
// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械
// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its
// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the
// skill allows have no piece to place in the auto battle [ASSUMED: N/A]); attack interval +base_attack_time (−1.1 s on
// 2.9 s), ATK +atk, each attack attack@atk_scale × ATK on her path, and on every enemy it hits the buff aphris_sk3: 停顿
// origin_sluggish + extra_sluggish × n s and n hits of extra_atk_scale × ATK arts, n = the 中继器 the path passed before the
// enemy, at most max_times (1). It ends with its bullets; aphris_sk3[on_finish] withdraws every 中继器 and clears their
// redeploy time again (so the piece is back at once, now with its normal lifetime).
import { num, talentBb, skillRec, up, batMod, toggleBuff } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { dirVec, toLocal } from '../../../dir.js';
const S1 = 'skchr_aphris_1';
const S2 = 'skchr_aphris_2';
const S3 = 'skchr_aphris_3';
/** Her summon “中继器” (talent 链路协议). */
const RELAY = 'token_10070_aphris_pc';
/** Extra path length per 中继器 (the selectors' `_extendLengthEveryValidateChar`; "使该次攻击距离+2"). */
const RELAY_REACH = 2;
/** Talent-2 aura refresh period and buff length (the non-stacking aura convention of the kits). */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
/** S2 临界瞬爆: the first beam hit (skchr_aphris_2 `_preDelay`, the Skill_2_Begin clip's length), then every `interval` s. */
const BEAM_FIRST = 0.167;
/** How often a withdrawn 中继器 tries to come back once ready (her on the field, its tile free, DP paid). */
const RELAY_RETRY = 0.1;
const LINK_KEY = 'aphris:link';
const SAMPLING_KEY = 'aphris:sampling';
const CHARGE_KEY = 'aphris:charge';
const RELAY_ABNORMAL_KEY = 'aphris:relayAbnormal';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 中继器 of `unit`. */
const isRelayOf = (t, unit) => !!t && t.kind === 'token' && t.defId === RELAY && t.ownerUnit === unit;
const relaysOf = (battle, unit) => battle.allyUnits.filter((t) => isRelayOf(t, unit));
/** 连续映射 / 混沌的本质 running: a 中继器 taking the field now gets the no-withdraw mark (aphris_pc_t[with-draw]). */
const marksRelays = (unit) => !!(unit.skill && unit.skill.active && (unit.skill.id === S1 || unit.skill.id === S3));
/** Her own path length: the farthest forward tile of her data grid (5 on her E2 5-1). */
function baseReach(unit) {
let n = 0;
for (const p of unit.def?.rangeGrid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1];
return n;
}
/** A 中继器's lifetime: the last `duration` of its talents (the BLA-Y stage-3 hidden talent after the base one). */
function relayLife(t) {
let life = 0;
for (const x of t.def?.talents ?? []) if (x && x.bb && x.bb.duration != null) life = num(x.bb.duration, life);
return life;
}
/**
* Her 延伸 path (header): absolute tiles in order from her own, with `index` (tile key → position) and `relays` (the
* positions of the 中继器 tiles it passed). `beam` = S2: no length limit, every allied operator turns it too.
*/
function aphrisPath(battle, unit, beam = false) {
const tiles = [[unit.tileR, unit.tileC]];
const index = new Map([[unit.tileR * COLS + unit.tileC, 0]]);
const relays = [];
let [dr, dc] = dirVec(unit.dir);
let reach = beam ? Infinity : baseReach(unit);
let r = unit.tileR, c = unit.tileC, steps = 0;
while (steps < reach) {
const nr = r + dr, nc = c + dc;
const k = nr * COLS + nc;
if (!battle.grid.inBounds(nr, nc) || index.has(k)) break;
index.set(k, tiles.length);
tiles.push([nr, nc]);
r = nr; c = nc; steps++;
const o = battle.unitAt(nr, nc);
if (o && isRelayOf(o, unit)) {
reach += RELAY_REACH;
[dr, dc] = dirVec(o.dir);
relays.push(tiles.length - 1);
} else if (beam && o && o !== unit && o.kind === 'op' && o.side === 'ally') [dr, dc] = dirVec(o.dir);
}
return { tiles, index, relays };
}
/** Rebuild her range from the path when it changed (the relative grid of the path in her facing-RIGHT frame). */
function refreshPath(battle, unit) {
if (!up(unit)) return;
const beam = !!(unit.skill && unit.skill.active && unit.skill.id === S2);
const p = aphrisPath(battle, unit, beam);
const sig = p.tiles.map(([r, c]) => r * COLS + c).join(',');
unit.mem.aphrisPath = p;
if (unit.mem.aphrisSig === sig) return;
unit.mem.aphrisSig = sig;
unit.rangeGrid = p.tiles.map(([r, c]) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir));
battle.refreshRange(unit);
}
/** How many 中继器 her path passed before `e`'s tile (S3 aphris_sk3 "extendTimes"). */
function relaysBefore(unit, e) {
const p = unit.mem.aphrisPath;
if (!p || !p.relays.length) return 0;
const i = p.index.get(Math.round(e.y) * COLS + Math.round(e.x));
return i == null ? 0 : p.relays.filter((j) => j < i).length;
}
/** Withdraw every 中继器 of hers that stands; `clear` = their redeploy time cleared (ResetTokenRespawnTime). */
function withdrawRelays(battle, unit, clear) {
for (const t of relaysOf(battle, unit)) {
if (t.alive) {
t.mem.relayCleared = clear;
battle.retreat(t, { reason: 'expired', permanent: true });
} else if (clear && !t.removed) t.mem.relayReadyAt = battle.time;
}
}
/** A 中继器 of hers took the field: its abnormal flags and redeploy rule once, its lifetime and mark every time. */
function relayDeployed(battle, unit, t) {
if (!t.mem.aphrisRelay) {
t.mem.aphrisRelay = true;
battle.addBuff(t, { key: RELAY_ABNORMAL_KEY, flags: { invulnerable: true, isolated: true, noHeal: true }, persist: true, allowDead: true });
// back on its tile its respawnTime after it left (at once when its redeploy time was cleared), while she stands,
// paying its DP — a token is otherwise removed for good: the piece is kept (`removed = false`) so its hooks survive
// and `battle.redeploy` accepts it
battle.on('death', (c) => {
if (c.unit !== t || battle.finished) return;
t.removed = false;
t.mem.relayReadyAt = battle.time + (t.mem.relayCleared ? 0 : Math.max(0, num(t.base.respawnTime)));
t.mem.relayCleared = false;
if (t.mem.relayRetry) return;
t.mem.relayRetry = battle.every(RELAY_RETRY, (b, sched) => {
const stop = () => { sched.cancel(); t.mem.relayRetry = null; };
if (t.alive || t.removed || b.finished) { stop(); return; }
if (!up(unit) || b.time + 1e-9 < t.mem.relayReadyAt) return;
if (b.redeploy(t, { free: false })) stop();
}, { owner: t });
}, { owner: t, priority: -10 });
}
t.mem.relayKept = marksRelays(unit); // aphris_pc_t[with-draw] ON_BUFF_START: aphris_sk1_token
const life = relayLife(t);
if (!(life > 0)) return;
const seq = t.deploySeq;
battle.after(life, () => {
if (!t.alive || t.deploySeq !== seq || t.mem.relayKept) return; // 连续映射's mark: "不再进行自动撤退"
battle.retreat(t, { reason: 'expired', permanent: true });
}, { owner: t });
}
export default {
char_4229_aphris: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
skills: {
[S1]: {
kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk) },
onStart({ battle, unit }) { for (const t of relaysOf(battle, unit)) if (t.alive) t.mem.relayKept = true; }, // aphris_sk1 → aphris_sk1_token
},
[S2]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ battle, unit }) {
unit.mem.beamAt = 0;
unit.mem.beamNext = BEAM_FIRST;
battle.fx('beam', { x: unit.x, y: unit.y, id: unit.id, skill: 'aphris:beam' });
},
onTick({ battle, unit, skill, dt }) {
const iv = num(b2.interval, 0.5);
if (!(iv > 0)) return;
const t = (unit.mem.beamAt = (unit.mem.beamAt ?? 0) + dt);
while (t >= unit.mem.beamNext - 1e-6 && skill.active && unit.alive) {
unit.mem.beamNext += iv;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'aphris:beam'] });
}
}
},
},
[S3]: {
kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20),
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
attack: {
atkScale: num(b3['attack@atk_scale'], 1),
onEachHit({ battle, unit, target }) { // aphris_sk3 on every enemy the attack hits
if (!target || target.side !== 'enemy' || !target.alive) return;
const max = num(b3['attack@max_times'], 1);
const n = max >= 0 ? Math.min(max, relaysBefore(unit, target)) : relaysBefore(unit, target);
const slow = num(b3['attack@origin_sluggish']) + num(b3['attack@extra_sluggish']) * n;
if (slow > 0) battle.applyStatus(target, 'sluggish', { duration: slow, source: unit });
const ex = num(b3['attack@extra_atk_scale']);
for (let i = 0; i < n && target.alive && ex > 0; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * ex, type: 'arts', isSkill: true, tags: ['skill', 'aphris:relay'] });
},
},
onStart({ battle, unit }) {
const ct = num(b3.charge_time);
if (ct > 0) battle.addBuff(unit, { key: CHARGE_KEY, duration: ct, flags: { disarm: true }, tags: ['skill'] }); // 强制缴械
withdrawRelays(battle, unit, true); // aphris_sk3[token-withdraw]
},
onEnd({ battle, unit }) {
battle.removeBuff(unit, CHARGE_KEY);
withdrawRelays(battle, unit, true); // aphris_sk3[on_finish]
},
},
},
talents: [
{ install(battle, unit) { // 链路协议: ATK +atk while one of her 中继器 stands (one buff however many)
const a = num(t0.atk);
if (a) toggleBuff(battle, unit, LINK_KEY, () => relaysOf(battle, unit).some((t) => t.alive && t.deployed), { atkPct: a });
// die_to_kill_token: her pieces leave with her; charge_token[born]: her (re)deployment makes a waiting one ready
battle.on('death', (c) => { if (c.unit === unit) withdrawRelays(battle, unit, false); }, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of relaysOf(battle, unit)) if (!t.alive && !t.removed) t.mem.relayReadyAt = battle.time;
}, { owner: unit });
} },
{ install(battle, unit) { // 取样优化: 法术穿透 +v on the allied operators of her path
const v = num(t1.magic_resist_penetrate_fixed);
if (!(v > 0)) return;
battle.every(AURA_IV, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const a of battle.alliesFor(unit)) {
if (a.kind !== 'op' || !unit.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
const cur = a.findBuff(SAMPLING_KEY);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key: SAMPLING_KEY, duration: AURA_DUR, mods: { resIgnoreFlat: v }, source: unit, data: { v }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
} },
],
install(battle, unit) {
// the 延伸 path: rebuilt on any ally's arrival / departure (her 中继器, an operator S2 bends on), her skill's
// start / end, and checked every tick
battle.on('deploy', (c) => {
const t = c.unit;
if (t === unit) unit.mem.aphrisSig = null;
else if (isRelayOf(t, unit)) relayDeployed(battle, unit, t);
if (t && t.side === 'ally') refreshPath(battle, unit);
}, { owner: unit });
battle.on('death', (c) => { if (c.unit && c.unit.side === 'ally' && c.unit !== unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('tick', () => refreshPath(battle, unit), { owner: unit });
// S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly
// checks `disarm` before the cast only) — no target, no attack, no bullet spent
battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit });
},
};
},
};
+215
View File
@@ -0,0 +1,215 @@
// server/sim/content/kits/ops/op-ifrit.js — 伊芙利特 (char_134_ifrit) 自选 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_134_ifrit, 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). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 伊芙利特 (炎爆 备注
// "灼伤:目标每秒受到伊芙利特当前攻击力33%的法术持续伤害,伊芙利特离场后也会生效" and "<模组相关>于伊芙利特在场时受模组基础效果影响";
// 灼地 备注 "属于维持技能状态,时间模式为跟随动画", "降低法术抗性的效果持续1秒"; 精神融解 备注 "<Δ模组>造成的元素伤害为持续伤害";
// “同熔” 特性 "目标距离自身0格或以上时应用加成,按距离线性提升,目标距离自身4格或以上时达到最大值"); PRTS 游戏数据基础 §维持技能状态;
// PRTS 分支特性信息 轰击术师 ("可对空", "普通攻击同时攻击范围内的所有敌人"); the client's battle data (battle/prefabs [uc]skills
// skchr_ifrit_1/2/3, [uc]equips ifrit_equip_*, charpack char_134_ifrit, buff_template_data: burn, atk_scale_by_distance,
// periodic_damage_by_hp_ratio[skip_modifier_fix], ifrit_e_002[sp] / [dice_sp], ifrit_e_003_tr, ifrit_e_003_ele) and her
// battle skeleton (Skill_2_Begin's OnAttack event).
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her 5-1 line at
// once, air units too, the same damage near and far (professions.js blastcaster).
// - Module BLA-X “同熔” (trait min_dist 0 / max_dist 4 / damage_scale 0.1): "距离越远伤害越高,最高达到110%" — ×(1 + 0.1 ×
// clamp((d − 0) / (4 − 0), 0, 1)) by the Euclidean distance d between her and the target, on EVERY damage she outputs
// while on the field (the module's buff atk_scale_by_distance: ON_OUTPUT_DAMAGE, no damage mask) — her attacks, the 灼地
// ticks and the 炎爆 灼伤 (PRTS: "于伊芙利特在场时受模组基础效果影响"). Stage 3 also changes T2 (below).
// - Module BLA-D “热成形记忆” (trait ep_damage_ratio 0.08): "造成法术伤害时附带相当于8%伤害的灼燃损伤" (ifrit_e_003_tr: ON_AFTER_
// OUTPUT_DAMAGE, every MAGICAL damage of hers) — 8 % of the HP damage dealt as 灼燃损伤 (`type: 'element'`, docs/SIM.md §7.2
// element conventions), the 灼伤 too while she stands. Stage 3 also changes T1 (below).
// - T1 精神融解 "攻击范围内的敌军法术抗性-40%" (magic_resistance −0.4: a ratio; charpack ifrit_t_1, removed when the enemy leaves):
// RES ×0.6 on every enemy on her range, refreshed every AURA_IV s; several 伊芙利特 keep one (applyStrongest — the mode's
// "同名效果取最高" convention; [ASSUMED] the client's independentCharacterSource flag would let two stack, which a lone
// base-game 伊芙利特 never shows). BLA-D stage 2+ "若攻击目标处于灼燃损伤爆发期间,攻击对其额外造成相当于攻击力50%的元素伤害" (the
// module's hidden talent element_atk_scale; ifrit_e_003_ele: IsTargetInEPBreakRecovery FIRE ⇒ ELEMENT damage, attack type
// BUFF, 持续伤害): every hit of her attack abilities — normal attacks and the 灼地 ticks (the equip adds it to the abilities of
// `_targetFamilyMask` 31, [ASSUMED] every family: 1 / 3 mark the narrower equips) — on an enemy in its 灼燃 burst (its
// `burnBurst` lock) adds 50 % ATK 元素伤害
// (`type: 'elemental'`), tagged 'dot' (PRTS: "<Δ模组>造成的元素伤害为持续伤害"); never the 灼伤 (a buff's damage).
// - T2 莱茵回路 "每6秒额外回复2点技力" (periodic_sp; BLA-X stage 2+ ifrit_e_002[sp]): +sp SP every `interval` s of a deployment
// (the first one interval in: waitFirstTriggerInterval; a gift during a running 狂热 / 灼地 is lost — no SP during a
// skill). BLA-X stage 2+ "有30%概率额外回复5点技力" (ifrit_e_002[dice_sp] prob / interval / sp): one roll per its own interval.
// - S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +atk, ASPD +attack_speed.
// - S2 炎爆 (AUTO, 可充能 2 / 3 次, data DEFAULT — the "next attack" waits for her attack): the next attack hits her whole line
// (ground and air: its selector's targetMotion ALL) for atk_scale × ATK; every enemy it hits gets the buff ifrit_s_2
// (template burn): DEF +def (−100 / −200, "同名效果取最高") for `duration` (3.01) s and the 灼伤 — burn.atk_scale (33 %) × her
// CURRENT ATK arts every second of it (triggerInterval 1, the first a second in: 3 ticks), a 持续伤害 (no dodge) that keeps
// ticking after she leaves the field. A new 炎爆 on the same enemy restarts both.
// - S3 灼地 (MANUAL, data DEFAULT, 20 s): a 维持技能状态 — no normal attack. The ability (skchr_ifrit_3: `_triggerDelta` 1,
// ground-only selector) strikes first at the attack event of its Begin clip (SCORCH_FIRST: 0.4 s; facing UP / DOWN the
// back / down clips, 0.333 s) and then every S3_CADENCE (1) s — a fixed cadence no ASPD change touches: every ground enemy
// on her range takes atk_scale × ATK arts and the active buff ifrit_s_3, RES +magic_resistance (−7 / −10, flat) for 1 s
// (PRTS 备注) — the cut before the damage [ASSUMED order], so the tick it comes with counts it. Her own periodic_damage_by_
// hp_ratio buff takes hp_ratio (2 %) of her max HP every second of the skill (the first a second in; a 流失 — it ignores
// for SP and skips the damage events —, it can knock her out; PRTS "实际的生命流失量略低于标注值" gives no number [ASSUMED:
// the stated 2 %]). 晕眩 / 冻结 (any state that stops her acting) ends it at once (PRTS 维持技能状态).
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skchr_ifrit_1';
const S2 = 'skchr_ifrit_2';
const S3 = 'skchr_ifrit_3';
/** T1 aura refresh period and buff length (the non-stacking aura convention of the kits). */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
/** S3 灼地: the ability's `_triggerDelta` (s) — one damage tick per second, "每秒". */
const S3_CADENCE = 1;
/** S3 灼地: the first tick at the OnAttack event of Skill_2_Begin (front 0.4 s; the back clip and Skill_Down_2_Begin 0.333 s). */
const SCORCH_FIRST = Object.freeze({ side: 0.4, vertical: 0.333 });
/** S3 灼地 RES cut length (ifrit_s_3 lifeTime 1; PRTS 备注 "降低法术抗性的效果持续1秒"). */
const S3_RES_TIME = 1;
/** S3 灼地 self 流失 period (periodic_damage_by_hp_ratio triggerInterval 1). */
const S3_LOSS_IV = 1;
const MELT_KEY = 'ifrit:meltdown';
const S2_DEF_KEY = 'ifrit:pyroDef';
const S2_BURN_KEY = 'ifrit:pyroBurn';
const S3_RES_KEY = 'ifrit:scorchRes';
const SCORCH_TAG = 'ifrit:scorch';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** RES cut mods for a blackboard magic_resistance value (applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
const defCut = (v) => ({ defFlat: v });
/** S2 炎爆 on one enemy its attack hit: DEF cut and the 灼伤 for `duration` s. */
function pyroclasm(battle, unit, e, b2) {
if (!e || !e.alive || e.side !== 'enemy') return;
const dur = num(b2.duration, 3);
const d = num(b2.def);
if (d) battle.applyStrongest(e, S2_DEF_KEY, { duration: dur, value: d, mods: defCut, source: unit });
const sc = num(b2['burn.atk_scale']);
if (!(sc > 0)) return;
battle.addBuff(e, {
key: S2_BURN_KEY, duration: dur, interval: 1, source: unit, tags: ['dot'],
// "伊芙利特当前攻击力": her ATK at each tick; it ticks on after she leaves the field (PRTS 备注)
onTick: ({ battle: b, unit: tgt }) => b.dealDamage(unit, tgt, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'skill', 'ifrit:burn'] }),
});
}
/** S3 灼地 tick: every ground enemy on her range — the RES cut, then the damage. */
function scorch(battle, unit, b3) {
const scale = num(b3.atk_scale), mr = num(b3.magic_resistance);
const victims = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false });
for (const e of victims) {
if (mr) battle.applyStrongest(e, S3_RES_KEY, { duration: S3_RES_TIME, value: mr, mods: resCut, source: unit });
if (e.alive && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', SCORCH_TAG] });
}
battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id, n: victims.length, skill: SCORCH_TAG });
}
export default {
char_134_ifrit: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hidden = moduleBb(chess); // BLA-D stage 2+: element_atk_scale
const tb = traitBb(chess); // BLA-X: min_dist / max_dist / damage_scale; BLA-D: ep_damage_ratio
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b2.atk_scale, 1),
onEachHit({ battle, unit, target }) { pyroclasm(battle, unit, target, b2); },
},
onStart({ battle, unit }) { battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.5, id: unit.id, skill: 'ifrit:pyroclasm' }); },
},
[S3]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ unit }) {
unit.mem.scorchAt = 0;
unit.mem.scorchNext = unit.dir === 'UP' || unit.dir === 'DOWN' ? SCORCH_FIRST.vertical : SCORCH_FIRST.side;
unit.mem.scorchLoss = 0;
},
onTick({ battle, unit, skill, dt }) {
// 维持技能状态: a hard control (晕眩 / 冻结 / 浮空 / 睡眠 — the unit cannot act) ends it at once
if (!unit.canAct) { skill.end('interrupted'); return; }
const t = (unit.mem.scorchAt = (unit.mem.scorchAt ?? 0) + dt);
while (t >= unit.mem.scorchNext - 1e-6 && unit.alive && skill.active) {
unit.mem.scorchNext += S3_CADENCE;
scorch(battle, unit, b3);
}
const ratio = num(b3.hp_ratio);
while (ratio > 0 && t >= (unit.mem.scorchLoss + 1) * S3_LOSS_IV - 1e-6 && unit.alive) {
unit.mem.scorchLoss++;
battle.loseHp(unit, unit.s.maxHp * ratio, { source: unit, tags: ['skill', SCORCH_TAG] });
}
},
},
},
talents: [
{ install(battle, unit) { // 精神融解: RES −40 % on her range; BLA-D stage 2+: +50 % ATK 元素伤害 on a bursting target
const v = num(t0.magic_resistance);
if (v) {
battle.every(AURA_IV, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue;
battle.applyStrongest(e, MELT_KEY, { duration: AURA_DUR, value: v, mods: resCut, source: unit });
}
}, { owner: unit, immediate: true });
}
const ex = num(hidden.element_atk_scale);
if (ex > 0) {
battle.on('damaged', (c) => {
const e = c.target, d = c.dmg;
if (c.source !== unit || !d || !(d.isAttack || d.tags?.includes(SCORCH_TAG)) || !e || e.side !== 'enemy' || !hasHp(e) || !e.findBuff('burnBurst')) return;
battle.dealDamage(unit, e, { amount: unit.s.atk * ex, type: 'elemental', element: 'burn', canDodge: false, tags: ['dot', 'module', 'ifrit:meltdown'] });
}, { owner: unit });
}
} },
{ install(battle, unit) { // 莱茵回路: +sp SP every interval s; BLA-X stage 2+: prob to +5 SP more
const sp = num(t1.sp), iv = num(t1.interval, 6);
const dProb = num(t1['ifrit_e_002[dice_sp].prob']), dSp = num(t1['ifrit_e_002[dice_sp].sp']);
const dIv = num(t1['ifrit_e_002[dice_sp].interval'], iv);
if (!(sp > 0 && iv > 0) && !(dProb > 0 && dSp > 0 && dIv > 0)) return;
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.rhineAcc = 0; unit.mem.rhineDice = 0; } }, { owner: unit });
battle.on('tick', ({ dt }) => {
if (!up(unit)) return;
if (sp > 0 && iv > 0) {
unit.mem.rhineAcc = (unit.mem.rhineAcc ?? 0) + dt;
while (unit.mem.rhineAcc >= iv - 1e-6) { unit.mem.rhineAcc -= iv; giveSp(unit, sp); }
}
if (dProb > 0 && dSp > 0 && dIv > 0) {
unit.mem.rhineDice = (unit.mem.rhineDice ?? 0) + dt;
while (unit.mem.rhineDice >= dIv - 1e-6) {
unit.mem.rhineDice -= dIv;
if (battle.rng.chance(dProb)) giveSp(unit, dSp);
}
}
}, { owner: unit });
} },
],
install(battle, unit) {
// BLA-X “同熔”: farther targets take more of every damage she outputs (up to ×1 + damage_scale at max_dist tiles)
const ds = num(tb.damage_scale);
if (ds > 0 && tb.max_dist != null) {
const lo = num(tb.min_dist), hi = num(tb.max_dist, 4);
battle.on('hit', (c) => {
const e = c.target;
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return;
const d = Math.hypot(e.x - unit.x, e.y - unit.y);
c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
}, { owner: unit });
}
// BLA-D “热成形记忆”: 8 % of each arts damage she deals as 灼燃损伤
const ep = num(tb.ep_damage_ratio);
if (ep > 0) {
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || c.type !== 'arts' || !(c.amount > 0) || !e || e.side !== 'enemy' || !hasHp(e) || !up(unit)) return;
battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: c.amount * ep, tags: ['module', 'ifrit:thermal'] });
}, { owner: unit });
}
},
};
},
};
+228
View File
@@ -0,0 +1,228 @@
// server/sim/content/kits/ops/op-lin.js — 林 (char_4080_lin) 自选 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_4080_lin, 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). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 林 (计出万全 备注:
// "通常情况下琉璃璧生效优先级为+1000", "琉璃璧破碎效果于满足条件的伤害应用前瞬间触发" — so a lethal hit still sets it off —,
// "其破碎时的影响范围以持有该效果的单位为中心,与林的当前攻击范围一致,先造成晕眩后造成伤害", "可造成琉璃璧破碎的伤害结算时,伤害量减少
// 相当于琉璃璧吸收伤害上限的数值"; 玲珑 备注 "非初始状态下…技能开启时的效果均会生效,但此状态下并不实际属于“技能期间”"; 荫庇 备注
// "每个干员单独获得琉璃壁,单独计算琉璃壁破碎与恢复时间", "伤害计算使用林的攻击力,伤害来源为林自身"; 流光乍裂 备注 "第一天赋生效的
// 优先级从+1000变为±0,琉璃璧破碎效果于满足条件的伤害应用后瞬间触发", "仅自身普通攻击造成击杀时可触发琉璃璧破碎/生成效果,单次普攻
// 最多触发1次效果"); PRTS 卫戍协议/帮助 §技能操作 ("携带状态切换类技能的干员…每次部署后仅开启一次技能"; 阵法术师 row "在初始攻击范围内
// 存在敌人时释放技能"; 林【流光乍裂】 x-2 "在自定义范围内存在敌人时释放技能"); PRTS 分支特性信息 阵法术师 ("攻击时可对空"); the client's
// battle data (charpack char_4080_lin, battle/prefabs [uc]skills skchr_lin_1/2/3, buff_template_data lin_t_1 / lin_t_1[damage]
// / lin_t_1_s_3 / lin_t_1_s_3[refresh] / lin_t_2 / lin_e_002[half_attribute] / lin_e_002_trait).
// - Trait (阵法术师) "通常时不攻击且防御力和法术抗性大幅度提升,技能开启时攻击造成群体法术伤害": the profession (professions.js
// phalanx — DEF +def ×, RES +magic_resistance while no skill runs; while one runs, `rangeAoe`: every selectable enemy of
// her range at once, air units too). Module PLX-X “七窍玲珑” overrides it with "更大幅度提升" (def 2.15 / magic_resistance
// 25, the profession's trait tunables) and "技能开启时保留部分效果" (lin_e_002[buff] def 1 / magic_resistance 10): DEF +100 %
// and RES +10 while any skill runs — 玲珑's switched state too (PRTS: "技能开启时的效果均会生效"). Module PLX-Y “情与义”:
// "范围内敌人越多造成的伤害越高(最高提升15%)" — the hidden module talent: +damage_scale (3 %) per enemy on her range, at most
// max_valid_stack_cnt (5), on every damage she deals (the 琉璃璧 bursts too), counted like 卡涅利安's PLX-Y (every living
// enemy standing on her current range).
// - T1 计出万全 (lin_t_1, HIGH_PRIORITY: ON_TAKE_DAMAGE → BlockDamage of a fixed `value`, a break when the damage left it
// negative): a 琉璃璧 on her from every deployment — an HP shield of `value` (200) that every damage instance refills
// (`absorbShields` absorbs it after DEF / RES and the damage multipliers): a hit of at most 200 is absorbed whole; a hit
// over 200 ("单次超过200点伤害") goes through less 200 (PRTS: "伤害量减少相当于琉璃璧吸收伤害上限的数值") and breaks it — the
// burst (lin_t_1[damage] on the targets of Lin's "Attack" selector, i.e. her CURRENT attack range, centred on the holder):
// stun `stun` s on every enemy there, then atk_scale × Lin's ATK arts (source Lin) — and lin_t_1[disable] brings it back
// `interval` s later (8 s; PLX-Y stage 3: 110 %, 6 s). A 流失 or an element 损伤 never touches it. [ASSUMED] the shield sits
// in the buff order (HIGH_PRIORITY puts it first; no other shield usually stands on her), and the burst comes right after
// the hit has landed — before a knock-out, as PRTS's lethal-hit note says — in the 流光乍裂 order too (PRTS: "伤害应用后").
// - T2 韬光 "受到攻击时,有50%几率回复1点技力" (PLX-X stage 3: 75 %, 2 SP): the client's lin_t_2 (ON_TAKE_DAMAGE → IsIgnoreForSp →
// Dice prob → ModifySp sp, no damage-value filter): one roll per damage instance she takes that could give 受击回复 SP — a
// hit the 琉璃璧 absorbs whole included (it is still taken, at 0); not a 流失 or an element 损伤. No SP while a skill runs
// (玲珑's state included: it stays on).
// - S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once per deployment and kept — the official rule, `toggle`):
// attack interval +base_attack_time (+1.0 s on her 2.0 s), ATK +atk, every enemy each attack hits 停顿 attack@sluggish s.
// PRTS's "并不实际属于技能期间" changes nothing here: the state is never switched back in the auto battle.
// - S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +attack_speed, taunt +taunt_level (−1: "自身不容易受到敌人攻击"); every allied
// unit on her range (the client's validator: ground allies — every ally of this mode but the 孤立 “炎佑”, which no ally
// ability selects) gets its own lin_t_1 — the plain T1 (threshold `value`, Lin's ATK and source, its own break and
// `interval` regrowth; independentCharacterSource, not on Lin herself) — while the skill runs (removed when the ability
// detaches), checked every SHELTER_IV s; an ally that leaves the field loses it (its buffs go).
// - S3 流光乍裂 (MANUAL, data CUSTOM_RANGE on its x-2, 26 / 28 s): ATK +atk; range x-2 (the burst range follows: it is her
// current range: lin_t_1_s_3_selector); her own 琉璃璧 threshold ×talent_scale (2 / 2.5) while it runs (lin_t_1_s_3);
// each kill by her "Attack" ability (ON_TARGET_KILLED, one per attack) runs lin_t_1_s_3[refresh]: a standing 琉璃璧 bursts
// and stays, a broken one comes back at once. The auto battle never switches it off early.
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, batMod } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
const S1 = 'skchr_lin_1';
const S2 = 'skchr_lin_2';
const S3 = 'skchr_lin_3';
/** The 琉璃璧 shield buff (one per holder: Lin's own, and the S2 copies on her allies). */
const GLASS_KEY = 'lin:glass';
/** PLX-X: the part of the trait kept while a skill runs. */
const KEEP_KEY = 'lin:plxKeep';
/** S2 荫庇: how often the allies on her range are checked for a 琉璃璧. */
const SHELTER_IV = 0.1;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const s3On = (lin) => !!(lin.skill && lin.skill.active && lin.skill.id === S3);
export default {
char_4080_lin: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hidden = moduleBb(chess); // PLX-Y: damage_scale per enemy, max_valid_stack_cnt
const tb = traitBb(chess); // PLX-X: lin_e_002[buff] def / magic_resistance
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const g3 = skillRec(chess, S3)?.rangeGrid ?? null;
const glass = { value: num(t0.value, 200), atkScale: num(t0.atk_scale, 1), stun: num(t0.stun, 1), interval: num(t0.interval, 8) };
const s3Scale = num(b3.talent_scale, 1);
/** Threshold (= shield) of a holder's 琉璃璧: Lin's own ×talent_scale while 流光乍裂 runs, the plain value otherwise. */
const thresholdOf = (lin, holder) => (holder === lin && s3On(lin) ? glass.value * s3Scale : glass.value);
/** (Re)raise the 琉璃璧 of `holder`, full. `mods: {}` keeps the buff when an instance drains it exactly (absorbShields). */
const raise = (battle, lin, holder) => {
if (!holder.alive) return;
const r = holder.mem.linGlassRegen;
if (r) { r.cancel(); holder.mem.linGlassRegen = null; }
battle.addBuff(holder, { key: GLASS_KEY, shield: thresholdOf(lin, holder), mods: {}, source: lin, tags: ['talent'] });
battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id, skill: 'lin:glass' });
};
/** The break: Lin's current attack range centred on the holder — stun first, then the damage (PRTS 备注). */
const burst = (battle, lin, holder) => {
const grid = lin.liveRangeGrid || lin.rangeGrid || [[0, 0]];
const keys = absoluteRangeKeys(grid, holder.tileR, holder.tileC, lin.dir, 0);
const victims = battle.enemiesInKeys(keys, lin, { canHitFly: true });
if (glass.stun > 0) for (const e of victims) battle.applyStatus(e, 'stun', { duration: glass.stun, source: lin });
for (const e of victims) {
if (e.alive && glass.atkScale > 0) battle.dealDamage(lin, e, { amount: lin.s.atk * glass.atkScale, type: 'arts', tags: ['talent', 'lin:glass'] });
}
battle.fx('aoe', { x: holder.x, y: holder.y, radius: 1.5, id: lin.id, skill: 'lin:glass' });
};
/** Break `holder`'s 琉璃璧; it grows back `interval` s later (an S2 copy only while 荫庇 still covers it), or at once. */
const shatter = (battle, lin, holder, now = false) => {
battle.removeBuff(holder, GLASS_KEY);
burst(battle, lin, holder);
if (now) { raise(battle, lin, holder); return; }
const seq = holder.deploySeq;
holder.mem.linGlassRegen = battle.after(glass.interval, () => {
holder.mem.linGlassRegen = null;
if (!holder.alive || holder.deploySeq !== seq || holder.findBuff(GLASS_KEY)) return;
if (holder !== lin && !lin.mem.shelter?.has(holder.id)) return;
raise(battle, lin, holder);
}, { owner: lin });
};
/** Drop an S2 copy (the ally left her range, or 荫庇 ended). */
const dropCopy = (battle, holder) => {
const g = holder.findBuff(GLASS_KEY);
if (g) battle.removeBuff(holder, g);
const r = holder.mem.linGlassRegen;
if (r) { r.cancel(); holder.mem.linGlassRegen = null; }
};
/** S2: the allied units (not Lin, not devices) on her range get / keep / lose their 琉璃璧. */
const shelterTick = (battle, lin) => {
const st = lin.mem.shelter;
if (!st || !lin.rangeKeySet) return;
const now = new Set();
for (const a of battle.alliesFor(lin)) {
if (a === lin || a.kind === 'device' || !lin.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
now.add(a.id);
if (!st.has(a.id)) { st.add(a.id); raise(battle, lin, a); }
}
for (const id of [...st]) {
if (now.has(id)) continue;
st.delete(id);
const a = battle.unitById(id);
if (a) dropCopy(battle, a);
}
};
const endShelter = (battle, lin) => {
const st = lin.mem.shelter;
lin.mem.shelter = null;
for (const id of st || []) { const a = battle.unitById(id); if (a) dropCopy(battle, a); }
};
return {
skills: {
[S1]: {
kind: 'toggle',
mods: { atkPct: num(b1.atk), batPct: batMod(b1.base_attack_time, chess) },
attack: { onHitStatus: { key: 'sluggish', duration: num(b1['attack@sluggish']) } },
},
[S2]: {
kind: 'duration',
mods: { aspd: num(b2.attack_speed), taunt: num(b2.taunt_level) },
onStart({ battle, unit }) { unit.mem.shelter = new Set(); shelterTick(battle, unit); },
onTick({ battle, unit, dt }) {
unit.mem.shelterAcc = (unit.mem.shelterAcc ?? 0) + dt;
if (unit.mem.shelterAcc + 1e-9 < SHELTER_IV) return;
unit.mem.shelterAcc = 0;
shelterTick(battle, unit);
},
onEnd({ battle, unit }) { unit.mem.shelterAcc = 0; endShelter(battle, unit); },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: g3 ? { rangeGrid: g3 } : undefined,
onStart({ battle, unit }) { // the threshold ×talent_scale: a standing 琉璃璧 grows to it
const g = unit.findBuff(GLASS_KEY);
if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); }
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'lin:s3' });
},
onAttack({ battle, unit, targets }) { // a knock-out by this attack: break and raise again, once per attack
if (!(targets || []).some((t) => t && t.side === 'enemy' && !t.alive)) return;
if (unit.findBuff(GLASS_KEY)) shatter(battle, unit, unit, true);
else raise(battle, unit, unit);
},
},
},
talents: [
{ install(battle, unit) { // 计出万全: her 琉璃璧 (and the S2 copies — the same hook: any holder of one she gave)
battle.on('deploy', (c) => { if (c.unit === unit) raise(battle, unit, unit); }, { owner: unit });
battle.on('damaged', (c) => {
const h = c.target;
const g = h && h.findBuff(GLASS_KEY);
if (!g || g.source !== unit || c.type === 'element' || isHpLoss(c.dmg)) return;
if (g.shield > 1e-9 || !(c.amount > 0)) { // absorbed whole: full again for the next instance
g.shield = thresholdOf(unit, h);
h.markDirty();
return;
}
if (h === unit || unit.alive) shatter(battle, unit, h);
}, { owner: unit, priority: 50 });
battle.on('skillEnd', (c) => { // 流光乍裂 over: a standing 琉璃璧 shrinks to the plain threshold
if (c.unit !== unit || c.skill.id !== S3) return;
const g = unit.findBuff(GLASS_KEY);
if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); }
}, { owner: unit });
} },
{ install(battle, unit) { // 韬光: prob to +sp SP per damage instance taken (absorbed or not) that could give 受击回复 SP
const p = num(t1.prob), sp = num(t1.sp);
if (!(p > 0 && sp > 0)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !c.dmg || c.dmg.noSp || c.type === 'element' || isHpLoss(c.dmg) || !up(unit)) return;
if (battle.rng.chance(p)) giveSp(unit, sp);
}, { owner: unit });
} },
],
install(battle, unit) {
// PLX-X “七窍玲珑”: 技能开启时保留部分效果 (any skill; 玲珑's switched state included)
const keepDef = num(tb['lin_e_002[buff].def']), keepRes = num(tb['lin_e_002[buff].magic_resistance']);
if (keepDef || keepRes) {
battle.on('skillStart', (c) => { if (c.unit === unit) battle.addBuff(unit, { key: KEEP_KEY, mods: { defPct: keepDef, resFlat: keepRes }, tags: ['module'] }); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) battle.removeBuff(unit, KEEP_KEY); }, { owner: unit });
}
// PLX-Y “情与义”: +damage_scale per enemy on her range (≤ max_valid_stack_cnt), every damage she deals
const per = num(hidden.damage_scale), cap = Math.max(0, Math.floor(num(hidden.max_valid_stack_cnt, 5)));
if (per > 0 && cap > 0) {
battle.on('hit', (c) => {
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element' || !unit.rangeKeySet) return;
let n = 0;
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, unit.rangeKeySet) && ++n >= cap) break;
if (n > 0) c.dmg.mul *= 1 + per * n;
}, { owner: unit });
}
},
};
},
};