317 lines
19 KiB
JavaScript
317 lines
19 KiB
JavaScript
// server/sim/content/kits/ops/op-mantra.js — 真言 (char_4204_mantra) 自选 operator kit: 6★ 本源术师 (术师), an owned-6★ pick of
|
||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (PRI-X) at every form.
|
||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||
//
|
||
// Forms (data/backups.json units.char_4204_mantra, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||
// elite = E2 Lv60, rank 7, PRI-X at stage 1 (tier 5) or 3 (tier 6). Full potential (the owner's decision of 2026-10-07).
|
||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 真言 (the 备注
|
||
// quoted below). 麻痹 (ba.palsy, PRTS 术语释义): each stack interrupts one normal attack of an enemy, at most 3, kept until
|
||
// used — the engine `palsy` status; the moment a stack interrupts an attack is the engine hook `palsyTrigger` (ai.js).
|
||
// - Trait (本源术师) "攻击造成法术伤害,可以造成元素伤害": the profession default (ranged arts bolt, hits air units,
|
||
// targetable by ground enemies); 元素伤害 = the DamageInfo type 'elemental', 神经 损伤 = element 'neural' (SIM.md §3). PRI-X
|
||
// “中枢神经探测模块” "对处于元素爆发期间的敌人造成的伤害提升至110%" (trait bb damage_scale): ×damage_scale on every damage
|
||
// she deals to an enemy in an element burst (shared/tier5.js burstDamageUp, as 烛煌 / 妮芙).
|
||
// - T1 噤声限域 "场上敌人触发麻痹效果时立即受到相当于真言攻击力135%的元素伤害,且触发麻痹时有10%概率不消耗麻痹层数" (full
|
||
// potential: 145 % / 13 %; bb atk_scale / prob; PRI-X stage 3: 1.7 / 0.18): 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时
|
||
// (而非获得麻痹时)", "造成的元素伤害为元素持续伤害" (tagged 'dot'), "全场光环存在0.5秒检测周期,单位无法被真言选择的情况下
|
||
// 将失去效果" (every 0.5 s the enemies she can select — not untargetable, 隐匿 or asleep; "三技能期间,此天赋的检测无视
|
||
// 隐匿与沉睡" — carry her mark), "麻痹不消耗效果在同类效果中取概率最高" (two 真言: one roll at the higher chance; each deals
|
||
// her own hit [ASSUMED]).
|
||
// - T2 全局洞悉 "距离真言最近的一个侵入点出场的敌人出现时立刻获得1层麻痹": at each deployment she picks the 侵入点 (the
|
||
// stage's 'start' tiles) nearest by Manhattan distance, a tie drawn at random (备注 "优先选取距离自身曼哈顿距离最近的侵入点,
|
||
// 相同距离下随机选取"); every enemy spawning on it gets 1 麻痹 0.1 s later (备注 "【延迟麻痹】:0.1s后获得1层麻痹;付与选取
|
||
// 无视隐匿").
|
||
// - S1 共鸣溃缩 (AUTO, attack SP, data DEFAULT): the next attack deals atk_scale × ATK arts, then ep_damage_ratio of the
|
||
// damage as 神经 损伤, then — on a target in its 神经 burst — element_atk_scale × ATK 元素伤害 (S2 备注 "伤害顺序为:法术伤害 →
|
||
// 神经损伤 → 元素伤害", the same riders).
|
||
// - S2 意识联协 (MANUAL, 25 s, data DEFAULT): attack interval base_attack_time (a flat −0.7 / −0.6 s); each attack deals
|
||
// attack@atk_scale × ATK arts and jumps to chain_times other enemies (attack@chain.atk_scale × ATK arts each), every hit
|
||
// with the S1 riders at attack@ep_damage_ratio / attack@element_atk_scale. 备注 "跳跃半径2.0,首次跳跃开始后弹道速度降低至
|
||
// 3.2": each jump flies at 3.2 tiles/s to the nearest not-yet-hit enemy within 2.0 tiles [ASSUMED: nearest, as the
|
||
// 链术师]. The first shot keeps the engine's bolt (11 tiles/s; 备注 10).
|
||
// - S3 无言为真 (MANUAL, 40 s, data ACTIVE_RANGE on its 3-17): ATK +atk, attack@max_target targets; on her range (备注:
|
||
// 【反隐】 / the overflow by 碰撞判定 — an enemy's body —, 【麻痹付与】 by the enemy's tile) enemies lose 隐匿 (reveal) and
|
||
// their 麻痹 cap falls to 2 (text "降为2"; no blackboard key): stacks above it, held when the cap falls or gained later, become
|
||
// "溢出麻痹" (麻痹免疫 enemies count too — 备注 "仅为令麻痹失效": the stacks 真言 gives them are kept here); each one turns
|
||
// into an 元素伤害 bounce of atk_scale × ATK — on the holder, then (chain_times) to the nearest other enemy within 2.0 tiles
|
||
// at 3.2 tiles/s, max_target hits — one at once when gained and one every interval_projectile_trigger s after, all of them
|
||
// at once when the holder leaves the range, falls or the skill ends (备注). 【麻痹付与】: 0.1 s after the cast and whenever
|
||
// another operator on her range starts a skill, every enemy on her range gets per_active 麻痹, max_trigger_cnt times per
|
||
// cast, a grant with no enemy there kept (备注 "在范围内不存在可选敌方单位时保留可付与次数").
|
||
|
||
import { num, talentBb, traitBb, skillRec, batMod, up, once } from '../shared/tier1.js';
|
||
import { elementDmg } from '../shared/tier6.js';
|
||
import { burstDamageUp } from '../shared/tier5.js';
|
||
import { hasHp } from '../../../damage.js';
|
||
import { canTargetEnemy, enemyStealthed } from '../../../targeting.js';
|
||
import { bodyInKeys } from '../../../body.js';
|
||
import { COLS, PALSY_MAX } from '../../../constants.js';
|
||
|
||
const S1 = 'skchr_mantra_1';
|
||
const S2 = 'skchr_mantra_2';
|
||
const S3 = 'skchr_mantra_3';
|
||
const ANY = Object.freeze({ canHitFly: true });
|
||
/** 意识联协 / 无言为真 备注: "跳跃半径2.0", "速度3.2,弹射半径2.0". */
|
||
const JUMP_RADIUS = 2.0;
|
||
const JUMP_SPEED = 3.2;
|
||
/** 无言为真 "麻痹层数上限降为2" (text only). */
|
||
const S3_CAP = 2;
|
||
/** 无言为真 备注 "【麻痹付与】效果在自身技能开启后的0.1s触发一次". */
|
||
const GRANT_DELAY = 0.1;
|
||
/** 噤声限域 备注 "全场光环存在0.5秒检测周期". */
|
||
const MARK_IV = 0.5;
|
||
const MARK = 'mantra:hush:';
|
||
/** 全局洞悉 备注 "0.1s后获得1层麻痹". */
|
||
const SPAWN_DELAY = 0.1;
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
const palsyImmune = (e) => !!e?.def?.immune?.has?.('palsy');
|
||
/** 麻痹 stacks of an enemy — a 麻痹免疫 one keeps the ineffective stacks 真言 gave it in `mem.mantraPalsy`. */
|
||
const palsyOf = (e) => (palsyImmune(e) ? (e.mem.mantraPalsy ?? 0) : (e.findBuff('palsy')?.stacks ?? 0));
|
||
function setPalsy(battle, e, n) {
|
||
if (palsyImmune(e)) { e.mem.mantraPalsy = n; return; }
|
||
const b = e.findBuff('palsy');
|
||
if (!b) return;
|
||
if (n <= 0) battle.removeBuff(e, b);
|
||
else { b.stacks = n; e.markDirty(); }
|
||
}
|
||
const tileIn = (e, set) => set.has(Math.round(e.y) * COLS + Math.round(e.x));
|
||
|
||
/** The S1 / S2 riders: share × the damage dealt as 神经 损伤, then elemScale × ATK 元素伤害 on a target in its 神经 burst. */
|
||
function riders(battle, unit, t, dealt, share, elemScale) {
|
||
if (!t || t.side !== 'enemy') return;
|
||
if (share > 0 && dealt > 0) elementDmg(battle, unit, t, 'neural', dealt * share, ['skill', 'mantra']);
|
||
if (elemScale > 0 && hasHp(t) && t.findBuff('neuralBurst')) {
|
||
battle.dealDamage(unit, t, { amount: unit.s.atk * elemScale, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantra'] });
|
||
}
|
||
}
|
||
|
||
/** The nearest enemy within `r` of (x, y) that `unit` can select, none of `skip`. */
|
||
function nearestFoe(battle, unit, x, y, r, skip) {
|
||
let best = null, bd = Infinity;
|
||
for (const e of battle.enemiesInRadius(x, y, r)) {
|
||
if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue;
|
||
const d = Math.hypot(e.x - x, e.y - y);
|
||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
export default {
|
||
char_4204_mantra: (bb, chess) => {
|
||
const t0 = talentBb(chess, 0);
|
||
const tb = traitBb(chess); // PRI-X: damage_scale
|
||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||
const s3 = skillRec(chess, S3);
|
||
const s3On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S3 && unit.mem.mantraS3);
|
||
|
||
/** 意识联协: `left` jumps from `from`, each to the nearest not-yet-hit enemy, at JUMP_SPEED. */
|
||
function chain(battle, unit, from, hit, left, attackId) {
|
||
if (left <= 0) return;
|
||
const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit);
|
||
if (!next) return;
|
||
hit.add(next);
|
||
battle.addProjectile({
|
||
from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit, hitDead: true,
|
||
onHit: ({ target, x, y }) => {
|
||
if (target && target.alive) {
|
||
const d = battle.dealDamage(unit, target, { amount: unit.s.atk * num(b2['attack@chain.atk_scale'], 1), type: 'arts', isAttack: true, isSkill: true, tags: ['chain'], attackId });
|
||
riders(battle, unit, target, d, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale']));
|
||
}
|
||
chain(battle, unit, target && target.alive ? target : { x, y }, hit, left - 1, attackId);
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 无言为真: one 溢出麻痹 → an 元素伤害 bounce on `holder`, then to the nearest other enemy (chain_times jumps). */
|
||
function bounce(battle, unit, holder) {
|
||
const amount = unit.s.atk * num(b3.atk_scale);
|
||
if (!(amount > 0)) return;
|
||
const hit = new Set([holder]);
|
||
const elem = (t) => battle.dealDamage(unit, t, { amount, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantraOverflow'] });
|
||
let left = Math.max(1, Math.floor(num(b3.max_target, 2)));
|
||
if (holder.alive && hasHp(holder)) { elem(holder); left--; }
|
||
battle.fx('mantraArc', { x: holder.x, y: holder.y, id: unit.id, target: holder.id });
|
||
const jump = (from, jumps) => {
|
||
if (jumps <= 0 || left <= 0) return;
|
||
const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit);
|
||
if (!next) return;
|
||
hit.add(next);
|
||
left--;
|
||
battle.addProjectile({
|
||
from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit,
|
||
onHit: ({ target }) => { if (target && target.alive) elem(target); if (target) jump(target, jumps - 1); },
|
||
});
|
||
};
|
||
jump(holder, Math.max(0, Math.floor(num(b3.chain_times, 1))));
|
||
}
|
||
|
||
/** 溢出麻痹 gained by `e`: one turns into a bounce at once, the next one interval later. */
|
||
function overflow(battle, unit, st, e, rec, n) {
|
||
if (!(n > 0)) return;
|
||
rec.over += n;
|
||
rec.over--;
|
||
rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5);
|
||
bounce(battle, unit, e);
|
||
}
|
||
/** `e` leaves the overflow (range, fall, skill end): every 溢出麻痹 left bounces at once. */
|
||
function flush(battle, unit, e, rec) {
|
||
while (rec.over > 0) { rec.over--; bounce(battle, unit, e); }
|
||
}
|
||
/** 真言's own 麻痹 (S3 grants, 全局洞悉): a 麻痹免疫 enemy keeps them (ineffective) for the overflow count. */
|
||
function givePalsy(battle, unit, e, n) {
|
||
if (!e.alive || !(n > 0)) return;
|
||
if (!palsyImmune(e)) { battle.applyStatus(e, 'palsy', { value: n, source: unit }); return; }
|
||
const owner = battle.allyUnits.find((a) => s3On(a) && a.mem.mantraS3.map.has(e));
|
||
const cap = owner ? S3_CAP : PALSY_MAX;
|
||
const cur = palsyOf(e);
|
||
const add = Math.min(n, Math.max(0, cap - cur));
|
||
e.mem.mantraPalsy = cur + add;
|
||
if (owner && n > add) overflow(battle, owner, owner.mem.mantraS3, e, owner.mem.mantraS3.map.get(e), n - add);
|
||
}
|
||
/** 【麻痹付与】: per_active 麻痹 to every enemy on her range (by its tile); kept when there is none. */
|
||
function grant(battle, unit) {
|
||
const st = unit.mem.mantraS3;
|
||
if (!st || st.grants <= 0 || !s3On(unit)) return;
|
||
const set = unit.rangeKeySet;
|
||
const foes = battle.enemies.filter((e) => e.alive && !e.hidden && set && tileIn(e, set));
|
||
if (!foes.length) return;
|
||
st.grants--;
|
||
battle.fx('mantraGrant', { x: unit.x, y: unit.y, id: unit.id, n: foes.length });
|
||
for (const e of foes) givePalsy(battle, unit, e, Math.max(1, Math.floor(num(b3.per_active, 1))));
|
||
}
|
||
|
||
return {
|
||
skills: {
|
||
[S1]: {
|
||
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||
attack: {
|
||
atkScale: num(b1.atk_scale, 1),
|
||
onHit({ battle, unit, target, dealt }) { riders(battle, unit, target, dealt, num(b1.ep_damage_ratio), num(b1.element_atk_scale)); },
|
||
},
|
||
},
|
||
[S2]: {
|
||
kind: 'duration',
|
||
mods: { batPct: batMod(b2.base_attack_time, chess) },
|
||
attack: {
|
||
atkScale: num(b2['attack@atk_scale'], 1),
|
||
onEachHit({ battle, unit, target, dealt, kind, attackId }) {
|
||
if (kind !== 'main' || !target) return;
|
||
riders(battle, unit, target, dealt, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale']));
|
||
chain(battle, unit, target, new Set([target]), Math.max(0, Math.floor(num(b2.chain_times))), attackId);
|
||
},
|
||
},
|
||
},
|
||
[S3]: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(b3.atk) },
|
||
targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) },
|
||
onStart({ battle, unit, skill }) {
|
||
const n = skill.activations;
|
||
unit.mem.mantraS3 = { grants: Math.max(0, Math.floor(num(b3.max_trigger_cnt))), map: new Map() };
|
||
battle.after(GRANT_DELAY, () => { if (skill.activations === n) grant(battle, unit); });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const st = unit.mem.mantraS3;
|
||
const set = unit.rangeKeySet;
|
||
if (!st || !set) return;
|
||
const seen = new Set();
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden || !bodyInKeys(e, set)) continue;
|
||
seen.add(e);
|
||
// 【反隐】 (隐匿免疫) on her range
|
||
if (e.s.flags.stealth) battle.applyStatus(e, 'reveal', { duration: 2 * dt + 0.05, source: unit });
|
||
let rec = st.map.get(e);
|
||
if (!rec) {
|
||
rec = { over: 0, next: Infinity };
|
||
st.map.set(e, rec);
|
||
const cur = palsyOf(e);
|
||
if (cur > S3_CAP) { setPalsy(battle, e, S3_CAP); overflow(battle, unit, st, e, rec, cur - S3_CAP); }
|
||
} else if (rec.over > 0 && battle.time >= rec.next - 1e-9) {
|
||
rec.over--;
|
||
rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5);
|
||
bounce(battle, unit, e);
|
||
}
|
||
}
|
||
for (const [e, rec] of [...st.map]) if (!seen.has(e)) { st.map.delete(e); flush(battle, unit, e, rec); }
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const st = unit.mem.mantraS3;
|
||
unit.mem.mantraS3 = null;
|
||
if (st) for (const [e, rec] of st.map) flush(battle, unit, e, rec);
|
||
},
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 噤声限域: her mark on every enemy she can select (0.5 s); a 麻痹 interrupt hits it
|
||
const scale = num(t0.atk_scale), prob = num(t0.prob);
|
||
if (!(scale > 0) && !(prob > 0)) return;
|
||
battle.every(MARK_IV, () => {
|
||
if (!up(unit)) return;
|
||
const all = s3On(unit); // 三技能期间: 隐匿 / 沉睡 do not hide an enemy from the check
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden || !e.deployed || e.s.flags.untargetable) continue;
|
||
if (!all && (e.s.flags.sleep || (e.s.flags.stealth && enemyStealthed(e)))) continue;
|
||
battle.addBuff(e, { key: MARK + unit.id, duration: MARK_IV + 0.1, source: unit, data: { scale, prob } });
|
||
}
|
||
}, { owner: unit, immediate: true });
|
||
once(battle, 'mantra:palsyTrigger', () => battle.on('palsyTrigger', (ctx) => {
|
||
const e = ctx.enemy;
|
||
let p = 0;
|
||
for (const m of e.buffs.slice()) {
|
||
if (typeof m.key !== 'string' || !m.key.startsWith(MARK) || !m.data) continue;
|
||
const src = m.source;
|
||
if (!up(src)) continue;
|
||
if (m.data.scale > 0 && hasHp(e)) {
|
||
battle.dealDamage(src, e, { amount: src.s.atk * m.data.scale, type: 'elemental', element: 'neural', canDodge: false, tags: ['talent', 'dot', 'mantraHush'] });
|
||
}
|
||
p = Math.max(p, m.data.prob);
|
||
if (!e.alive) return;
|
||
}
|
||
if (p > 0 && battle.rng.chance(p)) ctx.keep = true;
|
||
}));
|
||
} },
|
||
{ install(battle, unit) { // 全局洞悉: the nearest 侵入点; its spawns get 1 麻痹 0.1 s later
|
||
battle.on('deploy', (ctx) => {
|
||
if (ctx.unit !== unit) return;
|
||
let best = [], bd = Infinity;
|
||
for (const [r, c] of battle.grid.specialTiles('start')) {
|
||
const d = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
|
||
if (d < bd) { bd = d; best = [[r, c]]; } else if (d === bd) best.push([r, c]);
|
||
}
|
||
unit.mem.mantraGate = best.length > 1 ? battle.rng.pick(best) : best[0] ?? null;
|
||
if (unit.mem.mantraGate) battle.fx('mantraGate', { x: unit.mem.mantraGate[1], y: unit.mem.mantraGate[0], id: unit.id });
|
||
}, { owner: unit });
|
||
battle.on('enemySpawn', ({ enemy: e }) => {
|
||
const g = unit.mem.mantraGate;
|
||
if (!g || !up(unit) || Math.round(e.spawnY) !== g[0] || Math.round(e.spawnX) !== g[1]) return;
|
||
battle.after(SPAWN_DELAY, () => givePalsy(battle, unit, e, 1));
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// PRI-X: ×damage_scale on enemies in an element burst
|
||
burstDamageUp(battle, unit, num(tb.damage_scale));
|
||
// 无言为真: the 麻痹 cap 2 on her range; the stacks above it become 溢出麻痹
|
||
battle.on('beforeStatus', (c) => {
|
||
if (c.status !== 'palsy' || !s3On(unit) || !c.target || c.target.side !== 'enemy') return;
|
||
const st = unit.mem.mantraS3;
|
||
const rec = st.map.get(c.target);
|
||
if (!rec) return;
|
||
const n = Math.max(1, Math.floor(Number.isFinite(c.value) ? c.value : 1));
|
||
const room = Math.max(0, S3_CAP - palsyOf(c.target));
|
||
if (n <= room) return;
|
||
if (room > 0) c.value = room; else c.cancel = true;
|
||
overflow(battle, unit, st, c.target, rec, n - room);
|
||
}, { owner: unit });
|
||
// 【麻痹付与】 when another operator on her range starts a skill
|
||
battle.on('skillStart', (ctx) => {
|
||
const u = ctx.unit;
|
||
if (u === unit || !u || u.kind !== 'op' || !s3On(unit) || !unit.rangeKeySet?.has(u.tileR * COLS + u.tileC)) return;
|
||
grant(battle, unit);
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
};
|