Files
ark-stronghold-protocol/server/sim/content/enemies/invisible.js
T
Sukvii c250ff66f4 feedback7 R22-GHSIM: 假想敌:骨刺 targets as PRTS writes — its normal attack ignores its block (by priority, not the blocker first), never a flying unit, and its 3 hidden targets come from its normal attack's reach (PR #365 by @Sukvii, sim part, each line checked against PRTS); golden: roster-018 moves
The sim lines are the PR's (content/enemies/invisible.js kitBoneSpike: profile blockFree and canTarget, the hidden
selection over rangeRadius + the ally collider), so the contributor is the author of this commit; the maintainer side
checked each rule, kept the engine's single target when the hidden list is empty, and wrote the tests.

Official: PRTS 假想敌:骨刺, 级别0 天赋 (on the page since 2025-11, PRTS editors): 「自身普通攻击索敌不受阻挡影响」 「不会攻击
飞行单位,隐匿(解除阻挡0秒后恢复)」 「隐匿期间可同时攻击3个目标」; enemy_database's level description 「隐匿;隐匿状态下同时攻击
3个目标。」 (the handbook line keeps 「数个目标」) — the 3 is no longer [ASSUMED]. blockFree works as for 自制投石机 (a 隐匿 /
迷彩 blocker is no target); canTarget as the engine's other 不会攻击飞行单位 enemies; the hidden 3 are picked among the
allies its normal attack could hit (ai.js attackTargets: rangeRadius + ALLY_COLLIDER_RADIUS, PRTS 作战机制 碰撞体积) — they
were picked within the bare 2.0. Stealth back 0 s after a block was already so.

Golden: roster-018 — the 骨刺 in it lands one more attack (on 异客; dmg 2434 → 2771), the same move PR #365 reported.
2026-10-08 19:28:53 +08:00

