feedback5: merge O3 — 自选 kits for 逻各斯, 真言 and 维伊; engine hook palsyTrigger (麻痹 interrupting an enemy attack); golden: only diy moved
This commit is contained in:
commit
8357149afa
11 files changed
+2270
-121
No files matched your search
+9
-2
@@ -676,10 +676,17 @@ function enemyAttack(b, e) {
|
||||
if (own && targets.length) targets = targets.filter((a) => own(a));
|
||||
if (targets.length > 1) sortAllyTargets(e, targets);
|
||||
if (!targets.length) return false;
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack. The `palsyTrigger` hook { enemy, buff, keep } fires as it
|
||||
// does (PRTS 真言 备注: "触发麻痹时" = the attack interrupted by 麻痹, not a stack gained): a handler may set `keep` — the
|
||||
// stack is not consumed (真言 噤声限域 "触发麻痹时有N%概率不消耗麻痹层数") — or deal damage (the enemy may die there)
|
||||
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
|
||||
if (palsy) {
|
||||
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
|
||||
let keep = false;
|
||||
if (b._hooks.palsyTrigger) {
|
||||
keep = !!b.emit('palsyTrigger', { enemy: e, buff: palsy, keep: false }).keep;
|
||||
if (!e.alive) return false;
|
||||
}
|
||||
if (!keep) { if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty(); }
|
||||
e.atkCd = e.s.interval;
|
||||
// the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled)
|
||||
if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE;
|
||||
|
||||
@@ -92,6 +92,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-poca.js',
|
||||
'op-typhon.js',
|
||||
'op-narant.js',
|
||||
'op-logos.js',
|
||||
'op-mantra.js',
|
||||
'op-veen.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// server/sim/content/kits/ops/op-logos.js — 逻各斯 (char_4133_logos) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (CCR-Δ, CCR-Y) at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4133_logos, 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) and PRTS 逻各斯 (the
|
||||
// 备注 quoted below). Every number is read from the record (`chess` / the skill blackboards); the few constants below exist
|
||||
// only in the PRTS 备注.
|
||||
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts bolt, hits air units, targetable by ground
|
||||
// enemies). CCR-Δ “来自河谷的笔盒” adds "造成法术伤害时附带相当于8%伤害的凋亡损伤" (hidden module talent
|
||||
// `ep_damage_ratio`): every arts damage he deals — attacks, the talent's extra hits, 殁亡's follow-up — fills 8 % of the
|
||||
// HP it removed as 凋亡 损伤 (the kits' element convention, docs/SIM.md §7.2). CCR-Y “《语义范式百科》” adds
|
||||
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb `sp`): +sp SP per normal-attack hit on an elite or leader enemy
|
||||
// (none while a timed skill runs — AK) and, per the 语汇演化 备注 "<Y模组>…可触发模组提供的额外特性", per talent hit too.
|
||||
// - T1 语汇演化 "对一个目标发起攻击时,有40%几率额外对攻击范围内一个随机目标造成相当于攻击力60%的法术伤害并使其停顿0.8秒"
|
||||
// (bb prob / atk_scale / sluggish): rolled for every target of each of his attacks and after 殁亡's follow-up hit (S1 备注
|
||||
// "可触发第一天赋"); the extra hit lands at once on a random targetable enemy of his current range [ASSUMED: the attacked
|
||||
// target can be drawn], then 停顿 (the engine `sluggish`) for `sluggish` s. 备注 "该天赋的触发不依赖普通攻击,不受缴械类效果
|
||||
// 制约". CCR-Δ stage 2+: prob 0.5 / 0.6 and, on a target in its 凋亡 burst, `element_atk_scale` × ATK 元素伤害 after the arts
|
||||
// hit (备注 "先造成法术伤害,后造成元素伤害"). CCR-Y stage 2+: `emit_count` 2 — 备注 "本质上是将该天赋重复执行2次": two
|
||||
// independent executions (each its own roll and random target).
|
||||
// - T2 剜魂具辞 "攻击使目标在5秒内法术抗性-10且受到的法术伤害提高150点" (bb duration / magic_resistance / atk_addition):
|
||||
// each of his attack hits gives the target, for `duration` s, RES `magic_resistance` and +`atk_addition` on every arts
|
||||
// damage it takes (备注 "在计算攻击倍率后、计算法术抗性前增加当次伤害", "可对当次伤害生效", "即使法术伤害的攻击力为0也可
|
||||
// 生效"): added to the hit's pre-mitigation amount, the hit that applies it included. One instance per enemy whoever
|
||||
// applies it (同名效果: applyStrongest).
|
||||
// - S1 殁亡 (AUTO, 持续时间无限 ⇒ toggle; the data's DEFAULT trigger — it acts on enemies): range 3-3, ATK +atk, and an aura
|
||||
// (备注 "光环效果,不依赖攻击行为"): every enemy of his range whose HP is below attack@kill_atk_scale × his ATK takes
|
||||
// attack@kill_damage (9999999) 无来源 true damage (备注 "无来源真实斩杀伤害"; no dodge), then another random enemy of his
|
||||
// range takes arts damage equal to the HP the first had left (备注: his T2 bonus applies, T1 rolls). At most one try per
|
||||
// enemy per entry into the range, and none ever again on an enemy that survived one (备注; a 频次 unit takes 1 HP, a
|
||||
// leader in a boss battle loses nothing to 限伤 — 备注 "卫戍协议的最终攻势Boss…只能以常规的“当前生命值”处理斩杀的触发").
|
||||
// - S2 提喻 (MANUAL, 20 s, data DEFAULT): RES +magic_resistance; the attack locks one target (an instant hit, no bolt
|
||||
// [ASSUMED: a lock-on beam]) every attack@cooldown s whatever the attack speed (备注 "攻击间隔固定为0.5s"), each hit
|
||||
// attack@atk_scale_base + attack@atk_scale_delta × n of his ATK and the target's movement speed × (1 + attack@move_speed
|
||||
// × n), n = the hits made on it so far, at most attack@max_stack_cnt (备注 "线性提升" — 3× and 40 % after 5 s). A stun /
|
||||
// freeze, the target's fall or no attack for 0.1 s past the cadence (备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态")
|
||||
// resets the lock and its slow; the next attack locks anew.
|
||||
// - S3 延异视阈 (MANUAL, 30 s, data ACTIVE_RANGE on its 3-4): ATK +atk, attack@max_target targets, and every enemy
|
||||
// projectile whose position is on his range flies at projectile_move_scale × its speed (备注 "降低至0.05倍"; leaving the
|
||||
// range restores it) and is removed when the skill ends — not when he is disarmed then (备注 "受缴械类效果制约") nor when
|
||||
// he is knocked out [ASSUMED]. Every enemy projectile counts as 可变速 / 可消除 [ASSUMED]. The sim's projectiles live in
|
||||
// `battle.projectiles.list` (ProjectileSystem, server/sim/projectiles.js): the kit adjusts `speed` / `maxAge` there.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, once } from '../shared/tier1.js';
|
||||
import { isElite, elementDmg } from '../shared/tier6.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS, ROWS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_logos_1';
|
||||
const S2 = 'skchr_logos_2';
|
||||
const S3 = 'skchr_logos_3';
|
||||
const ANY = Object.freeze({ canHitFly: true });
|
||||
/** 剜魂具辞: the debuff on an enemy (one instance whoever applies it). */
|
||||
const T2_KEY = 'logos:soulRend';
|
||||
/** 提喻 备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态": the lock ends 0.1 s after a missed attack. */
|
||||
const LOCK_IDLE = 0.1;
|
||||
/** Enemy projectiles slowed by a running 延异视阈 (projectile → { orig speed, by: Set of unit ids }); per projectile. */
|
||||
const SLOWED = new WeakMap();
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const tileOf = (p) => {
|
||||
const r = Math.round(p.y), c = Math.round(p.x);
|
||||
return r >= 0 && r < ROWS && c >= 0 && c < COLS ? r * COLS + c : -1;
|
||||
};
|
||||
/** Enemy projectiles on the field (ProjectileSystem list: the sim's only projectile store). */
|
||||
const enemyShots = (battle) => (battle.projectiles?.list ?? []).filter((p) => p && p.source && p.source.side === 'enemy');
|
||||
|
||||
/** 延异视阈: slow the enemy projectiles on `unit`'s range, restore those that left it. */
|
||||
function slowShots(battle, unit, scale) {
|
||||
const set = unit.rangeKeySet;
|
||||
for (const p of enemyShots(battle)) {
|
||||
let s = SLOWED.get(p);
|
||||
if (set && set.has(tileOf(p))) {
|
||||
if (!s) { s = { orig: p.speed, by: new Set() }; SLOWED.set(p, s); }
|
||||
s.by.add(unit.id);
|
||||
p.speed = s.orig * scale;
|
||||
p.maxAge = Math.max(p.maxAge, p.age + 1); // a slowed shot never lands by age while it is held
|
||||
} else if (s && s.by.delete(unit.id) && !s.by.size) {
|
||||
p.speed = s.orig;
|
||||
SLOWED.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 延异视阈 ends: clear the enemy projectiles on the range (unless disarmed / knocked out), release the rest. */
|
||||
function endShots(battle, unit, clear) {
|
||||
const set = unit.rangeKeySet;
|
||||
let n = 0;
|
||||
if (clear && set && battle.projectiles) {
|
||||
const keep = [];
|
||||
for (const p of battle.projectiles.list) {
|
||||
if (p && p.source && p.source.side === 'enemy' && set.has(tileOf(p))) { n++; SLOWED.delete(p); } else keep.push(p);
|
||||
}
|
||||
battle.projectiles.list = keep;
|
||||
}
|
||||
for (const p of enemyShots(battle)) {
|
||||
const s = SLOWED.get(p);
|
||||
if (s && s.by.delete(unit.id) && !s.by.size) { p.speed = s.orig; SLOWED.delete(p); }
|
||||
}
|
||||
if (n) battle.fx('bulletClear', { x: unit.x, y: unit.y, id: unit.id, n });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4133_logos: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0);
|
||||
const t1 = talentBb(chess, 1);
|
||||
const hidden = moduleBb(chess); // CCR-Δ: ep_damage_ratio
|
||||
const tb = traitBb(chess); // CCR-Y: sp
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
|
||||
const lex = {
|
||||
prob: num(t0.prob), scale: num(t0.atk_scale), sluggish: num(t0.sluggish),
|
||||
elemScale: num(t0.element_atk_scale), emit: Math.max(1, Math.floor(num(t0.emit_count, 1))),
|
||||
};
|
||||
|
||||
/** 语汇演化: `emit` executions, each a `prob` roll for a random enemy of his range. */
|
||||
function lexicon(battle, unit) {
|
||||
if (!(lex.prob > 0) || !(lex.scale > 0)) return;
|
||||
for (let k = 0; k < lex.emit; k++) {
|
||||
if (!up(unit) || !battle.rng.chance(lex.prob)) continue;
|
||||
const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY));
|
||||
if (!t) continue;
|
||||
battle.fx('logosLexicon', { x: t.x, y: t.y, id: unit.id, target: t.id });
|
||||
battle.dealDamage(unit, t, { amount: unit.s.atk * lex.scale, type: 'arts', tags: ['talent', 'logosLexicon'] });
|
||||
if (t.alive && lex.sluggish > 0) battle.applyStatus(t, 'sluggish', { duration: lex.sluggish, source: unit });
|
||||
if (lex.elemScale > 0 && hasHp(t) && t.findBuff('apoptosisBurst')) {
|
||||
battle.dealDamage(unit, t, { amount: unit.s.atk * lex.elemScale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'logosLexicon'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 殁亡's execution of `e` (HP below the threshold), then the follow-up arts hit on another random enemy of his range. */
|
||||
function execute(battle, unit, e, st) {
|
||||
const left = e.hp;
|
||||
battle.fx('logosExecute', { x: e.x, y: e.y, id: unit.id, target: e.id });
|
||||
battle.dealDamage(unit, e, { amount: num(b1['attack@kill_damage'], 9999999), type: 'true', canDodge: false, sourceless: true, isSkill: true, tags: ['skill', 'execute'] });
|
||||
if (e.alive) st.spared.add(e.id); // survived (不死, 频次, 限伤 …): never tried again
|
||||
const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY).filter((x) => x !== e));
|
||||
if (!t || !(left > 0)) return;
|
||||
battle.dealDamage(unit, t, { amount: left, type: 'arts', isSkill: true, tags: ['skill', 'logosPerish'] });
|
||||
lexicon(battle, unit);
|
||||
}
|
||||
|
||||
/** 提喻: end the lock (its slow leaves the target). */
|
||||
function unlock(battle, unit) {
|
||||
const L = unit.mem.logosLock;
|
||||
if (L && L.target) battle.removeBuff(L.target, `logos:lockSlow:${unit.id}`);
|
||||
unit.mem.logosLock = null;
|
||||
}
|
||||
const lockIv = num(b2['attack@cooldown'], 0.5);
|
||||
const base2 = num(b2['attack@atk_scale_base'], 1), delta2 = num(b2['attack@atk_scale_delta']);
|
||||
const maxN = Math.max(0, Math.floor(num(b2['attack@max_stack_cnt'])));
|
||||
const s2On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S2);
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'toggle',
|
||||
mods: { atkPct: num(b1.atk) },
|
||||
targeting: { rangeGrid: s1?.rangeGrid ?? null },
|
||||
onStart({ unit }) { unit.mem.logosExec = { tried: new Set(), spared: unit.mem.logosExec?.spared ?? new Set() }; },
|
||||
onTick({ battle, unit }) {
|
||||
const st = unit.mem.logosExec;
|
||||
if (!st || !up(unit)) return;
|
||||
const inRange = battle.enemiesInKeys(unit.rangeKeys, unit, ANY);
|
||||
const ids = new Set(inRange.map((e) => e.id));
|
||||
for (const id of st.tried) if (!ids.has(id)) st.tried.delete(id); // left the range: a new entry may try again
|
||||
const thr = num(b1['attack@kill_atk_scale']) * unit.s.atk;
|
||||
for (const e of inRange) {
|
||||
if (st.tried.has(e.id) || st.spared.has(e.id) || !e.alive || !hasHp(e) || !(e.hp < thr)) continue;
|
||||
st.tried.add(e.id);
|
||||
execute(battle, unit, e, st);
|
||||
if (!up(unit)) return;
|
||||
}
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { resFlat: num(b2.magic_resistance) },
|
||||
attack: {
|
||||
projectile: 'beam',
|
||||
atkScale: base2,
|
||||
// the hit's scale: base + delta × n (n = the hits already made on the locked target)
|
||||
dmgMul: (battle, unit, target) => {
|
||||
const L = unit.mem.logosLock;
|
||||
const n = L && L.target === target ? L.n : 0;
|
||||
return base2 > 0 ? (base2 + delta2 * n) / base2 : 1;
|
||||
},
|
||||
onEachHit({ battle, unit, target, kind }) {
|
||||
const L = unit.mem.logosLock;
|
||||
if (kind !== 'main' || !L || L.target !== target || !target.alive) return;
|
||||
L.n = Math.min(maxN, L.n + 1);
|
||||
const mul = Math.max(0, 1 + num(b2['attack@move_speed']) * L.n);
|
||||
battle.addBuff(target, { key: `logos:lockSlow:${unit.id}`, duration: lockIv + LOCK_IDLE + 0.2, source: unit, mods: { moveMul: mul } });
|
||||
},
|
||||
},
|
||||
onStart({ battle, unit }) { unlock(battle, unit); unit.mem.logosLockAt = null; },
|
||||
onAttack({ battle, unit }) { unit.mem.logosLockAt = battle.time; },
|
||||
onTick({ battle, unit }) {
|
||||
const L = unit.mem.logosLock;
|
||||
// a stun / freeze breaks the lock; so does no attack for LOCK_IDLE past the cadence
|
||||
if (L && (!unit.canAct || !L.target.alive || battle.time - unit.mem.logosLockAt > lockIv + LOCK_IDLE + 1e-9)) unlock(battle, unit);
|
||||
if (!unit.canAct) return;
|
||||
// 攻击间隔固定为0.5s: the next attack exactly lockIv after the last one (the engine counts atkCd down right after)
|
||||
const at = unit.mem.logosLockAt;
|
||||
if (at == null) { unit.atkCd = Math.min(unit.atkCd, lockIv); return; }
|
||||
const left = at + lockIv - battle.time;
|
||||
unit.atkCd = left > 1e-6 ? left + battle.dt : 0;
|
||||
},
|
||||
onEnd({ battle, unit }) { unlock(battle, unit); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) },
|
||||
onTick({ battle, unit }) { slowShots(battle, unit, num(b3.projectile_move_scale, 1)); },
|
||||
onEnd({ battle, unit, reason }) { endShots(battle, unit, reason !== 'death' && !unit.s.flags.disarm); },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 语汇演化: every target of each of his attacks
|
||||
battle.on('attack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !up(unit)) return;
|
||||
for (let i = 0; i < ctx.targets.length; i++) lexicon(battle, unit);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 剜魂具辞: his attack hits mark the target (before mitigation: the hit itself counts)
|
||||
const dur = num(t1.duration), res = num(t1.magic_resistance), add = num(t1.atk_addition);
|
||||
if (!(dur > 0) || !(add > 0 || res)) return;
|
||||
battle.on('hit', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.dmg?.isAttack || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
battle.applyStrongest(ctx.target, T2_KEY, { duration: dur, value: add, source: unit, mods: () => (res ? { resFlat: res } : {}) });
|
||||
}, { owner: unit, priority: 10 });
|
||||
// "受到的法术伤害提高N点": every arts damage on a marked enemy, after the attack multiplier, before its RES
|
||||
once(battle, 'logos:artsAddition', () => battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (!d || d.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
const m = ctx.target.findBuff(T2_KEY);
|
||||
if (m && m.data && m.data.value > 0) d.amount += m.data.value;
|
||||
}, { priority: -50 }));
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// 提喻: keep attacking the locked target while it stays a target; a new lock otherwise
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !s2On(unit) || !ctx.targets.length) return;
|
||||
const L = unit.mem.logosLock;
|
||||
const prof = ctx.profile || unit.profile;
|
||||
if (L && L.target.alive) {
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
|
||||
if (cands.includes(L.target) || battle.blockedTargets(unit, prof).includes(L.target)) { ctx.targets = [L.target]; return; }
|
||||
}
|
||||
unlock(battle, unit);
|
||||
unit.mem.logosLock = { target: ctx.targets[0], n: 0 };
|
||||
ctx.targets = [ctx.targets[0]];
|
||||
}, { owner: unit });
|
||||
// CCR-Δ: 8 % of every arts damage as 凋亡 损伤
|
||||
const ep = num(hidden.ep_damage_ratio);
|
||||
if (ep > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.source !== unit || ctx.type !== 'arts' || !(ctx.amount > 0) || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
elementDmg(battle, unit, ctx.target, 'apoptosis', ctx.amount * ep, ['trait', 'logosCcrd']);
|
||||
}, { owner: unit });
|
||||
}
|
||||
// CCR-Y: a normal-attack (or 语汇演化) hit on an elite / leader enemy gives SP
|
||||
const sp = num(tb.sp);
|
||||
if (sp > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (ctx.source !== unit || !d || ctx.type === 'element' || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
if ((d.isAttack || (d.tags || []).includes('logosLexicon')) && isElite(ctx.target)) giveSp(unit, sp, 'trait');
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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). Potential 0 [ASSUMED: no account].
|
||||
// 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%概率不消耗麻痹层数" (bb
|
||||
// atk_scale / prob; PRI-X stage 3: 1.6 / 0.15): 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时(而非获得麻痹时)", "造成的元素
|
||||
// 伤害为元素持续伤害" (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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
// server/sim/content/kits/ops/op-veen.js — 维伊 (char_4226_veen) 自选 operator kit: 6★ 秘术师 (术师), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (MSC-Y) at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4226_veen, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, MSC-Y 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) and PRTS 维伊 (the 备注
|
||||
// quoted below).
|
||||
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射" (ranged arts, hits air units,
|
||||
// targetable by ground enemies). PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
|
||||
// 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)" — when her attack is ready
|
||||
// and she has no valid target she stores one energy and her attack interval starts again (this kit replaces the
|
||||
// engine's mystic profile, which stores one interval after the attack is ready); the energies fly with her next attack,
|
||||
// at its target ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
|
||||
// T1 “在挥刀之前” "特性储存的能量达到3个时,合成为1个转置能量(至多储存等同于9倍的能量)" (trait bb merge_cnt 3, times 9):
|
||||
// 3 stored make 1 转置能量, at most times / merge_cnt of those, then no more storing (备注 "转置能量数量达到上限时,停止储存
|
||||
// 攻击能量"); a 转置能量 hits for merge_cnt × ATK (备注 "攻击力300%的法术普通伤害"). Every shot uses the ATK it left with
|
||||
// (备注 "所有攻击、储存能量、转置能量的弹道均使用脱手时的缓存攻击力"), released 转置能量 → main attack → stored energies (S2
|
||||
// 备注 "弹道创建顺序"); one volley flies to one target and lands at once, so the kit resolves all its hits as the main bolt
|
||||
// lands (the engine's own hit makes none: `hitsFn` 0), with that attack's attackId, the later ones only on a target still
|
||||
// alive. [ASSUMED] a redeployment holds no energy.
|
||||
// - T1 “在挥刀之前” "拥有转置能量时攻击力+10%,未拥有转置能量时攻击速度+15" (bb atk / attack_speed): two toggles on her
|
||||
// stored state (checked every tick — the 备注's "攻击力加成失效存在短暂延迟": the volley that spends the 转置能量 keeps it).
|
||||
// - T2 战争技艺 "攻击和储存的能量使目标在5秒内每秒受到90点法术伤害(至多叠加3次,发射转置能量可以叠加3次),维伊不以该效果影响的
|
||||
// 敌人为攻击目标" (bb attack@duration / attack@value / attack@max_stack_cnt; MSC-Y stage 3: 120 / 4): each hit adds 1
|
||||
// stack (a 转置能量 3, an S3 bounce 1 — S3 备注 "相当于一发储存能量"), each stack attack@value arts per second, the 5 s
|
||||
// restarting with every stack [ASSUMED]; she never picks a marked enemy (canAttack / beforeAttack, as 深靛 skips bound
|
||||
// enemies — with only marked enemies in range she stores energy).
|
||||
// - MSC-Y “军械库” "拥有已储存的攻击能量时,攻击速度+30" (hidden module talent attack_speed): while she holds any energy.
|
||||
// - S1 “自呼号生发” (AUTO, 18 s): ASPD +attack_speed — an AUTO skill acting on herself fires at full SP (the owner's rule,
|
||||
// kits/README.md checklist 5: `trigger: 'SP_FULL'`).
|
||||
// - S2 “以鲜血洗去” (MANUAL, 28 s, data DEFAULT): attack interval base_attack_time (a flat −0.5 s on 3.0 s); every released
|
||||
// main attack or stored energy adds a stack (a 转置能量 3), at most …max_stack_cnt: ATK +…atk and ASPD +…attack_speed each,
|
||||
// until the skill ends; a shot leaving later in the same volley already has the stacks of those before it (备注).
|
||||
// - S3 “用赤铁铭记” (MANUAL, 21 / 18 bullets, data DEFAULT): ASPD +attack_speed; 备注 "技能期间发射的储存能量与转置能量不会
|
||||
// 产生伤害,维伊仅在每次攻击时根据当前储存能量与转置能量消耗相应数量的弹药(不考虑是否足够),并令本次攻击获得相应弹射次数":
|
||||
// an attack spends 1 bullet per stored energy and merge_cnt per 转置能量 (none without) and its main hit
|
||||
// (attack@base_atk_scale × ATK) bounces that many times, every 0.5 s within 1.7 tiles (备注 "弹射间隔0.5,弹射半径1.7"),
|
||||
// preferring an enemy not hit yet by it, else another one, else the same (text "优先不同目标" [ASSUMED order]), each
|
||||
// attack@bounce_atk_scale × the cached ATK arts. The skill ends when the bullets run out.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
|
||||
const S1 = 'skchr_veen_1';
|
||||
const S2 = 'skchr_veen_2';
|
||||
const S3 = 'skchr_veen_3';
|
||||
const ANY = Object.freeze({ canHitFly: true });
|
||||
/** 用赤铁铭记 备注 "弹射间隔0.5,弹射半径1.7". */
|
||||
const BOUNCE_IV = 0.5;
|
||||
const BOUNCE_R = 1.7;
|
||||
const S2_STACK = 'veen:s2stack';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const warKey = (unit) => `veen:war:${unit.id}`;
|
||||
const marked = (unit, e) => !!e.findBuff(warKey(unit));
|
||||
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
|
||||
/**
|
||||
* Her stored energies (per deployment): `s` stored, `t` 转置能量, `q` the volleys in flight per target, `use` the S3 bullets of
|
||||
* the attack just made, `k` the S2 stacks.
|
||||
*/
|
||||
const stateOf = (unit) => unit.trait.veen ?? (unit.trait.veen = { s: 0, t: 0, q: new Map(), use: 0, k: 0 });
|
||||
|
||||
/** Enemies she may pick: in her range (or blocked by her), none under her 战争技艺. */
|
||||
function validTargets(battle, unit, prof) {
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
|
||||
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
return cands.filter((e) => !marked(unit, e));
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4226_veen: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0);
|
||||
const t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
const hidden = moduleBb(chess); // MSC-Y: attack_speed
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const merge = Math.max(1, Math.floor(num(tb.merge_cnt, 3)));
|
||||
const tMax = Math.max(1, Math.floor(num(tb.times, 9) / merge));
|
||||
const war = { dur: num(t1['attack@duration']), value: num(t1['attack@value']), max: Math.max(1, Math.floor(num(t1['attack@max_stack_cnt'], 1))) };
|
||||
const s2 = { atk: num(b2['attack@veen_s_2_buff[stack].atk']), aspd: num(b2['attack@veen_s_2_buff[stack].attack_speed']), max: Math.max(0, Math.floor(num(b2['attack@veen_s_2_buff[stack].max_stack_cnt']))) };
|
||||
|
||||
/** 战争技艺: `n` stacks on `e`. */
|
||||
function warOn(battle, unit, e, n) {
|
||||
if (!e || !e.alive || !(war.value > 0) || !(war.dur > 0) || !(n > 0)) return;
|
||||
battle.addBuff(e, {
|
||||
key: warKey(unit), duration: war.dur, refresh: 'stack', stacks: Math.min(n, war.max), maxStacks: war.max, source: unit,
|
||||
interval: 1, data: { value: war.value },
|
||||
onTick: ({ battle: b, unit: t, buff }) => { b.dealDamage(unit, t, { amount: buff.data.value * buff.stacks, type: 'arts', canDodge: false, tags: ['talent', 'dot', 'veenWar'] }); },
|
||||
});
|
||||
}
|
||||
/** S2: `n` more stacks (a released shot). */
|
||||
function s2Stack(battle, unit, n) {
|
||||
if (!skillOn(unit, S2) || !(s2.max > 0)) return;
|
||||
const v = stateOf(unit);
|
||||
v.k = Math.min(s2.max, v.k + n);
|
||||
battle.addBuff(unit, { key: S2_STACK, mods: { atkPct: s2.atk * v.k, aspd: s2.aspd * v.k }, tags: ['skill'] });
|
||||
}
|
||||
/** One hit of the volley: `atk` (cached at release) × `scale` arts, then `stacks` of 战争技艺. */
|
||||
function hitOnce(battle, unit, t, atk, scale, stacks, attackId, isSkill) {
|
||||
battle.dealDamage(unit, t, { amount: atk * scale * unit.s.atkScaleMul, type: 'arts', isAttack: true, isSkill, attackId, tags: ['veen'] });
|
||||
warOn(battle, unit, t, stacks);
|
||||
}
|
||||
/** S3: `n` bounces from `from`, every BOUNCE_IV s, each bounce × the cached `atk`. */
|
||||
function bounces(battle, unit, from, n, atk, attackId) {
|
||||
const hit = new Set([from]);
|
||||
let cur = from;
|
||||
const step = (left) => {
|
||||
if (left <= 0) return;
|
||||
battle.after(BOUNCE_IV, () => {
|
||||
const near = battle.enemiesInRadius(cur.x, cur.y, BOUNCE_R).filter((e) => canTargetEnemy(unit, e, ANY));
|
||||
let pool = near.filter((e) => !hit.has(e));
|
||||
if (!pool.length) pool = near.filter((e) => e !== cur);
|
||||
if (!pool.length) pool = near;
|
||||
let next = null, bd = Infinity;
|
||||
for (const e of pool) {
|
||||
const d = Math.hypot(e.x - cur.x, e.y - cur.y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && next && e.spawnSeq < next.spawnSeq)) { bd = d; next = e; }
|
||||
}
|
||||
if (!next) return; // nobody within reach: the bounces left are lost
|
||||
battle.fx('veenBounce', { x: cur.x, y: cur.y, id: unit.id, target: next.id });
|
||||
hit.add(next);
|
||||
cur = next;
|
||||
battle.dealDamage(unit, next, { amount: atk * num(b3['attack@bounce_atk_scale'], 1) * unit.s.atkScaleMul, type: 'arts', isAttack: true, attackId, tags: ['veen', 'veenBounce'] });
|
||||
warOn(battle, unit, next, 1);
|
||||
step(left - 1);
|
||||
});
|
||||
};
|
||||
step(n);
|
||||
}
|
||||
/** The main bolt of an attack lands on `victim`: every hit of its volley (released in order), then the S3 bounces. */
|
||||
function land(battle, unit, victim, attackId, isSkill) {
|
||||
const v = stateOf(unit);
|
||||
const list = v.q.get(victim.id);
|
||||
const rec = list ? list.shift() : null;
|
||||
if (list && !list.length) v.q.delete(victim.id);
|
||||
if (!rec) { hitOnce(battle, unit, victim, unit.s.atk, 1, 1, attackId, isSkill); return; }
|
||||
for (const p of rec.pre) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); }
|
||||
if (!victim.alive) return;
|
||||
hitOnce(battle, unit, victim, rec.main.atk, rec.main.scale, 1, attackId, isSkill);
|
||||
if (rec.main.bounces > 0) bounces(battle, unit, victim, rec.main.bounces, rec.main.atk, attackId);
|
||||
for (const p of rec.post) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); }
|
||||
}
|
||||
|
||||
return {
|
||||
trait: {
|
||||
hitsFn: () => 0, // the volley's hits are the kit's (land)
|
||||
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc.attackId, !!hc.isSkill); },
|
||||
// only enemies under her 战争技艺 in range: she holds her fire (and stores energy)
|
||||
canAttack(battle, u) {
|
||||
const ok = validTargets(battle, u, u.profile).length > 0;
|
||||
if (!ok) u.trait.hadTarget = false;
|
||||
return ok;
|
||||
},
|
||||
install(battle, unit) {
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.veen = null; }, { owner: unit });
|
||||
// the mystic store: her attack ready and no valid target ⇒ one energy, an attack action (the interval restarts);
|
||||
// 3 merge into a 转置能量 (the attack loop ran first this tick: a valid target was attacked instead)
|
||||
battle.on('tick', () => {
|
||||
const v = stateOf(unit);
|
||||
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || v.t >= tMax) return;
|
||||
if (validTargets(battle, unit, unit.profile).length) return;
|
||||
if (++v.s >= merge) { v.s -= merge; v.t++; }
|
||||
unit.atkCd = unit.s.interval;
|
||||
}, { owner: unit });
|
||||
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', trigger: { rule: 'SP_FULL' }, mods: { aspd: num(b1.attack_speed) } },
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { batPct: batMod(b2.base_attack_time, chess) },
|
||||
onStart({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); },
|
||||
onEnd({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo',
|
||||
ammo: num(b3['attack@trigger_time'], 1),
|
||||
mods: { aspd: num(b3.attack_speed) },
|
||||
// bullets: 1 per stored energy, merge_cnt per 转置能量 the attack released (none without)
|
||||
onAttack(ctx) {
|
||||
ctx.noAmmo = true;
|
||||
const { battle, unit, skill } = ctx;
|
||||
const v = stateOf(unit);
|
||||
const n = v.use;
|
||||
v.use = 0;
|
||||
for (let i = 0; i < n && skill.active; i++) {
|
||||
skill.ammoLeft--;
|
||||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||||
if (skill.ammoLeft <= 0) skill.end('ammo');
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // “在挥刀之前”: ATK while holding a 转置能量, ASPD while holding none
|
||||
const atk = num(t0.atk), aspd = num(t0.attack_speed);
|
||||
if (atk) toggleBuff(battle, unit, 'talent:veen:merged', () => stateOf(unit).t > 0, { atkPct: atk });
|
||||
if (aspd) toggleBuff(battle, unit, 'talent:veen:unmerged', () => stateOf(unit).t === 0, { aspd });
|
||||
} },
|
||||
{ install(battle, unit) { // 战争技艺: the stacks come with every hit (warOn); she never picks a marked enemy
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || ctx.targets.every((e) => !marked(unit, e))) return;
|
||||
const prof = ctx.profile || unit.profile;
|
||||
const cands = validTargets(battle, unit, prof);
|
||||
sortEnemyTargets(battle, unit, cands, prof.priority);
|
||||
ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// MSC-Y: ASPD while she holds stored energy
|
||||
const msc = num(hidden.attack_speed);
|
||||
if (msc) toggleBuff(battle, unit, 'trait:veen:stored', () => { const v = stateOf(unit); return v.s + v.t > 0; }, { aspd: msc });
|
||||
// the volley leaves with the attack: 转置能量 → main → stored, each with the ATK of its release (S2 stacks between)
|
||||
battle.on('attack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !ctx.targets.length) return;
|
||||
const v = stateOf(unit);
|
||||
const t = v.t, s = v.s;
|
||||
v.t = 0; v.s = 0;
|
||||
const s3 = skillOn(unit, S3);
|
||||
const pre = [], post = [];
|
||||
for (let i = 0; i < t; i++) { if (!s3) pre.push({ atk: unit.s.atk, scale: merge, stacks: merge }); s2Stack(battle, unit, merge); }
|
||||
const main = { atk: unit.s.atk, scale: s3 ? num(b3['attack@base_atk_scale'], 1) : 1, bounces: s3 ? s + merge * t : 0 };
|
||||
s2Stack(battle, unit, 1);
|
||||
for (let i = 0; i < s; i++) { if (!s3) post.push({ atk: unit.s.atk, scale: 1, stacks: 1 }); s2Stack(battle, unit, 1); }
|
||||
v.use = s3 ? s + merge * t : 0;
|
||||
ctx.targets.forEach((tg, i) => {
|
||||
const list = v.q.get(tg.id) ?? [];
|
||||
list.push(i === 0 ? { pre, main, post } : { pre: [], main: { atk: main.atk, scale: main.scale, bounces: 0 }, post: [] });
|
||||
v.q.set(tg.id, list);
|
||||
});
|
||||
if (t || s) battle.fx('veenVolley', { x: unit.x, y: unit.y, id: unit.id, target: ctx.targets[0].id, stored: s, merged: t });
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user