Pure move: every line of a kit, of a helper and of the tier headers is copied verbatim (only the headers' first
line names the new file); a helper used by one kit moved into that kit's file. Glue only: the generated imports,
`export default { … }` around each registry entry, `export { … }` lists in shared/tier2..6.js, and tier4's
`(bb, chess, def)` default-argument wrapper as shared/tier4.js `withDefaults` (applied in each tier-4 kit file).
kits/index.js loads every kit file with a guarded dynamic import (browser-safe, no directory listing); tierN.js
re-export the tier registry (TIER_KITS) and their old named exports (tier1 helpers + tinmanKit, tier6
PITHST_ELEMENTS). The merged registry, every tier default and named export have the same keys, order, frozenness
and function sources as before. test/docs-consistency.test.js reads the moved code at its new paths.
Golden: GOLDEN_FULL=1 node --test test/golden.test.js unchanged.
181 lines
10 KiB
JavaScript
181 lines
10 KiB
JavaScript
// server/sim/content/kits/ops/chess_char_6_13-angel2.js — 新约能天使 (char_1041_angel2) kit, tier 6.
|
||
// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md.
|
||
|
||
import { sortEnemyTargets, aggroCmp } from '../../../targeting.js';
|
||
import { summonToken, TOKEN_IDS } from '../../tokens.js';
|
||
import { num, bv, tbb, live, hasBond, ANY, isTok, selectedSkill, batOf, aura } from '../shared/tier6.js';
|
||
|
||
/** Barrier of `total` HP decaying linearly to 0 over `dur` seconds. */
|
||
function decayingShield(battle, u, key, total, dur) {
|
||
if (!(total > 0)) return null;
|
||
const decays = dur > 0;
|
||
return battle.addBuff(u, {
|
||
key, shield: total, duration: decays ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0,
|
||
onTick: decays ? ({ unit, buff }) => { buff.shield = Math.max(0, buff.shield - (total * 0.5) / dur); unit.markDirty(); } : null,
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组
|
||
// S2 (PRTS 备注): the ally is the friendly operator of her attack range with the highest 仇恨值 ("选择的友方干员为攻击范围内
|
||
// 仇恨值最高的我方干员": highest taunt level, then the latest deployed — targeting.js aggroCmp, the order enemies attack
|
||
// in; it used to be the highest ASPD, so the shield often went to a back-row shooter instead of the operator in front);
|
||
// both barriers are shield_max_hp_ratio × the holder's OWN max HP ("获得的屏障均以自身生命上限为标准计算"), losing
|
||
// initial/shield_max_duration per second ("屏障每秒衰减量为:初始屏障量/30"); a new one replaces the old one ("重复获得此
|
||
// 屏障时,重置屏障量与衰减速度").
|
||
|
||
const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data)
|
||
|
||
function angel2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const sid = selectedSkill(chess, def);
|
||
const steal = num(bb.steal), extra = Math.floor(num(bb.addtional_ammo_each));
|
||
const shieldRatio = num(bb.shield_max_hp_ratio), shieldDur = num(bb.shield_max_duration);
|
||
const coordId = (chess?.tokens || []).find((t) => /angel2_target/.test(String(t))) || TOKEN_IDS.deliveryTarget;
|
||
const perAttack = 5; // S3 "每次攻击消耗5发" (the 5 连击)
|
||
const coord = (battle, unit) => battle.allyUnits.find((t) => isTok(t, coordId, unit) && t.alive) ?? null;
|
||
const placeCoord = (battle, unit) => {
|
||
unit.mem.angelCoord = false;
|
||
if (coord(battle, unit)) return;
|
||
const t = summonToken(battle, unit, coordId, 'melee');
|
||
if (t) battle.fx('anchor', { x: t.x, y: t.y, id: t.id, src: unit.id });
|
||
};
|
||
const skills = {
|
||
// S1 天空大扫除: 8 bullets at attack@atk_scale × ATK, flyers first; stopped by hand ⇒ the remaining bullets are fired
|
||
// at random enemies of her range
|
||
skchr_angel2_1: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 8))),
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
|
||
targeting: { priority: 'fly' },
|
||
onAttack({ unit, skill }) { unit.mem.angelLeft = skill.ammoLeft - 1; },
|
||
onEnd({ battle, unit, skill, reason }) {
|
||
const left = Math.max(0, Math.floor(num(unit.mem.angelLeft)));
|
||
unit.mem.angelLeft = 0;
|
||
if (reason !== 'stopped' || !unit.alive || !left) return;
|
||
for (let i = 0; i < left; i++) {
|
||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ANY);
|
||
const e = battle.rng.pick(c);
|
||
if (!e) break;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@atk_scale'], 1), type: 'phys', isAttack: true, isSkill: true, tags: ['skill', 'volley'] });
|
||
battle.emit('ammoUsed', { unit, left: left - i - 1, skill });
|
||
}
|
||
},
|
||
},
|
||
// S3 使命必达!: ATK +atk; attacks are 5 hits of attack@atk_scale × ATK spending 5 of the 50 bullets; at the start,
|
||
// with a 投递坐标 (placed after each deployment, install): attack@cannon_atk_scale × ATK physical splash there and the
|
||
// knocked-out ground operator with the longest remaining redeploy time lands on it with attack@sp SP
|
||
skchr_angel2_3: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 50))),
|
||
mods: { atkPct: num(bb.atk) },
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: perAttack },
|
||
onStart({ battle, unit }) {
|
||
if (unit.mem.angelCoord) placeCoord(battle, unit);
|
||
const c = coord(battle, unit);
|
||
if (!c) return;
|
||
const r = c.tileR, col = c.tileC;
|
||
battle.fx('airstrike', { x: col, y: r, id: unit.id, r: AIRSTRIKE_RADIUS });
|
||
for (const e of battle.foesInRadius(col, r, AIRSTRIKE_RADIUS)) {
|
||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@cannon_atk_scale'], 1), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'delivery'] });
|
||
}
|
||
const waiting = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && !a.alive && !a.removed && a !== unit
|
||
&& a.def?.position !== 'RANGED' && battle.grid.canStand(r, col, { ranged: false }));
|
||
if (!waiting.length) return;
|
||
waiting.sort((a, b) => (b.respawnAt ?? 0) - (a.respawnAt ?? 0) || b.base.respawnTime - a.base.respawnTime || a.id - b.id);
|
||
const a = waiting[0];
|
||
battle.retreat(c, { reason: 'expired', permanent: true });
|
||
if (battle.redeploy(a, { free: true, tile: [r, col] })) {
|
||
if (a.skill) a.skill.gainSp(num(bb['attack@sp']), 'skill');
|
||
battle.fx('appear', { x: col, y: r, id: a.id, src: unit.id });
|
||
}
|
||
},
|
||
onAttack({ battle, unit, skill }) { // bullets 2..5 of the 5 连击 (the engine spends the 1st)
|
||
for (let i = 1; i < perAttack && skill.ammoLeft > 1; i++) {
|
||
skill.ammoLeft--;
|
||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||
}
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_angel2_1') { // bullets loaded at the start (after every skillStart ammo bonus: 逃犯引渡手续 …)
|
||
battle.on('skillStart', ({ unit: u, skill }) => { if (u === unit) unit.mem.angelLeft = skill.ammoLeft; }, { owner: unit, priority: -500 });
|
||
}
|
||
if (sid !== 'skchr_angel2_3') return;
|
||
// "部署后获得投递坐标": one coordinate per deployment, placed (the player's choice in the original) on a ground tile
|
||
// of her range on the enemy path next to the first enemy that enters her range (at the latest when S3 starts)
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.angelCoord = !coord(battle, unit); }, { owner: unit });
|
||
battle.on('tick', () => {
|
||
if (!unit.mem.angelCoord || !live(unit) || !battle.enemiesInKeys(unit.rangeKeys, unit, ANY).length) return;
|
||
placeCoord(battle, unit);
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 10))),
|
||
mods: { batPct: batOf(bb.base_attack_time, def) },
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
|
||
onStart({ battle, unit, skill }) {
|
||
const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a));
|
||
cands.sort(aggroCmp);
|
||
const victim = steal > 0 ? cands[0] ?? null : null;
|
||
unit.mem.angelVictim = victim;
|
||
decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur);
|
||
if (victim) {
|
||
battle.addBuff(victim, { key: 'angel2:stolen', mods: { aspd: -steal }, source: unit, visible: true });
|
||
battle.addBuff(unit, { key: 'angel2:steal', mods: { aspd: steal } });
|
||
decayingShield(battle, victim, 'angel2:barrier', victim.s.maxHp * shieldRatio, shieldDur);
|
||
if (extra > 0) skill.addAmmo(extra);
|
||
battle.fx('steal', { x: victim.x, y: victim.y, id: victim.id, src: unit.id });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const v = unit.mem.angelVictim;
|
||
unit.mem.angelVictim = null;
|
||
if (v) battle.removeBuff(v, 'angel2:stolen');
|
||
battle.removeBuff(unit, 'angel2:steal');
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 火力电台
|
||
const hr = num(t0.hp_ratio), prob = num(t0.prob), sc = num(t0.aoe_atk_scale, num(t0.damage_scale));
|
||
battle.on('ammoUsed', ({ unit: u }) => {
|
||
if (!live(unit) || !u || u.side !== 'ally') return;
|
||
if (hr > 0) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, silent: true });
|
||
if (!(prob > 0) || !(sc > 0) || battle.rng() >= prob) return;
|
||
const cands = battle.enemiesInKeys(u.rangeKeys || [], u, ANY);
|
||
if (!cands.length) return;
|
||
sortEnemyTargets(battle, u, cands, null);
|
||
const c = cands[0];
|
||
battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS });
|
||
for (const e of battle.foesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定
|
||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] });
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 铳弹协约
|
||
const atk = num(t1.atk), mult = num(t1.mult, 1);
|
||
if (atk) aura(battle, unit, 0.5, () => {
|
||
for (const a of battle.allyUnits) {
|
||
if (!live(a) || a.kind !== 'op' || !a.skill || a.skill.kind !== 'ammo') continue;
|
||
battle.addBuff(a, { key: 'angel2:covenant', mods: { atkPct: atk * (hasBond(a, 'lateranoShip') ? mult : 1) }, duration: 0.75, refresh: 'replace' });
|
||
}
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // elite module: HP > 80 % ⇒ SP +0.25/s
|
||
const thr = bv(tb, 'hp_ratio', 0), spr = bv(tb, 'sp_recovery_per_sec', 0);
|
||
if (spr > 0 && tb['angel2_tr[e].hp_ratio'] != null) aura(battle, unit, 0.25, () => {
|
||
if (unit.hpRatio > num(tb['angel2_tr[e].hp_ratio'], thr)) battle.addBuff(unit, { key: 'angel2:calm', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
export default {
|
||
chess_char_6_13_a: angel2,
|
||
};
|