Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-thumpy.js
T

308 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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',
// the owner's rule for a skill whose area is larger than her range (her tile): cast as soon as a ground enemy she
// can affect is on the glue's first five tiles — her tile, the front one, its two sides and the one beyond
// (glueTiles' PRTS order), as 余 S2's x-1 (§22.10); the data's DEFAULT (重装 exception) only saw her own tile
trigger: { rule: 'ACTIVE_RANGE', grid: [[0, 0], [0, 1], [1, 1], [-1, 1], [0, 2]] },
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',
// likewise on the conveyor: her tile and the four ahead (the owner's larger-area rule; 余 S2 precedent)
trigger: { rule: 'ACTIVE_RANGE', grid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] },
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 });
}
},
};
},
};