194 lines
13 KiB
JavaScript
194 lines
13 KiB
JavaScript
// server/sim/content/kits/ops/op-amgoat.js — 艾雅法拉 (char_180_amgoat) 自选 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_180_amgoat, 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; every module part is
|
||
// valid outside 集成战略 — no `validInGameTag`) and PRTS 艾雅法拉 (S1 备注 "每次部署艾雅法拉时,重新计算该技能的使用次数";
|
||
// S2 备注 "爆炸范围为半径1.5", "技能实际效果为先削减主攻击目标的法术抗性,再对爆炸范围内所有敌人造成½倍率的伤害(包括主攻击目标),
|
||
// 最后对主攻击目标额外造成一次½倍率的伤害", 法术抗性 "{{**|80%|-20%}}" = a multiplier; S3 "攻击间隔大幅度缩短(-1.1)";
|
||
// 乱火's hidden 备注 "在最小值与最大值之间随机生成一个浮点数后向下取整").
|
||
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “错过的声音”
|
||
// adds "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “宠物大赛第一名”
|
||
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader — the S2
|
||
// explosion's splash and its second ½ are not further 命中 [ASSUMED]; a running timed skill takes no SP, as always).
|
||
// - T1 炎息 "在场时,所有友方【术师】职业干员的攻击力+14%" (full potential: +16%): an aura on every 【术师】 operator of the
|
||
// field (her included) while she is deployed (installAura, the kits' convention for field-wide talents; several sources
|
||
// keep the strongest). CCR-X stage 2+ turns it into "携带时…+22%" (full potential: +24%; the module talent's bb): the
|
||
// same aura whether she is on the field or not — she is carried while she belongs to the battle, knocked out or waiting
|
||
// to redeploy included.
|
||
// - T2 乱火 "部署后立即随机获得7~15点技力" (full potential: 10~19; bb sp_min 10, sp_max 20): floor(uniform [sp_min, sp_max))
|
||
// SP at every deployment (PRTS hidden 备注). CCR-Y stage 3 adds "并随机提升6~15的攻击速度" (attack_speed_min / _max, the
|
||
// same draw) for that deployment [ASSUMED: no duration given — until she leaves the field], and with `factor`
|
||
// "部署后范围内存在精英或领袖敌人时,获得的技力和提升的攻击速度取最大值": an elite or leader enemy in her attack range at
|
||
// that deployment gives the text's maxima, 19 SP / 15 ASPD — the blackboard maxima are the exclusive bounds of the
|
||
// draw (stage 2's attack_speed_max 1 with no ASPD in its text says so) [ASSUMED: 取最大值 = the largest value the draw
|
||
// yields; checked at the deployment instant — at the battle start no enemy is on the field yet].
|
||
// - S1 二重咏唱 (MANUAL, 25 s, data DEFAULT): ASPD +[a].attack_speed; from the second cast of a deployment on, ASPD
|
||
// +[b].attack_speed and ATK +[b].atk (PRTS 备注: the count restarts at every deployment).
|
||
// - S2 点燃 (AUTO, 可充能 2, data DEFAULT): the next attack, on impact — the main target's RES ×(1 + magic_resistance) for
|
||
// `duration` s first, then an explosion of radius 1.5 around it (a 中点判定 splash) dealing atk_scale × ATK arts to
|
||
// every enemy in it, the main target included, each of the others then getting the same RES cut, and last
|
||
// atk_scale_2 × ATK arts to the main target again (the PRTS 备注's order; it names the main target's cut only as
|
||
// coming first, so the others' cut follows their hit [ASSUMED]; the cut is "同名效果取最高" through applyStrongest).
|
||
// The explosion reaches air units and skips enemies an area effect cannot select (foesInRadius); like the engine's
|
||
// profession splash it can be dodged.
|
||
// - S3 火山 (MANUAL, 15 s): ATK +atk, attack interval base_attack_time (−1.1 s flat on her 1.6 s), the attack range x-3
|
||
// while it runs, each attack throws lava at attack@max_target enemies of that range drawn at random (all of them when
|
||
// fewer) — one projectile each, no splash [ASSUMED: the text names none]. Trigger: the data's SKILL_RANGE on
|
||
// x-3 — its text says "攻击范围增大", which tools/build-data.mjs ATTACK_RANGE_CHANGE does not list, so the owner's
|
||
// ACTIVE_RANGE rule did not reach it (reported; the kit keeps the data rule).
|
||
// - No summon (the forms list no token).
|
||
|
||
import { num, talentBb, traitBb, skillRec, statBuff, installAura, batMod, giveSp, onHitBy } from '../shared/tier1.js';
|
||
import { hasHp } from '../../../damage.js';
|
||
import { canTargetEnemy } from '../../../targeting.js';
|
||
import { bodyInKeys } from '../../../body.js';
|
||
|
||
const S1 = 'skchr_amgoat_1';
|
||
const S2 = 'skchr_amgoat_2';
|
||
const S3 = 'skchr_amgoat_3';
|
||
/** S2 点燃: the explosion radius (PRTS 备注 "爆炸范围为半径1.5"; no blackboard key). */
|
||
const IGNITE_RADIUS = 1.5;
|
||
/** S3 火山's 技能范围 x-3 when the data carries no grid (range_table x-3: the tiles within 3 steps). */
|
||
const X3 = Object.freeze([[3, 0], [2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -3], [0, -2], [0, -1],
|
||
[0, 0], [0, 1], [0, 2], [0, 3], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1], [-3, 0]]);
|
||
/** Damage of the S2 explosion and its second ½ (not a further 命中 of the attack: CCR-Y counts the first). */
|
||
const IGNITE_TAG = 'amgoat:ignite';
|
||
const AURA_KEY = 'talent:amgoat:casters';
|
||
const AURA_EVERY = 0.5;
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
/** A 【术师】 operator (summons are never operators). */
|
||
const isCaster = (a) => !!a && a.kind === 'op' && a.def?.profession === 'CASTER';
|
||
/** An elite or leader enemy (精英或领袖敌人). */
|
||
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
|
||
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
|
||
/** A module trait that works 在集成战略中 only (ISW modules): nothing of it applies in this mode. */
|
||
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
|
||
/** Named talent `i` of the record (its desc tells the module stage's wording). */
|
||
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
|
||
|
||
/** "携带时": the aura of installAura, held while she belongs to the battle — on the field or not. */
|
||
function carriedAura(battle, unit, { key, select, mods, value }) {
|
||
battle.every(AURA_EVERY, () => {
|
||
for (const a of battle.alliesFor(unit)) {
|
||
if (!select(a)) continue;
|
||
const cur = a.findBuff(key);
|
||
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
|
||
battle.addBuff(a, { key, duration: AURA_EVERY + 0.1, mods, source: unit, data: { v: value }, tags: ['aura'] });
|
||
}
|
||
}, { owner: unit, immediate: true });
|
||
}
|
||
|
||
export default {
|
||
char_180_amgoat: (bb, chess) => {
|
||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||
const carried = /携带时/.test(String(talentRec(chess, 0)?.desc ?? ''));
|
||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||
const mr = num(b2.magic_resistance), resDur = num(b2.duration);
|
||
const resCut = (battle, unit, e) => {
|
||
if (e && e.alive && mr) battle.applyStrongest(e, 'amgoat:ignite', { duration: resDur, value: mr, mods: (v) => ({ resMul: Math.max(0, 1 + v) }), source: unit });
|
||
};
|
||
const maxLava = Math.max(1, Math.floor(num(b3['attack@max_target'], 1)));
|
||
return {
|
||
skills: {
|
||
[S1]: {
|
||
kind: 'duration',
|
||
onStart({ battle, unit }) {
|
||
// the casts of this deployment (PRTS 备注: "每次部署…重新计算该技能的使用次数")
|
||
const n = unit.mem.amgoatDuetSeq === unit.deploySeq ? num(unit.mem.amgoatDuetN) + 1 : 1;
|
||
unit.mem.amgoatDuetSeq = unit.deploySeq;
|
||
unit.mem.amgoatDuetN = n;
|
||
const mods = n >= 2
|
||
? { aspd: num(b1['amgoat_s_1[b].attack_speed']), atkPct: num(b1['amgoat_s_1[b].atk']) }
|
||
: { aspd: num(b1['amgoat_s_1[a].attack_speed']) };
|
||
battle.addBuff(unit, { key: 'skill:amgoat:duet', mods, tags: ['skill'] });
|
||
},
|
||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:amgoat:duet'); },
|
||
},
|
||
[S2]: {
|
||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||
attack: {
|
||
// the engine's hit on the main target is its share of the explosion (½); the RES cut came first (install)
|
||
atkScale: num(b2.atk_scale, 1),
|
||
onEachHit({ battle, unit, target, kind }) {
|
||
if (kind !== 'main' || !target) return;
|
||
const atk = unit.s.atk, x = target.x, y = target.y;
|
||
for (const e of battle.foesInRadius(x, y, IGNITE_RADIUS, true)) {
|
||
if (e === target) continue;
|
||
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'arts', isAttack: true, isSplash: true, isSkill: true, tags: [IGNITE_TAG] });
|
||
resCut(battle, unit, e);
|
||
}
|
||
if (hasHp(target)) battle.dealDamage(unit, target, { amount: atk * num(b2.atk_scale_2, num(b2.atk_scale, 1)), type: 'arts', isAttack: true, isSkill: true, tags: [IGNITE_TAG] });
|
||
battle.fx('explosion', { x, y, id: unit.id, r: IGNITE_RADIUS, dmgType: 'arts' });
|
||
},
|
||
},
|
||
},
|
||
[S3]: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||
// every enemy of x-3 is a candidate; install draws the lava targets among them
|
||
targeting: { rangeGrid: s3?.rangeGrid ?? X3, allInRange: true },
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 炎息: the field's 【术师】 ATK +atk — while she is deployed, or (CCR-X stage 2+) carried
|
||
const a = num(t0.atk);
|
||
if (!(a > 0)) return;
|
||
const opts = { key: AURA_KEY, value: a, select: isCaster, mods: { atkPct: a } };
|
||
if (carried) carriedAura(battle, unit, opts);
|
||
else installAura(battle, unit, { ...opts, interval: AURA_EVERY });
|
||
} },
|
||
{ install(battle, unit) { // 乱火: SP (and with CCR-Y stage 3 ASPD) at every deployment; the maxima with an elite in range
|
||
const spLo = num(t1.sp_min), spHi = num(t1.sp_max), asLo = num(t1.attack_speed_min), asHi = num(t1.attack_speed_max);
|
||
const maxRule = num(t1.factor) > 0;
|
||
battle.on('deploy', (ctx) => {
|
||
if (ctx.unit !== unit || !unit.alive) return;
|
||
// (a scan of the enemies: the tile index of enemiesInKeys is the last tick's)
|
||
const top = maxRule && battle.enemies.some((e) => isEliteEnemy(e) && canTargetEnemy(unit, e, unit.profile) && bodyInKeys(e, unit.rangeKeySet));
|
||
// floor of a uniform draw in [lo, hi); "取最大值" = the largest value the draw yields (hi − 1 for integer bounds)
|
||
const draw = (lo, hi) => (hi > lo ? (top ? Math.ceil(hi) - 1 : Math.floor(battle.rng.range(lo, hi))) : 0);
|
||
const sp = draw(spLo, spHi);
|
||
if (sp > 0) giveSp(unit, sp);
|
||
const as = draw(asLo, asHi);
|
||
if (as > 0) battle.addBuff(unit, { key: 'talent:amgoat:wildfire', mods: { aspd: as }, tags: ['talent'] });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
// module traits (none on the normal form): CCR-X 无视目标10点法术抗性, CCR-Y SP per hit on an elite / leader
|
||
if (!roguelikeOnly(chess)) {
|
||
const tb = traitBb(chess);
|
||
const pen = num(tb.magic_resist_penetrate_fixed);
|
||
if (pen > 0) statBuff(battle, unit, 'trait:amgoat:resIgnore', { resIgnoreFlat: pen });
|
||
const sp = num(tb.sp);
|
||
if (sp > 0) {
|
||
battle.on('damaged', (ctx) => {
|
||
const d = ctx.dmg;
|
||
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, IGNITE_TAG) || !isEliteEnemy(ctx.target)) return;
|
||
giveSp(unit, sp, 'trait');
|
||
}, { owner: unit });
|
||
}
|
||
}
|
||
const sid = unit.skill?.id;
|
||
if (sid === S2) {
|
||
// 点燃: "先削减主攻击目标的法术抗性" — the main target's RES cut lands before the first damage of the attack
|
||
onHitBy(battle, unit, (ctx) => {
|
||
const d = ctx.dmg;
|
||
if (d.isAttack && d.isSkill && !d.isSplash && !tagged(d, IGNITE_TAG)) resCut(battle, unit, ctx.target);
|
||
}, 50);
|
||
} else if (sid === S3) {
|
||
// 火山: "随机对攻击范围内至多N个敌人发射熔岩" — a random draw among every enemy of the range
|
||
battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.skill?.active || ctx.targets.length <= maxLava) return;
|
||
ctx.targets = battle.rng.shuffle(ctx.targets.slice()).slice(0, maxLava);
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
};
|
||
},
|
||
};
|