feedback5 O7: 自选 operator kits for 早露, 提丰 and 娜仁图亚 — every skill, talent, trait and module at every form
- kits/ops/op-poca.js 早露 (攻城手): heaviest first; 深入骨髓 60 % DEF ignore vs 重量等级 ≥ 3; 学生楷模 ATK on every 【乌萨斯学生自治团】 operator of her team (teamId student); S1 ATK, S2 ATK + 2 targets, S3 雪崩击 links the 3 heaviest, binds them and strikes each every second (6 / 7 attacks, PRTS link-break rules); SIE-X ×1.15 vs heavy + Lv3 60 % ATK extra hit; SIE-Y distance bonus ≤ 12 % + Lv3 学生楷模 +12 % and +15 % per student in skill (≤ 45 %). - kits/ops/op-typhon.js 提丰 (攻城手): 锐如兽牙 stacking DEF ignore; 重如沼泥 first hit per enemy per cast ×1.6 + 停顿 3 s; S1 ATK / ASPD; S2 two arrows (both at a lone enemy), 40 % stun, 20 s on the first cast of a deployment then endless; S3 永恒狩猎 mark (radius 1.3, follows its enemy), whole-field range, 4-arrow rain per bullet with 0.4 s stuns; SIE-X ×1.15, Lv3 12 % / 10 s; SIE-Y distance bonus, Lv3 two marks ×2.2; ISW-A attributes only (集成战略 parts N/A). - kits/ops/op-narant.js 娜仁图亚 (回环射手): 偷取 25 ATK / 20 DEF per landing attack hit (caps 250 / 200; LPS-X Lv3 300 / 240, twice within one tile; the largest thief wins; given back when she leaves); 婀娜虚影 35 % dodge and −20 % 命中率 for enemies within one tile (LPS-Y Lv3 −30 % and ATK +15 % after 6 s unhurt); S1 旋刃 toggle, range −1, up to 3 bounces; S2 恶魇 dash and the sweep on its way back; S3 吞日 3 boomerangs and the recall blast; LPS-X ×1.1 within one tile; LPS-Y 1 SP per 5 boomerangs caught. - Data triggers unchanged (all nine skills MANUAL / DEFAULT). Registered in OPERATOR_KIT_FILES (one line per file). - Tests: test/content/op_poca.test.js (11), op_typhon.test.js (12), op_narant.test.js (11, two on the engine hooks). - Golden: test/golden/diy.json only — diy-003 … diy-009 now field the three kits in every form × module × skill, the prototypes / 预备干员 re-packed into diy-009 … diy-013; diy-001 / diy-002 (推进之王) and every other family unchanged.
This commit is contained in:
1 parent
12fa92d9f6
commit
5643dab2cb
8 files changed
+2104
-100
No files matched your search
@@ -78,7 +78,12 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh
|
||||
* The 自选 operator kit files (`ops/op-<codename>.js`, registry key = the operator's charId, an owned-6★ pick of
|
||||
* data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS).
|
||||
*/
|
||||
export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']);
|
||||
export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-siege.js',
|
||||
'op-poca.js',
|
||||
'op-typhon.js',
|
||||
'op-narant.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// server/sim/content/kits/ops/op-narant.js — 娜仁图亚 (char_4138_narant) 自选 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_4138_narant, 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 娜仁图亚 (备注 of 旋刃
|
||||
// and 恶魇); PRTS 分支特性信息 回环射手; gamedata_const ba.steal 偷取; PRTS 命中率.
|
||||
// - Trait (回环射手) "持有回旋投射物时才能够攻击(投射物需要时间回收)": the profession's boomerang (professions.js loopshooter,
|
||||
// ai.js throwBoomerang: out 15, back 3.75 tiles/s; she attacks again once every boomerang is caught); hits air units.
|
||||
// - Module LPS-X “吹尽狂沙”: trait "攻击周围8格的敌人时攻击力提升至110%" (trait bb atk_scale; the hidden module talent repeats it):
|
||||
// ×atk_scale on her attack hits on an enemy within one tile (3 × 3, as 跃跃's LPS-X). Stage 2+ “我见,我得” (below).
|
||||
// - Module LPS-Y “致我们的老大”: trait "每回收5次回旋投射物时获得1点技力" (come_back_cnt / sp): every come_back_cnt boomerangs caught
|
||||
// (hook boomerangCaught; an S3 flight carries cnt) give sp SP — none while a skill of hers runs (the AK rule: giveSp)
|
||||
// [ASSUMED: the catches count all the same]. Stage 2+ 婀娜虚影 (below).
|
||||
// - T1 “我见,我得” "在场时,每次攻击到敌人偷取其25点攻击力(最高250点)与20点防御力(最高200点)": every attack hit of hers that lands
|
||||
// (normal attacks, S1 bounces, S2 hits and its way back, each S3 boomerang — not S3's recall blast [ASSUMED]) steals:
|
||||
// the enemy loses steal_atk ATK / steal_def DEF of its base (flat, at most steal_atk_max / steal_def_max in all) and she
|
||||
// gains as much (flat, the same caps) — ba.steal "减少目标的基础属性作为自身加成,目标减少和自身加成的属性不超过指定上限(同类属性
|
||||
// 取最高)": one loss per enemy, the largest of the 娜仁图亚 that stole from it. Both last while she is on the field (在场时)
|
||||
// [ASSUMED: they end when she leaves]; each steal adds to both sides, each capped on its own [ASSUMED]. LPS-X stage 2+ (caps
|
||||
// 275 / 220 → 300 / 240) "攻击到周围8格的敌人时偷取触发2次": the count is parsed from the talent text (no blackboard key).
|
||||
// - T2 婀娜虚影 "获得35%的物理与法术闪避,周围8格内敌人的物理与法术命中率-20%": dodgePhys / dodgeArts +prob; every physical / arts
|
||||
// attack of an enemy within one tile of her misses with damage_hitrate_* (PRTS 命中率: one roll when the attack's first
|
||||
// damage instance comes — as Raidian's 诱引, standin-acsupo.js —, a miss cancels every instance and its riders; two
|
||||
// 娜仁图亚 never add up). LPS-Y stage 3 −30 % and "6秒内未受到伤害时,攻击力+15%" (hidden atk / interval): ATK +atk while she
|
||||
// has taken no damage (流失 aside) for `interval` s — from her deployment on, as 远牙's 专注 [ASSUMED].
|
||||
// - S1 旋刃 (切换: a toggle that stays on until she leaves — the kits' reading, 银灰 S2 [ASSUMED]): her range loses its far
|
||||
// column (攻击距离-1: ability_range_forward_extend −1; the engine's rangeExtend only grows), her boomerang hits for
|
||||
// attack@atk_scale × ATK and then bounces up to attack@times times (PRTS 备注: radius BOUNCE_RADIUS from the boomerang,
|
||||
// an enemy this attack has not hit first, then the nearest; BOUNCE_SPEED tiles/s), each bounce a hit of the same attack;
|
||||
// then it flies back. The data's DEFAULT trigger (the running range is smaller).
|
||||
// - S2 恶魇 (30 s, attack SP): each hit attack@atk_scale × ATK + 停顿 attack@sluggish s; the boomerang then flies on
|
||||
// move_ahead_time s at DASH_SPEED tiles/s (PRTS 备注 "抵达目标所在位置后以1.0的速度再向前运动0.5s,随后返回") and on its way
|
||||
// back hits every enemy within attack@projectile_range (备注 "投射物碰撞半径1.0") once for attack@atk_scale_comeback × ATK.
|
||||
// - S3 吞日 (20 s): each attack throws cnt boomerangs at the target (each attack@atk_scale × ATK; they fly together, as
|
||||
// 跃跃's S2 [ASSUMED: the text names no other targets]); when they are caught she deals atk_scale_aoe × ATK physical to at
|
||||
// most attack@aoe.max_target enemies within one tile (her order) and 停顿 `sluggish` s — a skill hit, not an attack.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skchr_narant_1';
|
||||
const S2 = 'skchr_narant_2';
|
||||
const S3 = 'skchr_narant_3';
|
||||
/** PRTS 旋刃 备注: "弹射半径1.7…弹射期间投射物移动速度6.0". */
|
||||
const BOUNCE_RADIUS = 1.7;
|
||||
const BOUNCE_SPEED = 6;
|
||||
/** PRTS 恶魇 备注: "以1.0的速度再向前运动0.5s" (the time is the blackboard's move_ahead_time). */
|
||||
const DASH_SPEED = 1;
|
||||
/** "周围8格": her tile and the eight around it. */
|
||||
const AROUND = Object.freeze([[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]].map(Object.freeze));
|
||||
/** 婀娜虚影's battle-wide `hit` handler runs before the enemy content's own riders (200), as Raidian's 诱引. */
|
||||
const MISS_PRIORITY = 300;
|
||||
const LOOT_KEY = 'talent:narant:loot';
|
||||
const STOLEN_KEY = 'narant:stolen';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
|
||||
/** The named talent record `i` (data index ≥ 0) of the composed record. */
|
||||
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
|
||||
|
||||
/** A range grid whose rows lose their `n` farthest tiles ("攻击距离-n"). */
|
||||
function shortenGrid(grid, n) {
|
||||
const far = new Map();
|
||||
for (const [dr, dc] of grid) far.set(dr, Math.max(far.get(dr) ?? -Infinity, dc));
|
||||
return grid.filter(([dr, dc]) => dc <= far.get(dr) - n).map(([dr, dc]) => [dr, dc]);
|
||||
}
|
||||
|
||||
// ---- T1 偷取: the losses of the enemies, one battle-wide table (同类属性取最高 over several thieves) ----------------------
|
||||
/** battle → Map(enemy → Map(thief id → { a, d })). */
|
||||
const STOLEN = new WeakMap();
|
||||
const stolenOf = (battle) => { let m = STOLEN.get(battle); if (!m) STOLEN.set(battle, (m = new Map())); return m; };
|
||||
function applyLoss(battle, e, per) {
|
||||
let a = 0, d = 0;
|
||||
for (const v of per.values()) { a = Math.max(a, v.a); d = Math.max(d, v.d); }
|
||||
if (!(a > 0) && !(d > 0)) { battle.removeBuff(e, STOLEN_KEY); return; }
|
||||
battle.addBuff(e, { key: STOLEN_KEY, mods: { atkFlat: -a, defFlat: -d }, tags: ['steal'] });
|
||||
}
|
||||
|
||||
// ---- T2 命中率: one battle-wide handler over every 娜仁图亚 of the battle -------------------------------------------------
|
||||
/** battle → { srcs: [{ unit, phys, arts }], last: WeakMap(enemy → { id, miss }) }. */
|
||||
const MISS = new WeakMap();
|
||||
function missCut(st, e, type) {
|
||||
let cut = 0;
|
||||
for (const s of st.srcs) {
|
||||
const v = type === 'arts' ? s.arts : s.phys;
|
||||
if (v > cut && up(s.unit) && cheb(s.unit, e) <= 1) cut = v;
|
||||
}
|
||||
return Math.min(1, cut);
|
||||
}
|
||||
function missHit(battle, st, ctx) {
|
||||
const e = ctx.source, d = ctx.dmg;
|
||||
if (!e || e.side !== 'enemy' || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return;
|
||||
// one roll per attack: every damage instance of it shares the first instance's result
|
||||
const prev = d.attackId ? st.last.get(e) : null;
|
||||
let miss;
|
||||
if (prev && prev.id === d.attackId) miss = prev.miss;
|
||||
else {
|
||||
const cut = missCut(st, e, d.type);
|
||||
miss = cut > 0 && battle.rng() < cut;
|
||||
if (d.attackId) st.last.set(e, { id: d.attackId, miss });
|
||||
}
|
||||
if (!miss) return;
|
||||
d.cancel = true;
|
||||
ctx.stopPropagation = true;
|
||||
if (ctx.target) battle.fx('dodge', { x: ctx.target.x, y: ctx.target.y, id: ctx.target.id });
|
||||
}
|
||||
function installMiss(battle, unit, phys, arts) {
|
||||
if (!(phys > 0) && !(arts > 0)) return;
|
||||
let st = MISS.get(battle);
|
||||
if (!st) {
|
||||
st = { srcs: [], last: new WeakMap() };
|
||||
MISS.set(battle, st);
|
||||
// no owner: it serves every 娜仁图亚 of the battle, whichever is on the field
|
||||
battle.on('hit', (ctx) => missHit(battle, st, ctx), { priority: MISS_PRIORITY });
|
||||
}
|
||||
st.srcs.push({ unit, phys, arts });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4138_narant: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
const hidden = moduleBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const cnt = count(b3.cnt, 3);
|
||||
|
||||
/** S1: the boomerang bounces from (ctx.x, ctx.y) — at most attack@times jumps — then flies back. */
|
||||
const bounce = (ctx) => {
|
||||
const { battle, unit } = ctx;
|
||||
const hit = new Set(ctx.target ? [ctx.target] : []);
|
||||
let on = ctx.target, x = ctx.x, y = ctx.y, left = count(b1['attack@times'], 3);
|
||||
const next = () => {
|
||||
if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; }
|
||||
const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile));
|
||||
if (!cands.length) { ctx.comeBack(x, y); return; }
|
||||
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
|
||||
cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq);
|
||||
const tgt = cands[0];
|
||||
left--;
|
||||
battle.addProjectile({ from: { x, y }, target: tgt, speed: BOUNCE_SPEED, visual: 'boomerang', source: unit, hitDead: true,
|
||||
onHit: (c) => {
|
||||
x = c.x; y = c.y;
|
||||
on = c.target;
|
||||
if (c.target) { hit.add(c.target); ctx.hit(c.target, c.x, c.y); }
|
||||
next();
|
||||
} });
|
||||
battle.fx('bounce', { x, y, id: unit.id, target: tgt.id });
|
||||
};
|
||||
next();
|
||||
};
|
||||
|
||||
/** S2: the boomerang dashes on, then hits every enemy it passes on its way back (once each). */
|
||||
const dash = (ctx) => {
|
||||
const { battle, unit } = ctx;
|
||||
let dx = ctx.x - unit.x, dy = ctx.y - unit.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; }
|
||||
const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5));
|
||||
battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true,
|
||||
onHit: (c) => {
|
||||
const back = ctx.comeBack(c.x, c.y);
|
||||
if (!back) return;
|
||||
const r = num(b2['attack@projectile_range'], 1), scale = num(b2['attack@atk_scale_comeback'], 1);
|
||||
const passed = new Set();
|
||||
const h = battle.on('tick', () => {
|
||||
for (const e of battle.foesInRadius(back.x, back.y, r)) {
|
||||
if (passed.has(e) || !canTargetEnemy(unit, e, ctx.profile)) continue;
|
||||
passed.add(e);
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isAttack: true, isSkill: true, attackId: ctx.attackId, tags: ['skill', 'narant:comeback'] });
|
||||
}
|
||||
if ((back.x === back.tx && back.y === back.ty) || !ctx.home()) battle.off(h);
|
||||
}, { owner: unit });
|
||||
} });
|
||||
};
|
||||
|
||||
/** S3: the recall blast on at most attack@aoe.max_target enemies around her. */
|
||||
const blast = (battle, unit) => {
|
||||
if (!up(unit)) return;
|
||||
const n = count(b3['attack@aoe.max_target'], 3), scale = num(b3.atk_scale_aoe, 1), slug = num(b3.sluggish);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'narant:sunswallower' });
|
||||
for (const e of enemiesInGrid(battle, unit, AROUND, { n, canHitFly: true })) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'narant:blast'] });
|
||||
if (e.alive && slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'toggle',
|
||||
targeting: { rangeGrid: shortenGrid(chess.rangeGrid ?? [[0, 1]], Math.max(0, -Math.round(num(b1.ability_range_forward_extend, -1)))) },
|
||||
attack: { atkScale: num(b1['attack@atk_scale'], 1), boomerangOnward: bounce },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
attack: { atkScale: num(b2['attack@atk_scale'], 1), onHitStatus: { key: 'sluggish', duration: num(b2['attack@sluggish'], 1) }, boomerangOnward: dash },
|
||||
},
|
||||
[S3]: { kind: 'duration', attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: cnt } },
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // “我见,我得”: each landing attack hit steals ATK / DEF (LPS-X stage 2+: twice within one tile)
|
||||
const sa = num(t0['attack@steal_atk']), sd = num(t0['attack@steal_def']);
|
||||
if (!(sa > 0) && !(sd > 0)) return;
|
||||
const capA = num(t0['attack@steal_atk_max'], Infinity), capD = num(t0['attack@steal_def_max'], Infinity);
|
||||
const m = /周围8格的敌人时偷取触发(\d+)次/.exec(String(talentRec(chess, 0)?.desc ?? ''));
|
||||
const nearTimes = m ? Math.max(1, +m[1]) : 1;
|
||||
const losses = stolenOf(battle);
|
||||
const victims = new Set();
|
||||
const reset = () => { unit.mem.narantLoot = { a: 0, d: 0 }; };
|
||||
reset();
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) reset(); }, { owner: unit });
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !c.dmg?.isAttack || !e || e.side !== 'enemy' || !up(unit)) return;
|
||||
const times = nearTimes > 1 && cheb(unit, e) <= 1 ? nearTimes : 1;
|
||||
const me = unit.mem.narantLoot;
|
||||
let per = losses.get(e);
|
||||
if (!per) losses.set(e, (per = new Map()));
|
||||
const loss = per.get(unit.id) ?? { a: 0, d: 0 };
|
||||
for (let i = 0; i < times; i++) {
|
||||
me.a = Math.min(capA, me.a + sa); me.d = Math.min(capD, me.d + sd);
|
||||
loss.a = Math.min(capA, loss.a + sa); loss.d = Math.min(capD, loss.d + sd);
|
||||
}
|
||||
per.set(unit.id, loss);
|
||||
victims.add(e);
|
||||
battle.addBuff(unit, { key: LOOT_KEY, mods: { atkFlat: me.a, defFlat: me.d }, tags: ['talent'] });
|
||||
if (e.alive) applyLoss(battle, e, per);
|
||||
}, { owner: unit });
|
||||
// 在场时: off the field she keeps nothing (her buff goes with the removal) and the enemies get back what she took
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
for (const e of victims) {
|
||||
const per = losses.get(e);
|
||||
if (!per || !per.delete(unit.id)) continue;
|
||||
if (e.alive) applyLoss(battle, e, per);
|
||||
}
|
||||
victims.clear();
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 婀娜虚影: dodge; enemies within one tile miss; LPS-Y stage 3: ATK + while unhurt
|
||||
const p = num(t1.prob);
|
||||
statBuff(battle, unit, 'talent:narant:dodge', { dodgePhys: p, dodgeArts: p });
|
||||
installMiss(battle, unit, Math.abs(num(t1.damage_hitrate_physical)), Math.abs(num(t1.damage_hitrate_magical)));
|
||||
const atk = num(hidden.atk), iv = num(hidden.interval);
|
||||
if (!(atk > 0) || !(iv > 0)) return;
|
||||
unit.mem.narantHurt = -Infinity;
|
||||
battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.narantHurt = battle.time; }, { owner: unit });
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.narantHurt = -Infinity; }, { owner: unit });
|
||||
toggleBuff(battle, unit, 'talent:narant:calm', () => battle.time - unit.mem.narantHurt >= iv - 1e-9, { atkPct: atk });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// LPS-X: ×atk_scale on attack hits within one tile
|
||||
const near = num(tb.atk_scale, 1);
|
||||
if (near !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && cheb(unit, target) <= 1) dmg.amount *= near; });
|
||||
// LPS-Y: sp SP every come_back_cnt boomerangs caught; S3: the recall blast
|
||||
const every = Math.floor(num(tb.come_back_cnt)), sp = num(tb.sp);
|
||||
const s3 = unit.skill?.id === S3;
|
||||
if (!(every > 0 && sp > 0) && !s3) return;
|
||||
unit.mem.narantCaught = 0;
|
||||
battle.on('boomerangCaught', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
const flightS3 = s3 && c.isSkill;
|
||||
if (every > 0 && sp > 0) {
|
||||
unit.mem.narantCaught += flightS3 ? cnt : 1;
|
||||
while (unit.mem.narantCaught >= every) { unit.mem.narantCaught -= every; giveSp(unit, sp, 'module'); }
|
||||
}
|
||||
if (flightS3) blast(battle, unit);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
// server/sim/content/kits/ops/op-poca.js — 早露 (char_197_poca) 自选 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_197_poca, 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 早露 (备注 of
|
||||
// 深入骨髓, 学生楷模 and 雪崩击); PRTS 分支特性信息 攻城手 ("可对空。攻击范围不包含自身所在地块").
|
||||
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js: the highest current 重量等级 first, then
|
||||
// the usual order); ranged physical arrows that hit air units (data canHitFly, PRTS "可对空"), range 4-3.
|
||||
// - Module SIE-X 攻城器械训练装置: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" — the hidden module talent
|
||||
// (atk_scale / value, merged into 深入骨髓's blackboard): ×atk_scale on her attacks (normal attacks and the S3 strikes, the
|
||||
// profile dmgMul — the kits' "攻击…时攻击力提升至X%" convention, 斯卡蒂 DRE-X) on an enemy of 重量等级 ≥ value. Stage 2+ 深入骨髓
|
||||
// "…并额外造成60%攻击力的物理伤害" (extra_atk_scale): every attack hit that lands on such an enemy is followed by a physical hit
|
||||
// of extra_atk_scale × ATK (which 深入骨髓's 物理穿透 also gets).
|
||||
// - Module SIE-Y “心”: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" (trait bb min_dist / max_dist / damage_scale): her attack
|
||||
// damage × (1 + damage_scale × clamp((d − min_dist) / (max_dist − min_dist), 0, 1)), d = her distance to the target in tiles —
|
||||
// the kits' reading of these keys (远牙, 协律, 芳汀 modules) [ASSUMED linear: PRTS states no formula]. Stage 2+: 学生楷模 below.
|
||||
// - T1 深入骨髓 "攻击重量较重(重量等级大于等于3)的敌人时,无视其防御力的60%": every damage she deals to an enemy of 重量等级 ≥ value
|
||||
// ignores def_penetrate of its DEF (DamageInfo defIgnorePct, added — PRTS 备注 "临时获取永久的物理穿透(百分比)Buff(不可叠加,
|
||||
// 直接加算)").
|
||||
// - T2 学生楷模 "编入队伍时,所有【乌萨斯学生自治团】干员攻击力+8%": every 【乌萨斯学生自治团】 operator of her player's team ATK +atk
|
||||
// for the whole battle, deployed or not (the kits' 编入队伍时 convention, 斯卡蒂 深海掠食者). SIE-Y stage 3: +12 % and
|
||||
// "每有一名【乌萨斯学生自治团】干员处于技能期间时,【乌萨斯学生自治团】干员的攻击力额外+15%(最多+45%)" (hidden init_atk /
|
||||
// max_atk): the number of those operators on the field whose skill runs, re-read every STUDENT_TICK s and at every skill start
|
||||
// (PRTS 备注 "每0.3秒及每次开启技能时更新一次自身的额外加成") [ASSUMED: her team's operators only, as the base talent].
|
||||
// - S1 攻击力强化·γ型 (30 s): ATK +atk. S2 分裂射击 (60 s): ATK +atk, attack@max_target targets.
|
||||
// - S3 雪崩击 (6 / 7 s): ATK +atk; at the cast, harpoons link the max_target heaviest enemies of her range (her own target
|
||||
// order); each linked enemy is 束缚 (bind) while linked and takes one attack every hit_interval s, hit_duration /
|
||||
// hit_interval attacks in all, the first as the harpoons land (the tick after the cast) [ASSUMED] — her own attacks (Battle.forceAttack:
|
||||
// every attack rider of hers applies), instant along the links; she makes no other attack meanwhile [ASSUMED: the
|
||||
// strikes are what "每秒受到一次攻击" names]. PRTS 备注: a link breaks when its enemy is knocked out, 离地 (OUT_OF_GROUND —
|
||||
// no enemy of the mode holds it) or 消失 (hidden), every link at once when she is stunned / frozen / silenced, and the
|
||||
// skill ends as soon as no link is left. The binds end with their links.
|
||||
|
||||
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skcom_atk_up[3]';
|
||||
const S2 = 'skchr_poca_2';
|
||||
const S3 = 'skchr_poca_3';
|
||||
/** 【乌萨斯学生自治团】: character_table teamId `student` (古米, 凛冬, 烈夏, 苦艾, 真理, 早露, 怒潮凛冬); data/chess.json has no team id. */
|
||||
const STUDENTS = new Set(['char_196_sunbr', 'char_115_headbr', 'char_194_leto', 'char_405_absin', 'char_195_glassb',
|
||||
'char_197_poca', 'char_1051_headb2']);
|
||||
/** PRTS 学生楷模 备注: the SIE-Y extra is re-read every 0.3 s (and at every skill start). */
|
||||
const STUDENT_TICK = 0.3;
|
||||
const STUDENT_KEY = 'talent:poca:students';
|
||||
const DRIVE_KEY = 'talent:poca:studentSkills';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
|
||||
const isStudent = (a) => !!a && a.kind === 'op' && STUDENTS.has(a.def?.charId);
|
||||
/** A running skill of `a` (a passive is no 技能期间). */
|
||||
const inSkill = (a) => !!(a.skill && a.skill.active && a.skill.kind !== 'passive');
|
||||
|
||||
export default {
|
||||
char_197_poca: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const heavyAt = num(t0.value, 3);
|
||||
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
|
||||
// SIE-X (hidden part on 深入骨髓's index: atk_scale / value; stage 2+ extra_atk_scale), SIE-Y (trait bb; hidden
|
||||
// init_atk / max_atk at stage 3) — chess is the loadout-resolved record, so no module ⇒ none of these keys
|
||||
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
|
||||
const extra = num(t0.extra_atk_scale);
|
||||
const tb = traitBb(chess);
|
||||
const hidden = moduleBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
|
||||
/** S3: the links of the running cast (unit.mem.pocaS3 = { links: Set, left, timer }). */
|
||||
const unbind = (battle, unit, e) => {
|
||||
const b = e.findBuff('bind');
|
||||
if (b && b.source === unit) battle.removeBuff(e, b);
|
||||
};
|
||||
const linkBroken = (e) => !e.alive || !e.deployed || e.hidden || !!e.s.flags.outOfGround;
|
||||
const strike = (battle, unit, st) => {
|
||||
const targets = [...st.links].filter((e) => !linkBroken(e));
|
||||
if (!targets.length || !unit.canAct || st.left <= 0) return;
|
||||
st.left--;
|
||||
battle.forceAttack(unit, targets);
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } },
|
||||
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } },
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
// no attack of her own while the links hold; the strikes are instant along them ('beam')
|
||||
attack: { noAttack: true, projectile: 'beam' },
|
||||
onStart({ battle, unit, skill }) {
|
||||
const links = enemiesInGrid(battle, unit, null, { n: count(b3.max_target, 3), priority: 'heaviest', canHitFly: true });
|
||||
const st = { links: new Set(links), left: count(num(b3.hit_duration, skill.duration) / Math.max(0.1, num(b3.hit_interval, 1)), 6), timer: null };
|
||||
unit.mem.pocaS3 = st;
|
||||
for (const e of links) {
|
||||
battle.applyStatus(e, 'bind', { duration: skill.timeLeft, source: unit });
|
||||
battle.fx('harpoon', { x: e.x, y: e.y, id: unit.id, target: e.id });
|
||||
}
|
||||
// the harpoons land on the next tick (after the cast's skillStart handlers — 学生楷模's re-read), then every
|
||||
// hit_interval s
|
||||
st.timer = battle.after(0, () => {
|
||||
if (unit.mem.pocaS3 !== st) return;
|
||||
strike(battle, unit, st);
|
||||
st.timer = battle.every(Math.max(0.1, num(b3.hit_interval, 1)), (b, sc) => {
|
||||
if (unit.mem.pocaS3 !== st || st.left <= 0) { sc.cancel(); return; }
|
||||
strike(battle, unit, st);
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
},
|
||||
onTick({ battle, unit, skill }) {
|
||||
const st = unit.mem.pocaS3;
|
||||
if (!st) return;
|
||||
const f = unit.s.flags;
|
||||
if (f.stun || f.freeze || f.silence) { skill.end('interrupted'); return; }
|
||||
for (const e of [...st.links]) if (linkBroken(e)) { st.links.delete(e); unbind(battle, unit, e); }
|
||||
if (!st.links.size) skill.end('unlinked');
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
const st = unit.mem.pocaS3;
|
||||
unit.mem.pocaS3 = null;
|
||||
if (!st) return;
|
||||
if (st.timer) st.timer.cancel();
|
||||
for (const e of st.links) unbind(battle, unit, e);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 深入骨髓: +def_penetrate 物理穿透 on heavy enemies; SIE-X stage 2+: the extra physical hit
|
||||
const pen = num(t0.def_penetrate);
|
||||
if (pen > 0) onHitBy(battle, unit, ({ target, dmg }) => { if (heavy(target)) dmg.defIgnorePct += pen; });
|
||||
if (extra > 0) {
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !c.dmg?.isAttack || !heavy(c.target) || !hasHp(c.target)) return;
|
||||
battle.dealDamage(unit, c.target, { amount: unit.s.atk * extra, type: 'phys', tags: ['talent', 'poca:extra'] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 学生楷模: every student of her team ATK +atk; SIE-Y stage 3: +init_atk per student in skill
|
||||
const team = battle.allyUnits.filter((a) => a.ownerId === unit.ownerId && isStudent(a));
|
||||
for (const a of team) statBuff(battle, a, STUDENT_KEY, { atkPct: num(t1.atk) });
|
||||
const per = num(hidden.init_atk), cap = num(hidden.max_atk, Infinity);
|
||||
if (!(per > 0)) return;
|
||||
const update = () => {
|
||||
const v = Math.min(cap, per * team.filter((a) => up(a) && inSkill(a)).length);
|
||||
for (const a of team) {
|
||||
const cur = a.findBuff(DRIVE_KEY);
|
||||
if (!(v > 0)) { if (cur) battle.removeBuff(a, cur); continue; }
|
||||
if (!cur || cur.mods?.atkPct !== v) battle.addBuff(a, { key: DRIVE_KEY, mods: { atkPct: v }, persist: true, allowDead: true, tags: ['talent'], source: unit });
|
||||
}
|
||||
};
|
||||
battle.every(STUDENT_TICK, update, { owner: unit, immediate: true });
|
||||
battle.on('skillStart', (c) => { if (team.includes(c.unit)) update(); }, { owner: unit });
|
||||
} },
|
||||
],
|
||||
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
|
||||
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
|
||||
install(battle, unit) {
|
||||
// SIE-Y: the farther the target, the more damage (attacks only)
|
||||
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
|
||||
if (ds > 0) {
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
// server/sim/content/kits/ops/op-typhon.js — 提丰 (char_2012_typhon) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_2012_typhon, 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 提丰 (备注 of 锐如兽牙,
|
||||
// 重如沼泥, 冰原秩序 and “永恒狩猎”); PRTS 分支特性信息 攻城手 ("可对空").
|
||||
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js); ranged physical arrows that hit air units,
|
||||
// range 4-3 (as 早露, kits/ops/op-poca.js).
|
||||
// - Module SIE-X 自然的包容: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" (the hidden module talent's atk_scale /
|
||||
// value on 锐如兽牙's index): ×atk_scale on her attacks against an enemy of 重量等级 ≥ value (profile dmgMul); stage 2+ 锐如兽牙's
|
||||
// numbers (12 % a stack, 10 s) come with the composed talent.
|
||||
// - Module SIE-Y 冰原的影子: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" — as 早露's SIE-Y (her attack damage, linear between
|
||||
// min_dist and max_dist [ASSUMED]); ASPD in the stats; stage 2+ 重如沼泥 "前两次…攻击力提升至220%" (max_stack_cnt / atk_scale).
|
||||
// - Module ISW-A 提丰特限证章: its trait and talent parts say "在集成战略中" — no effect in this mode (the hidden damage_scale /
|
||||
// arrow-rain blackboards are ignored); its HP / ATK are in the stats.
|
||||
// - T1 锐如兽牙 "连续攻击时逐渐无视敌人的防御力,最高无视其防御力的50%(每次攻击提升10%的无视防御比例),8秒内未攻击则失去加成": every
|
||||
// attack she makes adds a stack (max_stack_cnt) of +def_penetrate 物理穿透 (defIgnorePct, added — PRTS 备注 "增加自身物理穿透(百分
|
||||
// 比)属性(直接加算)") before its damage ("获得的加成可应用于当次伤害"), all of them lost `duration` s after her last attack.
|
||||
// - T2 重如沼泥 "技能期间对每个敌人首次造成伤害时,攻击力提升至160%并使目标停顿3秒": while a skill of hers runs, her first (SIE-Y stage
|
||||
// 2+: first max_stack_cnt) damage instances on each enemy deal ×atk_scale (攻击力倍率) and, when they land, 停顿 `sluggish` s —
|
||||
// every damage of hers, the S3 arrow rain included ("造成伤害"); the marks are cleared when the skill ends or she leaves
|
||||
// (PRTS 备注 "非首次标记…自身技能结束时或自身离场时移除"). A dodged instance spends its mark [ASSUMED: "于计算伤害前触发"].
|
||||
// - S1 迅捷打击·γ型 (35 s): ATK +atk, ASPD +attack_speed.
|
||||
// - S2 冰原秩序: ATK +atk; every attack shoots two arrows, at two different enemies when there are two (targeting 2), else both
|
||||
// at the one (PRTS "优先攻击不同目标"); each arrow attack@prob to stun attack@stun s. first_duration s on its first cast of a
|
||||
// deployment, then "持续时间无限" until she leaves (a toggle the kit ends after first_duration s; PRTS 备注 "每次部署提丰时,
|
||||
// 重新计算该技能的使用次数").
|
||||
// - S3 “永恒狩猎” (ammo attack@s3_trigger_time): base attack time +base_attack_time s (flat: batMod). At the cast she marks the first
|
||||
// enemy of her own range (未开启技能时的攻击范围: the initial range, her heaviest-first order); the mark area (radius MARK_RADIUS,
|
||||
// 碰撞判定 — foesInRadius) follows that enemy until it leaves the field, then stays; its enemies are re-read every MARK_SCAN s.
|
||||
// Her range is the whole field (the card shows 全场) but she attacks only while an enemy is in the mark area; each attack
|
||||
// deals nothing itself and drops a round of s3_max_hit_num arrows, RAIN_GAP s apart, each on a random enemy of the area: a
|
||||
// physical skill hit (not an attack: PRTS "不属于普通攻击/技能攻击的直接伤害", so the SIE-X / SIE-Y attack riders do not apply
|
||||
// [ASSUMED]) of s3_atk_scale × ATK, then 晕眩 s3_stun s once that damage landed.
|
||||
// Auto-cast: the data's DEFAULT (the whole-field range exists only in PRTS's note; the mark is picked from her own range).
|
||||
|
||||
import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js';
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { ROWS, COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skcom_quickattack[3]';
|
||||
const S2 = 'skchr_typhon_2';
|
||||
const S3 = 'skchr_typhon_3';
|
||||
/** PRTS S3 备注: "标记区域半径1.3(碰撞判定),每0.2s更新范围内的敌人", "箭雨的每支箭间隔0.1s且分别索敌". */
|
||||
const MARK_RADIUS = 1.3;
|
||||
const MARK_SCAN = 0.2;
|
||||
const RAIN_GAP = 0.1;
|
||||
/** S3 "攻击范围扩大至全场": every offset that reaches the whole 19 × 21 field whatever her direction. */
|
||||
const FIELD = (() => {
|
||||
const m = Math.max(ROWS, COLS) - 1;
|
||||
const g = [];
|
||||
for (let dr = -m; dr <= m; dr++) for (let dc = -m; dc <= m; dc++) g.push(Object.freeze([dr, dc]));
|
||||
return Object.freeze(g);
|
||||
})();
|
||||
const FANG_KEY = 'talent:typhon:fang';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
|
||||
|
||||
export default {
|
||||
char_2012_typhon: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
// SIE-X: the hidden part merged into 锐如兽牙's blackboard (no module / SIE-Y / ISW-A ⇒ no atk_scale there)
|
||||
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
|
||||
const heavyAt = num(t0.value, 3);
|
||||
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
|
||||
/** S3: the enemies of the mark area (last scan) her attacks may still select. */
|
||||
const areaFoes = (unit, st) => st.inArea.filter((e) => e.alive && e.deployed && !e.hidden && canTargetEnemy(unit, e, unit.profile));
|
||||
const follow = (st) => { const t = st.target; if (t && t.alive && t.deployed && !t.hidden) { st.x = t.x; st.y = t.y; } };
|
||||
const scan = (battle, unit, st) => { st.inArea = battle.foesInRadius(st.x, st.y, MARK_RADIUS).filter((e) => canTargetEnemy(unit, e, unit.profile)); };
|
||||
const rain = (battle, unit) => {
|
||||
const st = unit.mem.typhonMark;
|
||||
if (!st) return;
|
||||
battle.fx('arrowRain', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id });
|
||||
const scale = num(b3['attack@s3_atk_scale'], 1), stun = num(b3['attack@s3_stun']);
|
||||
const arrow = () => {
|
||||
const pool = areaFoes(unit, st);
|
||||
if (!pool.length) return;
|
||||
const e = battle.rng.pick(pool);
|
||||
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'typhon:rain'] });
|
||||
if (dealt > 0 && e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
|
||||
};
|
||||
arrow();
|
||||
for (let k = 1; k < count(b3['attack@s3_max_hit_num'], 4); k++) battle.after(RAIN_GAP * k, arrow, { owner: unit });
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
|
||||
[S2]: {
|
||||
kind: 'toggle',
|
||||
mods: { atkPct: num(b2.atk) },
|
||||
targeting: { maxTargets: 2 },
|
||||
attack: {
|
||||
onHit({ battle, unit, target }) {
|
||||
if (target && target.alive && battle.rng.chance(num(b2['attack@prob']))) battle.applyStatus(target, 'stun', { duration: num(b2['attack@stun'], 1), source: unit });
|
||||
},
|
||||
},
|
||||
onStart({ unit, skill }) {
|
||||
unit.mem.typhonS2Uses = (unit.mem.typhonS2Uses ?? 0) + 1;
|
||||
skill.timeLeft = unit.mem.typhonS2Uses > 1 ? Infinity : Math.max(0.01, num(b2.first_duration, skill.duration));
|
||||
},
|
||||
onTick({ skill, dt }) {
|
||||
if (!Number.isFinite(skill.timeLeft)) return;
|
||||
skill.timeLeft -= dt;
|
||||
if (skill.timeLeft <= 1e-9) skill.end('duration');
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo',
|
||||
ammo: count(b3['attack@s3_trigger_time'], 8),
|
||||
mods: { batPct: batMod(b3.base_attack_time, chess) },
|
||||
targeting: { rangeGrid: FIELD },
|
||||
attack: {
|
||||
projectile: 'beam',
|
||||
hitsFn: () => 0,
|
||||
canAttack: (battle, unit) => !!unit.mem.typhonMark && areaFoes(unit, unit.mem.typhonMark).length > 0,
|
||||
onHit({ battle, unit }) { rain(battle, unit); },
|
||||
},
|
||||
onStart({ battle, unit }) {
|
||||
const cands = battle.enemiesInKeys(unit.baseRangeKeys ?? unit.rangeKeys, unit, unit.profile);
|
||||
sortEnemyTargets(battle, unit, cands, 'heaviest');
|
||||
const t = cands[0] ?? null;
|
||||
const st = { target: t, x: t ? t.x : unit.x, y: t ? t.y : unit.y, inArea: [], timer: null };
|
||||
unit.mem.typhonMark = st;
|
||||
scan(battle, unit, st);
|
||||
st.timer = battle.every(MARK_SCAN, (b, sc) => {
|
||||
if (unit.mem.typhonMark !== st) { sc.cancel(); return; }
|
||||
follow(st);
|
||||
scan(battle, unit, st);
|
||||
}, { owner: unit });
|
||||
battle.fx('mark', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id, target: t ? t.id : null });
|
||||
},
|
||||
onTick({ unit }) { const st = unit.mem.typhonMark; if (st) follow(st); },
|
||||
onEnd({ unit }) {
|
||||
const st = unit.mem.typhonMark;
|
||||
unit.mem.typhonMark = null;
|
||||
if (st && st.timer) st.timer.cancel();
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 锐如兽牙: +def_penetrate 物理穿透 a stack per attack (max_stack_cnt), gone `duration` s after the last
|
||||
const per = num(t0.def_penetrate);
|
||||
if (!(per > 0)) return;
|
||||
const max = count(t0.max_stack_cnt, 5), dur = num(t0.duration, 8);
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !up(unit)) return;
|
||||
battle.addBuff(unit, { key: FANG_KEY, duration: dur, refresh: 'stack', maxStacks: max, mods: { defIgnorePct: per }, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 重如沼泥: during a skill, the first max_stack_cnt damages on each enemy ×atk_scale + 停顿
|
||||
const scale = num(t1.atk_scale, 1), slug = num(t1.sluggish), max = count(t1.max_stack_cnt, 1);
|
||||
const marked = new WeakSet();
|
||||
battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.typhonMud = new Map(); }, { owner: unit });
|
||||
battle.on('skillEnd', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
const marks = unit.mem.typhonMud;
|
||||
if (!marks || !skillBusy(unit)) return;
|
||||
const n = marks.get(target.id) ?? 0;
|
||||
if (n >= max) return;
|
||||
marks.set(target.id, n + 1);
|
||||
dmg.amount *= scale;
|
||||
marked.add(dmg);
|
||||
});
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !marked.has(c.dmg) || !c.target.alive || !(slug > 0)) return;
|
||||
battle.applyStatus(c.target, 'sluggish', { duration: slug, source: unit });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
|
||||
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
|
||||
install(battle, unit) {
|
||||
// S2: two arrows per attack — both at the one enemy when it is the only target
|
||||
if (unit.skill?.id === S2) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.skill.active || ctx.targets.length !== 1) return;
|
||||
ctx.targets = [ctx.targets[0], ctx.targets[0]];
|
||||
}, { owner: unit });
|
||||
}
|
||||
// S3: the round is aimed at the marked enemy (else another one of the area): its 'atk' line points there
|
||||
if (unit.skill?.id === S3) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
const st = unit.mem.typhonMark;
|
||||
if (ctx.attacker !== unit || !unit.skill.active || !st) return;
|
||||
const pool = areaFoes(unit, st);
|
||||
const t = pool.includes(st.target) ? st.target : pool[0];
|
||||
if (t) ctx.targets = [t];
|
||||
}, { owner: unit });
|
||||
}
|
||||
// S2 counts its casts per deployment
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonS2Uses = 0; }, { owner: unit });
|
||||
// SIE-Y: the farther the target, the more damage (attacks only)
|
||||
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
|
||||
if (ds > 0) {
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user