166 lines
11 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/enemies/invisible.js — INVISIBLE 隐匿 kits (stealthy enemies) and their part of KITS (split from
// content/enemies.js).
import { canTargetAlly, enemyStealthed } from '../../targeting.js';
import { ALLY_COLLIDER_RADIUS } from '../../constants.js';
import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js';
import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js';
import { hypot } from '../../detmath.js';
// ---------------------------------------------------------------------------------------------------------------
// constants (numbers that exist nowhere in the data)
/** 假想敌:骨刺's targets at once while its 隐匿 holds: 3 — the level's own description 「隐匿;隐匿状态下同时攻击3个目标。」
* (enemy_database; the handbook line says 「数个目标」) and PRTS 天赋 「隐匿期间可同时攻击3个目标」 (GitHub #365). */
const ACBUNN_TARGETS = 3;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后
* 向目标方向发射1支弩箭…※技能持续2.5s"). */
const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4;
// ---------------------------------------------------------------------------------------------------------------
// kits
function kitCrossbow(ab) {
const s = ab.sk.CrossAttack;
const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH);
const nearest = (b, e, l) => l.sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y))[0];
// PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%
// 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still
// in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release
// cancels the shot [ASSUMED]. The blackboard's duration (2) is not the skill's length PRTS gives. Until 0.1.3: revealed
// 2 s, the bolt at once.
return [stealth(), skill(s, (b, e) => {
const t = nearest(b, e, aligned(b, e));
if (!t) return;
const bb = s.bb;
const dx = Math.sign(Math.round(t.x - e.x)), dy = Math.sign(Math.round(t.y - e.y));
b.addBuff(e, { key: 'ab:revealed', duration: CROSS_SKILL, flags: { reveal: true } });
b.addBuff(e, { key: 'ab:aim', duration: CROSS_SKILL, flags: { noMove: true, disarm: true } });
b.after(CROSS_CHARGE, () => {
if (!e.alive || e.hidden || e.s.flags.stun || e.s.flags.silence) return;
const tt = nearest(b, e, aligned(b, e).filter((u) => Math.sign(Math.round(u.x - e.x)) === dx && Math.sign(Math.round(u.y - e.y)) === dy));
if (!tt) return;
b.addProjectile({ from: e, target: tt, speed: 12, visual: 'enemy', source: e, onHit: (c) => {
if (!c.target || !c.target.alive) return;
hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts');
if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e });
} });
}, { owner: e });
}, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })];
}
/**
* 山海众头目 / 山海众秘使 (PRTS 天赋 "隐匿,该隐匿每次生效后自身获得强击标记(不可叠加)"; 技能 破隐一击 "仅持有强击标记且被阻挡时可
* 触发:对阻挡目标造成攻击力200%的物理普通伤害,技能开始时消耗强击标记"): the mark comes with its 隐匿 — at the spawn and every
* time it hides again (3 s after a block, Battle._stealthSwitch; or once a 反隐 ends) — and its next attack (a melee
* one: blocked) spends it at InvisibleCombat.atk_scale. A new block inside the 3 s gives no new mark (until 0.1.2 every
* block did).
*/
function kitShadowKiller(ab) {
const scale = (ab.sk.InvisibleCombat && ab.sk.InvisibleCombat.bb.atk_scale) || 1;
return [stealth(), {
spawn(b, e, a) { a.power = true; a.on = true; },
tick(b, e, a) { const on = enemyStealthed(e); if (on && !a.on) a.power = true; a.on = on; },
hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; },
attack(c, b, e, a) { a.power = false; },
}];
}
function kitJazz(ab, e) {
const s = ab.sk.fire;
const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.5;
const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block)
return [stealth(), {
// PRTS 天赋 "隐匿期间不进行普通攻击;未受隐匿影响时进入反击模式:仅进行阻挡攻击,造成100%物理伤害": its normal attack only ever
// hits its blocker (`melee`), so a revealed (反隐) unblocked one neither shoots nor stands for its attack clip (until
// 0.1.3 it shot everyone within its 2.5 range and stood still while it had a target — ai.js attackStand)
spawn(b, e2) { e2.profile.noAttack = true; e2.profile.melee = true; },
tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击
}, s ? {
// 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标
// 造成攻击力20%的法术伤害和攻击力10%的灼燃损伤 ※施法期间受到沉默影响后,立即结束技能" — ONE locked target (its own
// priority: the blocker first), floor(10.6 / 0.5) = 21 ticks at 0.5 … 10.5 s. The channel also ends when that target
// is gone, makes no normal attacks meanwhile and is never re-cast over itself — the 10 s cooldown (enemy_database
// skill 'fire') runs out mid-channel, the next cast waits for its end [ASSUMED "持续施法", as 死亡之眼].
sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2) && !(e2.mem.jazzChannel && !e2.mem.jazzChannel.cancelled) && targetsNear(b, e2, r).length > 0,
fire(b, e2) {
const dur = s.bb['enemy_cnvsax[cd].duration'] ?? 0, iv = s.bb.hit_interval ?? 0.5;
const t = byPriority(e2, targetsNear(b, e2, r))[0];
if (!t || !(dur > 0) || !(iv > 0)) return;
const n = Math.max(1, Math.floor(dur / iv + 1e-9));
const buff = b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } });
let k = 0;
const end = () => { h.cancel(); e2.mem.jazzChannel = null; if (buff) b.removeBuff(e2, buff); };
const h = b.every(iv, () => {
if (!e2.alive || e2.s.flags.silence || !t.alive || !t.deployed) { end(); return; }
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'jazzFire' });
hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts');
elem(b, e2, t, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0));
if (++k >= n) end();
}, { owner: e2 });
e2.mem.jazzChannel = h;
},
} : null];
}
function kitShadowBlade(ab) {
const bat = ab.t['traitAbility.base_attack_time'] ?? 0;
return [stealth(), {
iv: 0.5,
tick(b, e) {
const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId));
const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1;
if (partner && hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat });
},
}];
}
/**
* 假想敌:骨刺 — PRTS 天赋 (级别0): 「自身普通攻击索敌不受阻挡影响」 — profile `blockFree` (as 自制投石机): blocked, and so out of its
* 隐匿, it still picks by priority among the allies in reach, not its blocker first, and a 隐匿 / 迷彩 blocker is no target
* (targeting.js canTargetAlly); 「不会攻击飞行单位」 — profile `canTarget`; 「隐匿(解除阻挡0秒后恢复)」 — stealth(); 「隐匿期间
* 可同时攻击3个目标」: while its 隐匿 holds, the first ACBUNN_TARGETS by priority among the allies its normal attack could hit
* (ai.js attackTargets: the same reach — rangeRadius + the ally collider — and the same rules); one otherwise. Until
* 0.2.2 (PR #365 by @Sukvii, the PRTS lines checked for this port): blocker first, flyers hit, the 3 picked within the bare
* rangeRadius.
*/
function kitBoneSpike() {
return [stealth(), {
spawn(b, e) {
e.profile.blockFree = true;
e.profile.canTarget = (u) => !u.isFlying;
},
before(c, b, e) {
if (!enemyStealthed(e)) return;
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius + ALLY_COLLIDER_RADIUS).filter(e.profile.canTarget));
if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS);
},
}];
}
// ---------------------------------------------------------------------------------------------------------------
// this family's part of KITS (content/enemies.js spreads the parts in this order)
export const INVISIBLE_KITS = Object.freeze({
// --- INVISIBLE 隐匿
enemy_1009_lurker: kitStealth, // 潜伏者 · stealth
enemy_1019_jshoot: kitStealth, // 隐形弩手 · stealth
enemy_1019_jshoot_2: kitStealth, // 隐形弩手组长 · stealth
enemy_1023_jmage: kitStealth, // 隐形术师 · stealth
enemy_1283_sgkill: kitStealth, // 家族灭迹人 · stealth (清算时刻 n/a)
enemy_1283_sgkill_2: kitStealth, // 家族暗影灭迹人 · stealth
enemy_1299_ymkilr: kitShadowKiller, // 山海众头目 · stealth; its 隐匿 turning on brings the mark: next attack ×InvisibleCombat.atk_scale (§22.8)
enemy_1299_ymkilr_2: kitShadowKiller, // 山海众秘使 · same
enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a)
enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself)
enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth
enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth; revealed: attacks its blocker only; channel on one target (arts + burn)
enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a)
enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth
enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed
enemy_1175_dushdo_2: kitShadowBlade, // 深池伙友影刃精英 · stealth; BAT −1.3 s next to 卫队精英
enemy_2034_sythef: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · stealth, attacks stun
enemy_2034_sythef_2: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · same
});