- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double, one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them. - sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's. Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). - Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency). - Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors, typecheck, check:imports.
216 lines
13 KiB
JavaScript
216 lines
13 KiB
JavaScript
// 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). Full potential (the owner's decision of 2026-10-07).
|
||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); 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%" (full potential: 230%;
|
||
// 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秒" (full potential: 170%): 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';
|
||
import { hypot } from '../../../detmath.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 = 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)));
|
||
});
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|