feedback5: merge O14 — 自选 kits for 年, 斩业星熊 and 珊比; golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 01:26:00 +08:00
commit 6a8284837d
8 files changed
+2110 -104

No files matched your search

+3
View File
@@ -103,6 +103,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-ifrit.js',
'op-aphris.js',
'op-lin.js',
'op-nian.js',
'op-hsgma2.js',
'op-thumpy.js',
]);
/**
+291
View File
@@ -0,0 +1,291 @@
// server/sim/content/kits/ops/op-hsgma2.js — 斩业星熊 (char_1044_hsgma2) 自选 operator kit: 6★ 驭法铁卫 (重装), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_1044_hsgma2, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, AST-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), gamedata_const
// ba.berserk 坚忍 ("根据已损失的生命值获得相应比例的属性加成,损失一定比例时达最大加成") and ba.sluggish 停顿, PRTS 斩业星熊 (业火 备注:
// 负生命值 — "期间持有禁疗,一切生命值变动将先计入负生命值来抵消/增加负生命值…只有负生命值达到上限时才会被正常击倒…负生命值与真实生命值
// 无关", "生命恢复的提供方式为增加自身的“生命回复速度(百分比)”属性,不受治疗加成和禁疗影响", "需成功受到伤害,生命恢复效果才会失效并
// 重新计时"; S1 备注 "反伤效果会直接选中伤害来源,但仅对阵营为敌方的来源造成伤害"; S2 备注 "治疗效果无视自身的禁疗,计算治疗量时以目标
// 受伤前后的生命值差的绝对值为准", "投出的盾牌以自身前方1.2格为起点,逆时针旋转365°,角速度90°/s,碰撞半径1.0,碰撞范围内存在可选目标时
// 速度-80%", "技能持续至盾牌旋转365°并收回时结束"; S3 备注 "技能期间攻击可对空", 临死模式 — 阻回 / 沉默 / 不死, allies' 不死 and the
// lethal excess as her 流失, the forced exit, "若携带X模组,则X模组的“造成额外伤害”效果依然生效"; AST-X 备注 — the extra damage's
// conditions "①此为普通攻击或普通攻击附带的伤害;②伤害来源于斩业星熊自身;③不为本模组特性的伤害…伤害为法术附加伤害", "仅对敌方
// 单位反弹伤害,伤害为法术普通伤害"), PRTS 卫戍协议/帮助 技能策略 (her S3 row "于技能持续时间过半后…关闭技能(进入其技能效果的
// 临死模式)" added in 上半, REMOVED with 下半; "通常不会自动关闭技能").
// - Trait (驭法铁卫) "技能开启时普通攻击会造成法术伤害": physical attacks while no skill runs (the kit's trait dmgType — the
// data's 'arts' is the skill-on type) and arts while one runs (every skill's attack override); block 3, ground-only
// melee on her 1-1. Module AST-X 无迹 adds "且攻击和受到攻击时对目标额外造成10%攻击力的法术伤害" (trait bb atk_scale): every
// damage instance of her normal attacks (skill attacks included; not the shield, a counter or the module's own damage)
// is followed by atk_scale × ATK arts on that enemy (附加伤害: no dodge), and every enemy attack she takes returns
// atk_scale × ATK arts (普通伤害) to the attacker — whether a skill runs or not [ASSUMED: PRTS's list of the checked
// conditions names no skill period].
// - T1 业火 "生命降至0时进入“我执”…": a lethal damage instance leaves her on the field in 我执 instead (the damage past her
// HP opens the negative pool — the engine keeps her at its 1-HP floor for the official 0); in 我执 every later damage
// instance goes to the pool (no HP loss; it still counts as damage taken: 受击回复 SP, counters) until the pool would
// reach max_minus_hp_ratio × max HP — that blow knocks her out as usual (unless a 不死 holds her); she holds 禁疗 (flags
// noHeal + healFree, the status 'healFree'); after [heal].interval s without a damage instance landing she gets the
// 生命回复速度(百分比) attribute [heal].hp_recovery_per_sec_by_max_hp_ratio (an hpRegenRatio buff — not 治疗: 禁疗 does
// not stop it, no 治疗加成 scales it); whatever raises her HP above the floor (that regeneration, S2's heal that ignores
// 禁疗) clears the pool first, and she leaves 我执 once it is empty. A 流失 that reaches 0 counts whole into the pool
// [ASSUMED: the engine reports no HP before a loss].
// - T2 鬼之架势 "在场时,自身获得最高+50法术抗性和35%攻击力的坚忍(损失70%生命值时达到最大加成)": RES +min_magic_resistance and ATK
// +min_atk scaled linearly by the HP lost from max_hp_ratio down to min_hp_ratio (坚忍), re-read every tick; in 我执 at
// its maximum. AST-X stage 3: the maximum at 50 % lost (min_hp_ratio 0.5) and "达到最大加成时,攻击力额外+15%" (atk).
// - S1 恶业苦果 (AUTO, 受击回复 SP, 持续时间无限 — a toggle): ATK +atk, DEF +def, arts attacks; every enemy attack she takes
// deals atk_scale × ATK arts to the attacker (a direct pick of the damage source, enemies only). A self buff: the owner's
// AUTO rule fires it as soon as its SP is full (`trigger: 'SP_FULL'`, as 引星棘刺 S1 — kits/README.md checklist 5).
// - S2 无始无明 (AUTO, attack SP; data DEFAULT): the cast replaces the attack about to be made — the shield throw, three hits
// of attack@atk_scale × ATK arts on every enemy she blocks (none blocked: her target [ASSUMED]); then the shield circles
// her: from 1.2 tiles ahead, counter-clockwise 365° at 90°/s, ×0.2 while a targetable ground enemy [ASSUMED: no 对空
// note, unlike S3] is within 1.0 of it; every `interval` s from the throw [ASSUMED] each such enemy takes 停顿 `sluggish`
// s and shield_atk_scale × ATK arts, and she heals heal_ratio × the HP it lost (ignoring her 禁疗). The skill runs until
// the turn is done (its return taken as instant [ASSUMED]): no SP meanwhile, and her normal attacks of the flight are arts.
// - S3 地狱变相 (MANUAL; data ACTIVE_RANGE on the running x-2 — the owner's rule over the 重装 exception, rawRule
// TAKE_DAMAGE): `duration` s — range x-2, max HP +max_hp, ATK +atk, two hits on up to attack@max_target enemies, air units
// too (PRTS 备注), arts. 主动关闭 (a close: `skill.end('manual')`) starts the 临死模式 for before_dead_duration s: the
// skill's bonuses and range stay, four hits, 阻回 + 沉默, 不死 (her negative pool never knocks her out); every other allied
// operator on her range holds 不死 and the part of a damage instance past its HP becomes her 流失 (a 流失 it takes is not
// passed on); then she leaves the field (a retreat with the death animation, as 史尔特尔's 余烬; the official partial DP
// refund of a withdrawal is not modelled — no retreat in the sim refunds DP). No strategy of this mode closes it: PRTS
// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`.
import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_hsgma2_1';
const S2 = 'skchr_hsgma2_2';
const S3 = 'skchr_hsgma2_3';
/** S2's shield (PRTS 备注): start 1.2 tiles ahead, 365° counter-clockwise at 90°/s, collision radius 1.0, −80 % near a target. */
const SHIELD_START = 1.2;
const SHIELD_TURN = 365;
const SHIELD_SPEED = 90;
const SHIELD_RADIUS = 1.0;
const SHIELD_SLOW = 0.8;
/** An engine cap on S2's run (the shield's turn ends it long before: 365° at 18°/s takes 20.3 s). */
const S2_CAP = 60;
/** S3's x-2 when the data carries no grid. */
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1],
[0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
const GROUND = Object.freeze({ canHitFly: false });
const EGO = 'talent:hsgma2:ego';
const EGO_REGEN = 'talent:hsgma2:egoRegen';
const BERSERK = 'talent:hsgma2:berserk';
const DYING = 'skill:hsgma2:dying';
const ASTX = 'hsgma2:astx';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
export default {
char_1044_hsgma2: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const throwParts = { hits: 3, atkScale: num(b2['attack@atk_scale'], 1), hitAllBlocked: true };
// this unit's attack overrides (the kit is built per unit): S2's throw turns into plain arts attacks after it lands,
// S3's two hits become four in the 临死模式
const s2Attack = { dmgType: 'arts' };
const s3Attack = { dmgType: 'arts', hits: 2 };
const plainS2 = () => { delete s2Attack.hits; delete s2Attack.atkScale; delete s2Attack.hitAllBlocked; };
/** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */
const shieldAt = (unit, sh) => {
const a = sh.a0 + (sh.turned * Math.PI) / 180;
return { x: unit.x + SHIELD_START * Math.cos(a), y: unit.y + SHIELD_START * Math.sin(a) };
};
const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS));
/** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */
const shieldPulse = (battle, unit, p) => {
for (const e of touching(battle, unit, p)) {
battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish, 1), source: unit });
if (!e.alive) continue;
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.shield_atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'hsgma2:shield'] });
if (dealt > 0 && unit.alive) battle.heal(unit, unit, dealt * num(b2.heal_ratio), { self: true, ignoreHealFree: true });
battle.fx('strike', { x: p.x, y: p.y, id: unit.id, skill: 'hsgma2:shield' });
}
};
return {
trait: { dmgType: 'phys' },
skills: {
[S1]: {
kind: 'toggle', trigger: { rule: 'SP_FULL' },
mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }),
attack: { dmgType: 'arts' },
},
[S2]: {
kind: 'duration', duration: S2_CAP,
attack: s2Attack,
onStart({ unit }) { Object.assign(s2Attack, throwParts); unit.mem.hsShield = null; },
onAttack({ battle, unit }) {
if (!('hits' in s2Attack)) return;
plainS2(); // the throw was this attack: the shield flies from now on
const [fr, fc] = unit.fwd;
unit.mem.hsShield = { a0: Math.atan2(fr, fc), turned: 0, acc: 0 };
battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' });
shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield));
},
onTick({ battle, unit, skill, dt }) {
const sh = unit.mem.hsShield;
if (!sh) return;
const iv = Math.max(0.05, num(b2.interval, 0.5));
sh.acc += dt;
while (sh.acc + 1e-9 >= iv && unit.mem.hsShield === sh) { sh.acc -= iv; shieldPulse(battle, unit, shieldAt(unit, sh)); }
if (unit.mem.hsShield !== sh) return;
const slowed = touching(battle, unit, shieldAt(unit, sh)).length > 0;
sh.turned += SHIELD_SPEED * (slowed ? 1 - SHIELD_SLOW : 1) * dt;
if (sh.turned >= SHIELD_TURN - 1e-9) skill.end('shield');
},
onEnd({ unit }) { unit.mem.hsShield = null; plainS2(); },
},
[S3]: {
kind: 'duration',
mods: nz({ hpPct: num(b3.max_hp), atkPct: num(b3.atk) }),
targeting: { rangeGrid: skillRec(chess, S3)?.rangeGrid ?? X2, maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 1))), canHitFly: true },
attack: s3Attack,
onStart({ unit }) { s3Attack.hits = 2; unit.mem.hsDying = null; },
onEnd({ battle, unit, skill, reason }) {
const dying = unit.mem.hsDying;
if (!dying && reason === 'manual' && unit.alive && unit.deployed) {
// 主动关闭 ⇒ 临死模式: the skill keeps running (its bonuses, range and override stay; no skillStart — it is
// no new cast) for before_dead_duration s with four hits, 阻回 + 沉默, and 不死 (the fatal hooks below)
const dur = num(b3.before_dead_duration, 11);
unit.mem.hsDying = { seq: unit.deploySeq };
s3Attack.hits = 4;
skill.active = true;
skill.timeLeft = dur;
battle.addBuff(unit, { key: DYING, duration: dur, flags: { noSp: true, silence: true }, visible: true, tags: ['skill'] });
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id });
return;
}
if (!dying) return;
unit.mem.hsDying = null;
s3Attack.hits = 2;
battle.removeBuff(unit, DYING);
// "11秒后强制退出战场" (视为撤回干员)
if (reason === 'duration' && unit.alive && unit.deployed) battle.retreat(unit, { reason: 'retreat', dying: true });
},
},
},
talents: [
{ install(battle, unit) { // 业火: 我执 — the negative pool, 禁疗, the quiet-time regeneration
const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp;
const quiet = num(t0['hsgma2_t_1[heal].interval'], 5);
const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']);
const enter = (pool) => {
unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time };
battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] });
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id, talent: 'hsgma2:ego' });
};
const leave = () => {
unit.mem.hsEgo = null;
battle.removeBuff(unit, EGO);
battle.removeBuff(unit, EGO_REGEN);
};
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.hsOverflow = false; leave(); } }, { owner: unit });
// a damage instance (after the shields): into the pool while in 我执, or the lethal one opens it
battle.on('hpDamage', (c) => {
if (c.target !== unit || !unit.alive) return;
const ego = unit.mem.hsEgo;
if (ego) {
ego.lastHurt = battle.time;
if (ego.pool + c.amount < capOf()) { ego.pool += c.amount; c.amount = 0; } else unit.mem.hsOverflow = true;
return;
}
if (c.amount < unit.hp) return;
const over = c.amount - unit.hp;
if (over < capOf()) { c.amount = Math.max(0, unit.hp - 1); enter(over); } else unit.mem.hsOverflow = true;
}, { owner: unit, priority: -50 });
// HP reaching 0 anyway: the 临死模式's 不死 holds her; a full pool knocks her out; a 流失 goes to the pool
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented) return;
const overflow = !!unit.mem.hsOverflow;
unit.mem.hsOverflow = false;
const ego = unit.mem.hsEgo;
if (unit.mem.hsDying) {
c.prevented = true;
if (ego) ego.pool = Math.min(capOf(), ego.pool + c.amount); else enter(Math.min(capOf(), c.amount));
return;
}
if (overflow) return;
const pool = (ego ? ego.pool : 0) + c.amount;
if (pool >= capOf()) return;
c.prevented = true;
if (ego) ego.pool = pool; else enter(pool);
}, { owner: unit, priority: -40 });
battle.on('tick', () => {
unit.mem.hsOverflow = false;
const ego = unit.mem.hsEgo;
if (!ego) return;
if (!up(unit)) { leave(); return; }
// HP above the 1-HP floor (her regeneration, a heal that ignores 禁疗) clears the pool first
const surplus = unit.hp - 1;
if (surplus > 0 && ego.pool > 0) { const take = Math.min(surplus, ego.pool); ego.pool -= take; unit.hp -= take; }
if (!(ego.pool > 1e-9)) { leave(); return; }
const want = regen > 0 && battle.time - ego.lastHurt >= quiet - 1e-9;
const has = !!unit.findBuff(EGO_REGEN);
if (want && !has) battle.addBuff(unit, { key: EGO_REGEN, mods: { hpRegenRatio: regen }, tags: ['talent'] });
else if (!want && has) battle.removeBuff(unit, EGO_REGEN);
}, { owner: unit });
} },
{ install(battle, unit) { // 鬼之架势: 坚忍 RES / ATK by the HP lost; AST-X stage 3: ATK +atk more at the maximum
const hi = num(t1.max_hp_ratio, 1), lo = num(t1.min_hp_ratio, 0.3);
const res0 = num(t1.max_magic_resistance), res1 = num(t1.min_magic_resistance);
const atk0 = num(t1.max_atk), atk1 = num(t1.min_atk), extra = num(t1.atk);
if (!(hi > lo) || !(res0 || res1 || atk0 || atk1)) return;
battle.on('tick', () => {
const cur = unit.findBuff(BERSERK);
if (!up(unit)) { if (cur) battle.removeBuff(unit, BERSERK); return; }
const f = clamp01((hi - unit.hpRatio) / (hi - lo));
if (cur && Math.abs((cur.data?.f ?? -1) - f) < 1e-4) return;
const mods = nz({ resFlat: res0 + (res1 - res0) * f, atkPct: atk0 + (atk1 - atk0) * f + (f >= 1 - 1e-9 ? extra : 0) });
if (!Object.keys(mods).length) { if (cur) battle.removeBuff(unit, BERSERK); return; }
battle.addBuff(unit, { key: BERSERK, mods, data: { f }, tags: ['talent'] });
}, { owner: unit });
} },
],
install(battle, unit) {
// S1 恶业苦果: every enemy attack she takes ⇒ atk_scale × ATK arts back to the attacker
battle.on('damaged', (c) => {
const s = c.source;
if (c.target !== unit || !unit.alive || unit.skill?.id !== S1 || !unit.skill.active) return;
if (!s || s.side !== 'enemy' || !s.alive || !c.dmg?.isAttack) return;
battle.dealDamage(unit, s, { amount: unit.s.atk * num(b1.atk_scale), type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] });
battle.fx('counter', { x: s.x, y: s.y, id: unit.id });
}, { owner: unit });
// AST-X 无迹: her attack damage ⇒ +atk_scale × ATK arts (附加伤害); an enemy attack she takes ⇒ atk_scale × ATK arts back
const astx = num(tb.atk_scale);
if (astx > 0) {
battle.on('damaged', (c) => {
const t = c.target;
if (c.source !== unit || !c.dmg?.isAttack || c.type === 'element' || !t || t.side !== 'enemy' || !hasHp(t)) return;
battle.dealDamage(unit, t, { amount: unit.s.atk * astx, type: 'arts', canDodge: false, tags: ['module', ASTX] });
}, { owner: unit, priority: -20 });
battle.on('damaged', (c) => {
const s = c.source;
if (c.target !== unit || !unit.alive || !s || s.side !== 'enemy' || !s.alive || !c.dmg?.isAttack) return;
battle.dealDamage(unit, s, { amount: unit.s.atk * astx, type: 'arts', ignoreSelect: true, tags: ['module', ASTX, 'counter'] });
}, { owner: unit });
}
// S3 临死模式: every other allied operator on her range holds 不死; the part of a damage instance past its HP is her 流失
const shelters = (t) => !!unit.mem.hsDying && up(unit) && !!t && t !== unit && t.side === 'ally' && t.kind === 'op' && t.alive
&& !!unit.rangeKeySet && unit.rangeKeySet.has(t.tileR * COLS + t.tileC);
battle.on('hpDamage', (c) => {
const t = c.target;
if (!shelters(t) || c.amount < t.hp) return;
const over = c.amount - t.hp;
c.amount = Math.max(0, t.hp - 1);
if (over > 0) battle.loseHp(unit, over, { source: c.credit ?? null, tags: ['skill', 'hsgma2:shoulder'] });
}, { owner: unit, priority: -60 });
battle.on('fatal', (c) => { if (!c.prevented && shelters(c.unit)) c.prevented = true; }, { owner: unit, priority: -40 });
},
};
},
};
+170
View File
@@ -0,0 +1,170 @@
// server/sim/content/kits/ops/op-nian.js — 年 (char_2014_nian) 自选 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_2014_nian, 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), gamedata_const
// ba.shield 护盾 ("每层护盾可以抵挡一次伤害") and ba.buffres 抵抗, PRTS 年 (积甲成山 备注 "根据重装干员数量获得的治疗效果加成与
// 生命上限加成叠加时以加法叠加"; 干明可鉴 备注 "攻击力与防御力增益持续时间无限"; 铜印 备注 "沉默效果与反伤效果会直接选中伤害来源,
// 但仅对阵营为敌方的来源产生效果"; 铁御 技能范围 x-2).
// - Trait (铁卫) "能够阻挡三个敌人": the profession default (block 3 from the data stats), ground-only physical melee on her
// 1-1. Module PRO-X 混沌阵列 adds "阻挡敌人时防御力+20%" (trait bb def): DEF + while she blocks at least one enemy.
// Module PRO-Y “请勿叩门” "能够阻挡四个敌人" (attr block_cnt +1, already in the stats). From stage 2 its trait part carries
// the blackboard of 积甲成山's module text (battle_equip_table: TRAIT part max_hp / heal_scale / heal_scale_addition /
// heal_scale_max_value / max_stack_cnt, the talent part only the team bonus) "场上每有一名重装干员,自身受到治疗效果和生命上限
// 额外提升4%,最多叠加3层": n = the 重装 operators on the field, her included [ASSUMED: "每有一名重装干员" counts every 重装 on
// the field] and a teammate's in a shared field (alliesFor), at most max_stack_cnt — max HP +max_hp × n (直接乘算 with her
// other "+%" bonuses) and healing received ×(heal_scale + heal_scale_addition × n), at most heal_scale_max_value (the
// PRTS note's 加法叠加: the stacks add up, 104 / 108 / 112 %). Re-counted every tick.
// - T1 积甲成山 "编入队伍时,所有【重装】职业干员的生命上限+16%": every 重装 operator of her player's team (her included) max HP
// +max_hp for the whole battle, deployed or not (the kits' 编入队伍时 convention: 斯卡蒂 深海掠食者, 早露 学生楷模). PRO-Y
// stage 3: +21 % (the module talent change).
// - T2 干明可鉴 "部署后立即获得3层护盾": `times` 护盾 layers at every deployment (a hit-negating barrier: buff shieldHits — each
// layer negates one damage instance). PRO-X stage 2+ (talent change atk / def / sp / max_stack_cnt) "每层护盾破裂时,攻击力
// +7%,防御力+7%且获得3点技力": each broken layer one more stack of ATK / DEF + (at most max_stack_cnt; "持续时间无限": until she
// leaves the field [ASSUMED: a deployment's layers give a deployment's stacks]) and +sp SP (none while a skill runs: AK's
// no-SP-during-a-skill rule, tier1 giveSp).
// - S1 锡灼 (MANUAL, time SP; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of
// DEF +def and ATK +atk; normal attacks deal arts damage.
// - S2 铜印 (MANUAL; data DEFAULT): `duration` s — stops attacking; DEF +def, block +block_cnt; every enemy attack she takes
// (each damage instance of one, as 星熊 荆棘 / 泡泡) deals atk_scale × ATK arts to the attacker and silences it `silence`
// s ("失去特殊能力"): a direct pick of the damage source (ignoreSelect), enemies only (PRTS 备注), even when a 护盾 layer
// negated the hit.
// - S3 铁御 (MANUAL; data DEFAULT — its x-2 is a 技能范围 for the allies, no attack-range change): `duration` s of ATK
// +[self].atk (block +[self].block_cnt = 0); every other allied operator on her x-2 ("周围其他友方干员": summons are no 干员
// [ASSUMED]) gets DEF +[ally].def, block +[ally].block_cnt and 抵抗 (the engine `resist` status, value
// −one_minus_status_resistance), refreshed every AURA s while the skill runs; two 年 of a shared field keep the stronger
// (installAura's rule). The DEF / block buff ends with the skill, the 抵抗 within AURA_DUR.
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_nian_1';
const S2 = 'skchr_nian_2';
const S3 = 'skchr_nian_3';
/** 铁御's aura: refresh period and buff lifetime (s) — the tier-4 kits' AURA / AURA_DUR. */
const AURA = 0.2;
const AURA_DUR = 0.25;
/** 铁御's 技能范围 x-2 when the data carries no grid. */
const X2 = Object.freeze([[2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -2], [0, -1], [0, 0], [0, 1],
[0, 2], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1]]);
const SHIELD = 'talent:nian:shield';
const BREAK = 'talent:nian:shieldBreak';
const GUARD = 'skill:nian:ironGuard';
const FORT = 'trait:nian:fortify';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【重装】 operator (summons are never 重装 operators). */
const isTank = (a) => !!a && a.kind === 'op' && a.def?.profession === 'TANK';
/** The mods of `o` without its zero entries. */
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
export default {
char_2014_nian: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const guardGrid = skillRec(chess, S3)?.rangeGrid ?? X2;
const allyDef = num(b3['nian_s_3[ally].def']), allyBlock = num(b3['nian_s_3[ally].block_cnt']);
const resist = Math.min(0.95, Math.max(0, -num(b3.one_minus_status_resistance)));
/** 铁御: the other allied operators on her x-2 get the DEF / block buff and 抵抗 for AURA_DUR. */
const ironGuard = (battle, unit) => {
const keys = new Set(absoluteRangeKeys(guardGrid, unit.tileR, unit.tileC, unit.dir, 0));
for (const a of battle.alliesFor(unit)) {
if (a === unit || a.kind !== 'op' || a.hidden || !keys.has(a.tileR * COLS + a.tileC)) continue;
const cur = a.findBuff(GUARD);
if (!(cur && cur.source !== unit && (cur.data?.v ?? 0) > allyDef && cur.timeLeft > 0.05)) {
battle.addBuff(a, { key: GUARD, duration: AURA_DUR, mods: nz({ defPct: allyDef, blockCnt: allyBlock }), source: unit, data: { v: allyDef }, tags: ['skill', 'aura'] });
}
if (resist > 0) battle.applyStatus(a, 'resist', { duration: AURA_DUR, value: resist, source: unit });
}
};
return {
skills: {
[S1]: { kind: 'duration', mods: nz({ defPct: num(b1.def), atkPct: num(b1.atk) }), attack: { dmgType: 'arts' } },
[S2]: { kind: 'duration', mods: nz({ defPct: num(b2.def), blockCnt: num(b2.block_cnt) }), attack: { noAttack: true } },
[S3]: {
kind: 'duration',
mods: nz({ atkPct: num(b3['nian_s_3[self].atk']), blockCnt: num(b3['nian_s_3[self].block_cnt']) }),
onStart({ battle, unit }) {
unit.mem.nianGuardAcc = 0;
ironGuard(battle, unit);
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'nian:ironGuard' });
},
onTick({ battle, unit, dt }) {
unit.mem.nianGuardAcc = (unit.mem.nianGuardAcc ?? 0) + dt;
if (unit.mem.nianGuardAcc + 1e-9 < AURA) return;
unit.mem.nianGuardAcc -= AURA;
ironGuard(battle, unit);
},
onEnd({ battle, unit }) {
for (const a of battle.allyUnits) {
const b = a.findBuff(GUARD);
if (b && b.source === unit) battle.removeBuff(a, b);
}
},
},
},
talents: [
{ install(battle, unit) { // 积甲成山: every 重装 operator of her team max HP +max_hp (PRO-Y stage 3: the module's value)
const hp = num(t0.max_hp);
if (!(hp > 0)) return;
for (const a of battle.allyUnits) if (isTank(a) && a.ownerId === unit.ownerId) statBuff(battle, a, 'talent:nian:armor', { hpPct: hp });
} },
{ install(battle, unit) { // 干明可鉴: `times` 护盾 layers at every deployment; PRO-X stage 2+: ATK / DEF stack + SP per broken layer
const times = Math.max(0, Math.floor(num(t1.times)));
if (!(times > 0)) return;
const atk = num(t1.atk), def = num(t1.def), sp = num(t1.sp), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
unit.mem.nianLayers = times;
battle.addBuff(unit, { key: SHIELD, shieldHits: times, visible: true, tags: ['talent'] });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, n: times });
}, { owner: unit });
if (!(atk || def || sp)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !unit.alive) return;
const left = unit.findBuff(SHIELD)?.shieldHits ?? 0;
const broken = (unit.mem.nianLayers ?? 0) - left;
if (!(broken > 0)) return;
unit.mem.nianLayers = left;
for (let i = 0; i < broken; i++) {
if (atk || def) battle.addBuff(unit, { key: BREAK, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ atkPct: atk, defPct: def }), visible: true, tags: ['talent'] });
giveSp(unit, sp);
}
battle.fx('shieldBreak', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
} },
],
install(battle, unit) {
// PRO-X 混沌阵列: 阻挡敌人时防御力+20%
const blockDef = num(tb.def);
if (blockDef) toggleBuff(battle, unit, 'trait:nian:block', () => unit.blocking.length > 0, { defPct: blockDef });
// PRO-Y stage 2+: per 重装 operator on the field max HP +max_hp and healing received +heal_scale_addition (≤ the cap)
const perHp = num(tb.max_hp), healAdd = num(tb.heal_scale_addition), stackCap = Math.floor(num(tb.max_stack_cnt));
if ((perHp > 0 || healAdd > 0) && stackCap > 0) {
const healBase = num(tb.heal_scale, 1), healMax = num(tb.heal_scale_max_value, Infinity);
battle.on('tick', () => {
const cur = unit.findBuff(FORT);
const n = up(unit) ? Math.min(stackCap, battle.alliesFor(unit).filter(isTank).length) : 0;
if ((cur?.data?.n ?? 0) === n) return;
if (!(n > 0)) { battle.removeBuff(unit, FORT); return; }
battle.addBuff(unit, { key: FORT, mods: nz({ hpPct: perHp * n, healingTakenMul: Math.min(healMax, healBase + healAdd * n) }), data: { n }, tags: ['trait'] });
}, { owner: unit });
}
// S2 铜印: every enemy attack she takes ⇒ atk_scale × ATK arts back to the attacker + silence `silence` s
const scale = num(b2.atk_scale), sil = num(b2.silence);
battle.on('damaged', (c) => {
const src = c.source;
if (c.target !== unit || !unit.alive || unit.skill?.id !== S2 || !unit.skill.active) return;
if (!src || src.side !== 'enemy' || !src.alive || !c.dmg?.isAttack) return;
if (scale > 0) battle.dealDamage(unit, src, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, ignoreSelect: true, tags: ['skill', 'counter'] });
if (sil > 0 && src.alive) battle.applyStatus(src, 'silence', { duration: sil, source: unit });
battle.fx('counter', { x: src.x, y: src.y, id: unit.id });
}, { owner: unit });
},
};
},
};
+301
View File
@@ -0,0 +1,301 @@
// server/sim/content/kits/ops/op-thumpy.js — 珊比 (char_4235_thumpy) 自选 operator kit: 6★ 本源铁卫 (重装), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and her module at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4235_thumpy, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, PRP-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), gamedata_const
// ba.dt.erosion2 侵蚀损伤·我方 / ba.barrier 屏障 / ba.sluggish 停顿, PRTS 珊比 (talent 备注 "每次珊比成功造成伤害后,产生侵蚀
// 损伤/冷却减少效果;且实际上侵蚀损伤与冷却减少为互斥的效果…侵蚀损伤爆发期间将改为减少当前爆发持续时间", "每次珊比成功造成伤害后,
// 才会对目标施加用于检测侵蚀损伤爆发的【脚板标记】…珊比退场时清除所有已施加的标记", "若触发天赋时珊比已有来自此天赋的屏障,则改为
// 为已有屏障Buff累加伤害吸收量"; S1 备注 "推动效果为径向推力,攻击到未被阻挡的敌人时也会生效"; S2 备注 — the spraying order,
// "强力胶的效果施加无视无法选择,但其不会对隐藏、隐匿、无敌的单位产生停顿与伤害效果", "停顿效果先于此技能的物理伤害生效", "追加侵蚀
// 损伤的“其他干员”判断以伤害的来源为准,无来源伤害始终无法触发此效果"; S3 备注 — the conveyor "影响自身前方4格可通行地块(不包括
// 自身所在格,中心判定)内的所有未被阻挡的地面敌方敌人,可影响隐匿单位,无法影响自缚单位", "每帧…传送0.8/30格…卫戍协议为每秒20帧",
// "伤害效果影响自身所在格及正前方4格内所有地面敌方单位,以及所有被自身阻挡的敌方单位").
// - Trait (本源铁卫) "能够阻挡三个敌人,可以造成元素损伤": the profession default (block 3), ground-only physical melee on her
// own tile (range 0-1). Module PRP-X 出发的勇气 adds "阻挡敌人时,自身造成的元素损伤提升15%" (trait bb ep_damage_scale):
// every element fill she deals ×ep_damage_scale while she blocks — an `elementHit` multiplier (the kits' convention: 菲莱 /
// 余 PRP-X).
// - T1 探险理论: "珊比受到的元素损伤降低15%" (ep_damage_scale 0.85): an `elementHit` multiplier on her at priority 20 (the
// operators' 受到的元素损伤 convention, docs/SIM.md §7.2). "珊比造成物理伤害时,附带攻击力10%的侵蚀损伤,并使目标的侵蚀损伤冷却减少
// 1秒" (ep_damage_ratio[trigger] / duration_dec): after each physical damage instance of hers that removes HP — attacks,
// the glue and conveyor ticks too ("每次珊比成功造成伤害后") — ep_damage_ratio[trigger] × ATK 侵蚀损伤 on the target, or,
// while its 侵蚀 burst (爆发冷却) runs, that cooldown −duration_dec s instead. The skills' own ep_damage_ratio[trigger] 0
// is read as their ability carrying no rider of its own [ASSUMED: PRTS names no exception to the talent].
// - T2 坚硬脚板: her HP damage marks the enemy (【脚板标记】, kept through a 重生, cleared when she leaves the field); a marked
// enemy's 侵蚀 burst — whoever filled it — gives her one stack of DEF +def (PRP-X stage 3: and ATK +atk; flat, at most
// max_stack_cnt, until she leaves the field [ASSUMED]) and shield_value 屏障 (one barrier buff: a further trigger adds to
// it), at most scale × her max HP.
// - S1 “还不走?” (MANUAL; data DEFAULT — the owner's 重装 cast-in-range exception, rawRule TAKE_DAMAGE): `duration` s of
// ATK / DEF +; she strikes every enemy she blocks at once and pushes each one she strikes with 小力 (attack@force 0) —
// radially from her (PRTS 备注), Battle.push's official 力度 − 重量 distance.
// - S2 “慢慢走~” (MANUAL; DEFAULT): `duration` s of ATK / DEF +atk / +def. At the cast she sprays glue on her own tile (not
// counted) and max_cnt − 1 (4, "占据4格") passable ground tiles by PRTS's order — front tile, its right, its left, its
// front (front blocked: her left, her right), then spreading from the last sprayed tile not spread yet: right, left,
// front — left / right / front being hers (glueTiles). Every `interval` s, the cast included [ASSUMED: the first pulse at
// the cast], every ground enemy on the glue [ASSUMED: the glue lies on the ground; flyers pass over it] — untargetable
// ones too, never a 隐藏 / 隐匿 / 无敌 one — takes 停顿 `sluggish` s, then atk_scale × ATK physical. While it lies,
// another allied operator's physical damage that removes HP from an enemy on the glue adds ep_damage_ratio[damage] × her
// ATK 侵蚀损伤 from her (her PRP-X scales it) [ASSUMED: the rider is hers]; 无来源 damage never does.
// - S3 “不准走!” (MANUAL; DEFAULT): `duration` s — block +block_cnt, strikes every blocked enemy at once, ATK / DEF +. The
// conveyor: the passable tiles among the max_cnt − 1 tiles ahead of her (not hers); every tick each unblocked ground enemy
// standing on one (by its centre tile), 重量 ≤ mass_level, not 自缚 — 隐匿 ones too — is carried towards her after its own
// movement by CONVEYOR_FRAME × CONVEYOR_FPS × dt: the official 0.8 / 30 tile per frame at 卫戍协议's 20 frames per second
// (PRTS: 0.533 tiles/s here, not the regular game's 0.8), never into an impassable tile (keeping 0.2 tile off its edge on a
// horizontal belt). Every `interval` s, the cast included [ASSUMED], every targetable ground enemy on her tile and the
// max_cnt − 1 tiles ahead and every enemy she blocks takes atk_scale × ATK physical; an enemy there whose 侵蚀 bursts
// loses thumpy[ep_break_water].def DEF more — permanent and stacking, as the burst's own −120 [ASSUMED].
import { num, talentBb, traitBb, skillRec, up } from '../shared/tier1.js';
import { offsetTile, frontOf, dirVec } from '../../../dir.js';
import { bodyInKeys } from '../../../body.js';
import { enemyStealthed, tileKeyOf } from '../../../targeting.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_thumpy_1';
const S2 = 'skchr_thumpy_2';
const S3 = 'skchr_thumpy_3';
/** PRTS S3 备注: the conveyor carries 0.8 / 30 tile per frame ("传送带矢量"; conveyor_speed / 30) … */
const CONVEYOR_FRAMES = 30;
/** … and 卫戍协议 runs 20 frames per second ("如果游戏的实际运行帧率发生改变(例如卫戍协议为每秒20帧),位移量也将随之变化"). */
const CONVEYOR_FPS = 20;
/** PRTS S3 备注: a horizontal belt keeps its enemies 0.2 tile off the edge of an impassable tile. */
const BELT_EDGE = 0.2;
/** "占据4格" / "向前展开4格" when the blackboard has no max_cnt (it counts her own tile too: max_cnt − 1). */
const TILES = 4;
/** Her facing-RIGHT frame: right hand, left hand, front ([dRow, dCol]; row 0 is the bottom row — sim/dir.js). */
const RIGHT = Object.freeze([-1, 0]);
const LEFT = Object.freeze([1, 0]);
const FRONT = Object.freeze([0, 1]);
const SOLE = 'talent:thumpy:sole';
const BARRIER = 'talent:thumpy:barrier';
const BELT_BREAK = 'skill:thumpy:beltBreak';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const nz = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => Number.isFinite(v) && v !== 0));
/**
* "可用地块" of the glue / belt: passable ground of the field with no obstacle on it (a 射击台 / mound / 阻隔工事 tile is no
* ground an enemy stands on; the mode's stages have no 假高台 / 坑洞 tiles).
*/
const usable = (battle, r, c) => battle.grid.groundPassable(r, c);
/**
* S2's glue tiles (PRTS 备注 order): her own tile when usable (not counted, never spreads), then `count` tiles — the front
* tile, its right, its left and its front (front unusable: her left, then her right), then spreading from the last sprayed
* tile that has not spread yet to its right, left and front, until `count` tiles or nothing left to spread. Absolute keys.
*/
function glueTiles(battle, unit, count) {
const keys = [];
const sprayed = new Set();
const list = [];
const abs = (lr, lc) => offsetTile(unit.tileR, unit.tileC, lr, lc, unit.dir);
const spray = (lr, lc) => {
if (list.length >= count || sprayed.has(`${lr},${lc}`)) return;
const [r, c] = abs(lr, lc);
if (!usable(battle, r, c)) return;
sprayed.add(`${lr},${lc}`);
list.push({ lr, lc, spread: false });
keys.push(r * COLS + c);
};
const near = ([lr, lc], [dr, dc]) => [lr + dr, lc + dc];
if (usable(battle, unit.tileR, unit.tileC)) { sprayed.add('0,0'); keys.push(unit.tileR * COLS + unit.tileC); }
const front = [0, 1];
if (usable(battle, ...abs(...front))) {
for (const d of [[0, 0], RIGHT, LEFT, FRONT]) spray(...near(front, d));
} else {
for (const d of [LEFT, RIGHT]) spray(...near([0, 0], d));
}
while (list.length < count) {
let at = null;
for (let i = list.length - 1; i >= 0; i--) if (!list[i].spread) { at = list[i]; break; }
if (!at) break;
at.spread = true;
for (const d of [RIGHT, LEFT, FRONT]) spray(...near([at.lr, at.lc], d));
}
return keys;
}
export default {
char_4235_thumpy: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const glueCount = Math.max(0, Math.round(num(b2.max_cnt, TILES + 1)) - 1);
const beltLen = Math.max(0, Math.round(num(b3.max_cnt, TILES + 1)) - 1);
const beltRate = num(b3.conveyor_speed) / CONVEYOR_FRAMES * CONVEYOR_FPS;
const massCap = num(b3.mass_level, 4);
/** S2: one glue pulse — 停顿 first, then the physical hit (PRTS 备注), on every ground enemy on the glue. */
const gluePulse = (battle, unit, glue) => {
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, glue) || enemyStealthed(e) || e.s.flags.invulnerable) continue;
battle.applyStatus(e, 'sluggish', { duration: num(b2.sluggish), source: unit });
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'phys', isSkill: true, ignoreSelect: true, tags: ['skill', 'thumpy:glue'] });
}
};
/** S3: the enemies the belt damages — targetable ground enemies on her tile and the tiles ahead, and every one she blocks. */
const beltVictims = (battle, unit, belt) => {
const out = battle.enemiesInKeys(belt.hurtKeys, unit, { canHitFly: false });
for (const e of battle.blockedTargets(unit, unit.profile)) if (!out.includes(e)) out.push(e);
return out;
};
/** S3: the conveyor carries the unblocked ground enemies on its tiles towards her (after their own movement). */
const carry = (battle, unit, belt, dt) => {
const [fr, fc] = belt.fwd;
const step = beltRate * dt;
if (!(step > 0)) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || e.blockedBy || e.s.flags.selfBound || e.s.massLevel > massCap) continue;
if (!belt.carryKeys.has(tileKeyOf(e))) continue;
let nx = e.x - fc * step, ny = e.y - fr * step;
const r = Math.round(ny), c = Math.round(nx);
if (!usable(battle, r, c)) {
// never into an impassable tile; a horizontal belt keeps BELT_EDGE off its edge
const er = Math.round(e.y), ec = Math.round(e.x);
if (fc !== 0) nx = ec - fc * (0.5 - BELT_EDGE); else ny = er - fr * (0.5 - 1e-3);
if (fc !== 0 ? (nx - e.x) * -fc <= 0 : (ny - e.y) * -fr <= 0) continue;
}
e.x = nx; e.y = ny;
if (e.route) e.route.pts = null;
}
};
return {
skills: {
[S1]: {
kind: 'duration',
mods: nz({ atkPct: num(b1.atk), defPct: num(b1.def) }),
attack: {
hitAllBlocked: true,
onEachHit({ battle, unit, target }) {
if (target && target.alive && target.side === 'enemy') battle.push(target, num(b1['attack@force']), { from: unit });
},
},
},
[S2]: {
kind: 'duration',
mods: nz({ atkPct: num(b2.atk), defPct: num(b2.def) }),
onStart({ battle, unit }) {
const keys = glueTiles(battle, unit, glueCount);
unit.mem.thumpyGlue = { keys: new Set(keys), acc: 0 };
const mid = (f) => keys.reduce((s, k) => s + f(k), 0) / Math.max(1, keys.length);
battle.fx('zone', { x: mid((k) => k % COLS), y: mid((k) => Math.floor(k / COLS)), radius: 1.2, dur: num(skillRec(chess, S2)?.duration), id: unit.id, skill: 'thumpy:glue' });
gluePulse(battle, unit, unit.mem.thumpyGlue.keys);
},
onTick({ battle, unit, dt }) {
const g = unit.mem.thumpyGlue;
if (!g) return;
const iv = Math.max(0.05, num(b2.interval, 0.7));
g.acc += dt;
while (g.acc + 1e-9 >= iv && unit.alive) { g.acc -= iv; gluePulse(battle, unit, g.keys); }
},
onEnd({ unit }) { unit.mem.thumpyGlue = null; },
},
[S3]: {
kind: 'duration',
mods: nz({ atkPct: num(b3.atk), defPct: num(b3.def), blockCnt: num(b3.block_cnt) }),
attack: { hitAllBlocked: true },
onStart({ battle, unit }) {
const ahead = [];
for (let k = 1; k <= beltLen; k++) ahead.push(frontOf(unit.tileR, unit.tileC, unit.dir, k));
const inField = ahead.filter(([r, c]) => battle.grid.inRect(r, c));
const belt = {
fwd: dirVec(unit.dir), acc: 0,
carryKeys: new Set(inField.filter(([r, c]) => usable(battle, r, c)).map(([r, c]) => r * COLS + c)),
hurtKeys: [unit.tileR * COLS + unit.tileC, ...inField.map(([r, c]) => r * COLS + c)],
};
belt.hurtSet = new Set(belt.hurtKeys);
unit.mem.thumpyBelt = belt;
const [fr, fc] = belt.fwd, half = (beltLen + 1) / 2;
battle.fx('zone', { x: unit.x + fc * half, y: unit.y + fr * half, radius: half, dur: num(skillRec(chess, S3)?.duration), id: unit.id, skill: 'thumpy:belt' });
for (const e of beltVictims(battle, unit, belt)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'thumpy:belt'] });
},
onTick({ battle, unit, dt }) {
const belt = unit.mem.thumpyBelt;
if (!belt) return;
carry(battle, unit, belt, dt);
const iv = Math.max(0.05, num(b3.interval, 1));
belt.acc += dt;
while (belt.acc + 1e-9 >= iv && unit.alive) {
belt.acc -= iv;
for (const e of beltVictims(battle, unit, belt)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'thumpy:belt'] });
}
},
onEnd({ unit }) { unit.mem.thumpyBelt = null; },
},
},
talents: [
{ install(battle, unit) { // 探险理论: 元素损伤 taken ×ep_damage_scale; her physical damage ⇒ 侵蚀损伤 or the burst −duration_dec s
const taken = num(t0.ep_damage_scale, 1);
if (taken !== 1) {
battle.on('elementHit', (c) => {
if (c.target === unit && c.dmg?.type === 'element') c.dmg.mul *= taken;
}, { owner: unit, priority: 20 });
}
const ratio = num(t0['ep_damage_ratio[trigger]']), dec = num(t0.duration_dec);
battle.on('damaged', (c) => {
const t = c.target;
if (c.source !== unit || c.type !== 'phys' || !(c.amount > 0) || !t || t.side !== 'enemy' || !hasHp(t)) return;
const lock = t.findBuff('erosionBurst');
if (lock) { if (dec > 0) lock.timeLeft = Math.max(0, lock.timeLeft - dec); return; }
if (ratio > 0) battle.dealDamage(unit, t, { type: 'element', element: 'erosion', amount: unit.s.atk * ratio, tags: ['talent', 'thumpy:theory'] });
}, { owner: unit, priority: -10 });
} },
{ install(battle, unit) { // 坚硬脚板: her HP damage marks; a marked enemy's 侵蚀 burst ⇒ DEF (+ATK) stack and 屏障
const def = num(t1.def), atk = num(t1.atk), shield = num(t1.shield_value), cap = Math.max(1, Math.floor(num(t1.max_stack_cnt, 1)));
const capHp = num(t1.scale, 3);
if (!(def || atk || shield)) return;
const marks = () => unit.mem.thumpyMarks ?? (unit.mem.thumpyMarks = new Set());
battle.on('deploy', (c) => { if (c.unit === unit) marks().clear(); }, { owner: unit });
battle.on('death', (c) => { if (c.unit === unit) marks().clear(); }, { owner: unit });
battle.on('damaged', (c) => {
if (c.source === unit && c.target && c.target.side === 'enemy' && c.type !== 'element' && c.amount > 0) marks().add(c.target);
}, { owner: unit, priority: 10 });
battle.on('elementBurst', (c) => {
const t = c.target;
if (c.element !== 'erosion' || !t || t.side !== 'enemy' || !up(unit) || !marks().has(t)) return;
if (def || atk) battle.addBuff(unit, { key: SOLE, refresh: 'stack', stacks: 1, maxStacks: cap, mods: nz({ defFlat: def, atkFlat: atk }), visible: true, tags: ['talent'] });
if (shield > 0) {
const max = capHp * unit.s.maxHp;
const cur = unit.findBuff(BARRIER);
if (cur) { cur.shield = Math.min(max, (cur.shield || 0) + shield); unit.markDirty(); }
else battle.addBuff(unit, { key: BARRIER, shield: Math.min(max, shield), visible: true, tags: ['talent'] });
}
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, talent: 'thumpy:sole' });
}, { owner: unit });
} },
],
install(battle, unit) {
// PRP-X: 阻挡敌人时,自身造成的元素损伤提升15%
const epScale = num(tb.ep_damage_scale, 1);
if (epScale !== 1) {
battle.on('elementHit', (c) => {
if (c.source === unit && c.dmg?.type === 'element' && c.target?.side === 'enemy' && unit.blocking.length > 0) c.dmg.mul *= epScale;
}, { owner: unit });
}
// S2: another operator's physical damage on an enemy in the glue ⇒ ep_damage_ratio[damage] × her ATK 侵蚀损伤
const rider = num(b2['ep_damage_ratio[damage]']);
if (rider > 0) {
battle.on('damaged', (c) => {
const g = unit.mem.thumpyGlue, s = c.source, t = c.target;
if (!g || !up(unit) || !s || s === unit || s.side !== 'ally' || s.kind !== 'op' || c.type !== 'phys' || !(c.amount > 0)) return;
if (!t || t.side !== 'enemy' || t.isFlying || !hasHp(t) || !bodyInKeys(t, g.keys)) return;
battle.dealDamage(unit, t, { type: 'element', element: 'erosion', amount: unit.s.atk * rider, tags: ['skill', 'thumpy:glue'] });
}, { owner: unit });
}
// S3: an enemy on the belt's tiles (or blocked by her) whose 侵蚀 bursts ⇒ DEF −30 more, for good
const breakDef = num(b3['thumpy[ep_break_water].def']);
if (breakDef) {
battle.on('elementBurst', (c) => {
const belt = unit.mem.thumpyBelt, t = c.target;
if (!belt || !up(unit) || c.element !== 'erosion' || !t || t.side !== 'enemy') return;
if (t.blockedBy !== unit && (t.isFlying || !bodyInKeys(t, belt.hurtSet))) return;
battle.addBuff(t, { key: BELT_BREAK, refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: breakDef }, source: unit });
}, { owner: unit });
}
},
};
},
};