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.
169 lines
9.6 KiB
JavaScript
169 lines
9.6 KiB
JavaScript
// server/sim/content/kits/ops/chess_char_4_04-ines.js — 伊内丝 (char_4087_ines) kit, tier 4.
|
||
// Conventions of the tier-4 kits: ../shared/tier4.js; kit contract and rules: ../README.md.
|
||
|
||
import { bodyDist } from '../../../body.js';
|
||
import { canTargetEnemy } from '../../../targeting.js';
|
||
import { AURA, num, tbb, grid, enemiesOnRange, pulse, isSel, alt, instantKind, withDefaults } from '../shared/tier4.js';
|
||
|
||
export default withDefaults({
|
||
// ===== 伊内丝 (agent) S2 暗夜无明 — stealth, +ATK, +1 DP & steal ASPD per attack; talents 影织 / 影哨
|
||
// S1 淬影突袭 (next attack: 3 s arts DoT 40 %/55 % ATK per s, not stacking, +2 DP); S3 独影归途 (passive: the
|
||
// first deployment leaves a 影哨 and retreats with the redeploy timer refreshed; every deployment: ATK up for
|
||
// the skill duration, the sentry is recalled through ≤ 4/5 enemies for 110 %/140 % phys, +1 DP per damage)
|
||
chess_char_4_04_a: (bb, chess, def) => {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||
const tb = def.traitBb || {};
|
||
const g = grid(def.skill?.rangeGrid);
|
||
const step = num(bb['attack@steal_atk_speed'], 5), maxSteal = num(bb['attack@steal_atk_speed_max'], 50);
|
||
const S3 = isSel(def, 'skchr_ines_3');
|
||
const clearAspd = (battle, unit) => {
|
||
for (const id of unit.mem.inesAspdVictims || []) { const e = battle.unitById(id); if (e) battle.removeBuff(e, `ines:aspd:${unit.id}`); }
|
||
unit.mem.inesAspdVictims = [];
|
||
unit.mem.inesAspd = 0;
|
||
battle.removeBuff(unit, 'ines:aspdGain');
|
||
};
|
||
const s3Dur = num(def.skill?.duration, 11);
|
||
return {
|
||
skills: alt(def, {
|
||
skchr_ines_1: () => ({
|
||
kind: instantKind(def),
|
||
attack: {
|
||
onHit({ battle, unit, target }) {
|
||
battle.addDp(unit.ownerId, num(bb.cost, 2));
|
||
if (!target || !target.alive || target.side !== 'enemy') return;
|
||
const scale = num(bb.bleed_atk_scale, 0.4);
|
||
// (不叠加): a new DoT replaces the running one
|
||
battle.addBuff(target, { key: `ines:bleed:${unit.id}`, duration: num(bb.bleed_duration, 3) + 1e-6, interval: 1, source: unit, visible: true, refresh: 'extend',
|
||
onTick: ({ unit: t }) => battle.dealDamage(unit, t, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'dot'] }) });
|
||
},
|
||
},
|
||
}),
|
||
skchr_ines_3: () => ({
|
||
kind: 'passive',
|
||
onStart({ battle, unit }) {
|
||
if (!unit.mem.inesS3Placed) {
|
||
// 首次部署: place a 影哨 (her talent's sentry, left on retreat) and leave; 立刻刷新再部署时间 — the redeploy
|
||
// (auto, paying her DP cost like every redeploy) follows as soon as it is affordable
|
||
unit.mem.inesS3Placed = true;
|
||
battle.after(0, () => {
|
||
if (!unit.alive || !unit.deployed) return;
|
||
battle.retreat(unit, { reason: 'retreat' });
|
||
unit.respawnAt = battle.time;
|
||
battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit });
|
||
return;
|
||
}
|
||
battle.addBuff(unit, { key: 'ines:s3', duration: s3Dur, mods: { atkPct: num(bb.atk) }, visible: true });
|
||
// 立刻收回影哨: the sentry flies back to her and hits ≤ max_target enemies on its way (within
|
||
// projectile_range of the segment, [ASSUMED] nearest to its start first)
|
||
const from = unit.mem.inesSentryAt;
|
||
if (!unit.mem.sentry || !from) return;
|
||
unit.mem.sentry = null;
|
||
unit.mem.inesSentryAt = null;
|
||
const ax = from.x, ay = from.y, bx = unit.x, by = unit.y;
|
||
const L2 = (bx - ax) ** 2 + (by - ay) ** 2;
|
||
const w = num(bb.projectile_range, 1.4);
|
||
const hits = [];
|
||
for (const e of battle.aliveEnemies()) {
|
||
if (!canTargetEnemy(unit, e, { canHitFly: true })) continue;
|
||
const t = L2 > 1e-9 ? Math.max(0, Math.min(1, ((e.x - ax) * (bx - ax) + (e.y - ay) * (by - ay)) / L2)) : 0;
|
||
const d = bodyDist(e, ax + t * (bx - ax), ay + t * (by - ay));
|
||
if (d <= w + 1e-9) hits.push({ e, t, d });
|
||
}
|
||
hits.sort((a, b) => a.t - b.t || a.d - b.d || a.e.id - b.e.id);
|
||
const amount = unit.s.atk * num(bb.atk_scale, 1.1); // one projectile: ATK taken when it flies back
|
||
for (const { e } of hits.slice(0, Math.max(1, Math.floor(num(bb.max_target, 4))))) {
|
||
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill', 'sentryRecall'] });
|
||
}
|
||
battle.fx('beam', { x: ax, y: ay, tx: bx, ty: by, id: unit.id });
|
||
},
|
||
}),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
flags: { stealth: true },
|
||
targeting: g ? { rangeGrid: g } : undefined,
|
||
onStart({ battle, unit }) { clearAspd(battle, unit); battle.fx('stealth', { x: unit.x, y: unit.y, id: unit.id }); },
|
||
onAttack({ battle, unit }) { battle.addDp(unit.ownerId, num(bb.cost, 1)); },
|
||
onHit({ battle, unit, target }) {
|
||
if (!target || !target.alive || target.side !== 'enemy') return;
|
||
const room = maxSteal - (unit.mem.inesAspd ?? 0);
|
||
if (room <= 0) return;
|
||
const s = Math.min(step, room);
|
||
unit.mem.inesAspd = (unit.mem.inesAspd ?? 0) + s;
|
||
const key = `ines:aspd:${unit.id}`;
|
||
const cur = target.findBuff(key);
|
||
battle.addBuff(target, { key, mods: { aspd: (cur?.mods?.aspd ?? 0) - s }, source: unit });
|
||
if (!(unit.mem.inesAspdVictims ||= []).includes(target.id)) unit.mem.inesAspdVictims.push(target.id);
|
||
battle.addBuff(unit, { key: 'ines:aspdGain', mods: { aspd: unit.mem.inesAspd } });
|
||
},
|
||
onEnd({ battle, unit }) { clearAspd(battle, unit); },
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 影织: first damage on each enemy → bind 5 s + steal 90 ATK until it dies or Ines leaves
|
||
unit.mem.woven = new Set();
|
||
unit.mem.atkVictims = [];
|
||
const refresh = () => {
|
||
unit.mem.atkVictims = unit.mem.atkVictims.filter((id) => battle.unitById(id)?.alive);
|
||
const gain = Math.min(num(t0.steal_atk_max, Infinity), num(t0.steal_atk, 90) * unit.mem.atkVictims.length);
|
||
if (unit.alive) { if (gain > 0) battle.addBuff(unit, { key: 'ines:atkGain', mods: { atkFlat: gain } }); else battle.removeBuff(unit, 'ines:atkGain'); }
|
||
};
|
||
battle.on('damaged', (c) => {
|
||
const e = c.target;
|
||
if (c.source !== unit || !unit.alive || e.side !== 'enemy' || c.type === 'element' || unit.mem.woven.has(e.id)) return;
|
||
unit.mem.woven.add(e.id);
|
||
if (!e.alive) return;
|
||
battle.applyStatus(e, 'bind', { duration: num(t0.duration, 5), source: unit });
|
||
battle.addBuff(e, { key: `ines:atkSteal:${unit.id}`, mods: { atkFlat: -num(t0.steal_atk, 90) }, source: unit });
|
||
unit.mem.atkVictims.push(e.id);
|
||
refresh();
|
||
battle.fx('shadowWeave', { x: e.x, y: e.y, id: e.id });
|
||
}, { owner: unit });
|
||
battle.on('death', (c) => {
|
||
if (c.unit.side === 'enemy' && unit.mem.atkVictims.includes(c.unit.id)) refresh();
|
||
if (c.unit !== unit) return;
|
||
for (const id of unit.mem.atkVictims) { const e = battle.unitById(id); if (e) battle.removeBuff(e, `ines:atkSteal:${unit.id}`); }
|
||
unit.mem.atkVictims = [];
|
||
unit.mem.woven = new Set();
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 影哨: reveal + −30 % move speed in range; a sentry keeps it after she leaves (max 1)
|
||
const mods = { moveMul: Math.max(0, 1 + num(t1.move_speed, -0.3)) };
|
||
battle.on('death', (c) => {
|
||
if (c.unit !== unit || c.reason === 'expired') return;
|
||
unit.mem.sentry = new Set(unit.baseRangeKeys || unit.rangeKeys || []);
|
||
battle.fx('sentry', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit });
|
||
battle.every(AURA, () => {
|
||
const set = new Set();
|
||
if (unit.alive && unit.deployed) for (const k of unit.rangeKeys || []) set.add(k);
|
||
if (unit.mem.sentry) for (const k of unit.mem.sentry) set.add(k);
|
||
if (!set.size) return;
|
||
for (const e of enemiesOnRange(battle, unit, set)) pulse(battle, e, `ines:sentry:${unit.id}`, mods, { flags: { reveal: true } });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
if (S3) {
|
||
// where the talent's 影哨 stays (every leave but an expiry), for the recall of the next deployment
|
||
battle.on('death', (c) => { if (c.unit === unit && c.reason !== 'expired') unit.mem.inesSentryAt = { x: unit.x, y: unit.y }; }, { owner: unit, priority: 5 });
|
||
// 技能期间每对一个敌人造成伤害就获得1点部署费用
|
||
battle.on('damaged', (c) => {
|
||
if (c.source !== unit || c.target.side !== 'enemy' || !(c.amount > 0) || c.type === 'element' || !unit.findBuff('ines:s3')) return;
|
||
battle.addDp(unit.ownerId, num(bb.cost, 1));
|
||
}, { owner: unit });
|
||
}
|
||
// module (elite): the first retreat's redeploy time is 35 % shorter
|
||
const rt = num(tb.respawn_time, 0);
|
||
if (!rt) return;
|
||
battle.on('death', (c) => {
|
||
if (c.unit !== unit || unit.mem.firstRetreatDone || !Number.isFinite(unit.respawnAt)) return;
|
||
unit.mem.firstRetreatDone = true;
|
||
unit.respawnAt = unit.deathAt + (unit.respawnAt - unit.deathAt) * Math.max(0, 1 + rt);
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
});
|