feedback2 WG: an enemy's 隐匿 returns 3 s after a block ends (0 s / 1 s where its PRTS page says so); a 深池逐火 knocked out while blocked leaves a revealed ember; 山海众头目's strong hit comes with its 隐匿 (#43 follow-up)

This commit is contained in:
sganggs committed 2026-10-03 23:43:28 +08:00
1 parent 1aed315579
commit b32c510069
7 files changed
+120 -27

No files matched your search

+29 -8
View File
@@ -24,14 +24,14 @@
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN } from './constants.js';
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN, STEALTH_RESTORE } from './constants.js';
import { GEO, layerGainRoom } from '../../shared/constants.js';
import { createRng } from './rng.js';
import { Grid } from './grid.js';
import { Unit } from './units.js';
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js';
import { absoluteRangeKeys, canTargetEnemy, extendedGrid, evadesGround } from './targeting.js';
import { absoluteRangeKeys, canTargetEnemy, extendedGrid, evadesGround, enemyStealthed, stealthOffKey } from './targeting.js';
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
import { ProjectileSystem } from './projectiles.js';
@@ -1169,13 +1169,33 @@ export class Battle {
const i = bl.blocking.indexOf(e);
if (i >= 0) bl.blocking.splice(i, 1);
e.blockedBy = null;
this._stealthSwitch(e);
}
/** Release every enemy blocked by ally `u` (death, retreat, block count drop, substitution…). */
releaseBlocked(u) {
if (!u || !u.blocking || !u.blocking.length) return;
for (const e of u.blocking) if (e.blockedBy === u) e.blockedBy = null;
const was = u.blocking;
u.blocking = [];
for (const e of was) if (e.blockedBy === u) { e.blockedBy = null; this._stealthSwitch(e); }
}
/**
* A block on enemy `e` just ended (every release goes through here: `_unblock`, `releaseBlocked`, ai.js
* enforceBlockCapacity). Each 隐匿 source it holds stays switched off for its restore time — PRTS 作战机制 §隐匿 "对于
* 绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿" (STEALTH_RESTORE), or the source's
* own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`, content/enemies.js: 0 s / 1 s on some enemy pages) — as a
* `stealthOff` buff per source; meanwhile it is targetable, operator splash reaches it and it is drawn solid
* (targeting.js enemyStealthed). A new block inside the window lifts it again and its end restarts the window. Our
* operators' 隐匿 / 迷彩 are never lifted by blocking (only enemies get here).
*/
_stealthSwitch(e) {
if (!e || e.side !== 'enemy' || !e.alive || !e.s.flags.stealth) return;
for (const b of e.buffs.slice()) {
if (!b.flags || !b.flags.stealth) continue;
const t = Number.isFinite(b.data?.stealthRestore) ? b.data.stealthRestore : STEALTH_RESTORE;
if (t > 0) this.addBuff(e, { key: stealthOffKey(b.key), duration: t, flags: { stealthOff: true } });
}
}
// =============================================================================================================
@@ -1609,17 +1629,18 @@ export class Battle {
/**
* The enemies within `r` (as enemiesInRadius) an ally-side area effect can select — PRTS 作战机制 §AOE伤害判定 "AOE的判定是
* 对攻击范围内的每个可以被选中的敌人进行判定", 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标": no
* untargetable enemy and no 隐匿 one unless revealed or blocked (tile selectors — enemiesInKeys / canTargetEnemy — already
* skip them). Flying and asleep enemies stay the caller's choice. Enemy-side effects on other enemies (auras, heals) and
* physical collisions keep enemiesInRadius. Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash
* and skill circles still reached an unblocked 隐匿 enemy.
* untargetable enemy and no 隐匿 one unless revealed, blocked or not hidden again yet after a block (targeting.js
* enemyStealthed; tile selectors — enemiesInKeys / canTargetEnemy — already skip them). Flying and asleep enemies stay
* the caller's choice. Enemy-side effects on other enemies (auras, heals) and physical collisions keep enemiesInRadius.
* Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash and skill circles still reached an
* unblocked 隐匿 enemy.
*/
foesInRadius(x, y, r, centre = false) {
const out = this.enemiesInRadius(x, y, r, centre);
let n = 0;
for (const e of out) {
const f = e.s.flags;
if (f.untargetable || (f.stealth && !f.reveal && !e.blockedBy)) continue;
if (f.untargetable || (f.stealth && enemyStealthed(e))) continue;
out[n++] = e;
}
out.length = n;
+1 -1
View File
@@ -97,7 +97,7 @@ export function enforceBlockCapacity(b, u) {
for (const e of u.blocking) used += e.blockWeight ?? 1;
while (used > cap && u.blocking.length) {
const e = u.blocking.pop();
if (e.blockedBy === u) e.blockedBy = null;
if (e.blockedBy === u) { e.blockedBy = null; b._stealthSwitch(e); } // a released 隐匿 enemy hides again later
used -= e.blockWeight ?? 1;
}
}
+7
View File
@@ -31,6 +31,13 @@ export const ALLY_COLLIDER_RADIUS = 0.25;
*/
export const BLOCK_RADIUS = Object.freeze({ ground: 0.70709997, fly: 0.8944, device: 0.4472 });
export const BLOCK_RADIUS_SQ = Object.freeze({ ground: 0.49999037, fly: 0.79995137, device: 0.4472 * 0.4472 });
/**
* An enemy's 隐匿 after a block ends (s): PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被
* 阻挡的3秒后重新进入隐匿" / §隐匿与Buff的关系 "阻挡状态解除后3s开关重新被开启而恢复隐匿". An enemy page's "(解除阻挡N秒后
* 恢复)" overrides it per 隐匿 source (buff `data.stealthRestore`: content/enemies.js). Battle._stealthSwitch; our
* operators' 隐匿 / 迷彩 are never lifted by blocking ("我方干员并不会因为阻挡而解除隐匿").
*/
export const STEALTH_RESTORE = 3;
/** Default projectile speed in tiles/s for ranged operators/enemies. */
export const PROJECTILE_SPEED = 12;
/**
+57 -12
View File
@@ -57,7 +57,9 @@
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). An operator's radius area
// damage skips an unblocked one too (Battle.foesInRadius: profession splash around a struck target,
// skill circles — PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 it
// still hit it); tile selectors (enemiesInKeys) always skipped it.
// still hit it); tile selectors (enemiesInKeys) always skipped it. After a block it hides again only
// STEALTH_RESTORE (3) s later — or after the "(解除阻挡N秒后恢复)" of its PRTS page (STEALTH_RESTORE_BY_KEY,
// 清明's veil 0 s) — Battle._stealthSwitch / targeting.js enemyStealthed (until 0.1.2: at once).
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
// SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below.
@@ -78,7 +80,7 @@
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../constants.js';
import { canTargetAlly, sortAllyTargets, aggroCmp, evadesGround } from '../targeting.js';
import { canTargetAlly, sortAllyTargets, aggroCmp, evadesGround, enemyStealthed } from '../targeting.js';
import { mitigate, periodicDamage } from '../damage.js';
// ---------------------------------------------------------------------------------------------------------------
@@ -107,6 +109,23 @@ const TANK_ZONE_RADIUS = 1.7;
const POLLUTION_INTERVAL = 1;
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
const ACBUNN_TARGETS = 3;
/**
* 隐匿 that comes back sooner after a block than the general 3 s (PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言…不被阻挡的
* 3秒后重新进入隐匿"; engine STEALTH_RESTORE): the enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" — PRTS text search
* "解除阻挡0秒后恢复" (21 enemies; these 6 are in this mode) and "解除阻挡1秒后恢复" (the two 家族灭迹人), checked
* 2026-10-03. Every other stealthy enemy here has a plain "隐匿" (3 s), the 深池逐火 embers included. 清明 / 堂皇's veil
* ("获得隐匿(解除阻挡0秒后恢复)") is kitInvisShield's.
*/
const STEALTH_RESTORE_BY_KEY = Object.freeze({
enemy_10031_cnvsld: 0, // 业余竞演者
enemy_10034_cnvsax: 0, // 节日爵士乐手
enemy_9008_acbunn: 0, // 假想敌:骨刺
enemy_2034_sythef: 0, // 流泪小子
enemy_2034_sythef_2: 0, // 流泪小子 (its stronger copy)
enemy_1389_winbab_2: 0, // 访问团强攻冠军
enemy_1283_sgkill: 1, // 家族灭迹人
enemy_1283_sgkill_2: 1, // 家族暗影灭迹人
});
/** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */
const ACPUPP_AURA_RADIUS = 1.8;
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */
@@ -572,8 +591,16 @@ const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.t
// ---------------------------------------------------------------------------------------------------------------
// archetypes
/** 隐匿: permanent stealth. */
const stealth = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true }); } });
/**
* 隐匿: permanent stealth. Blocked it is lifted; it hides again STEALTH_RESTORE s after the block ends, or after its own
* "(解除阻挡N秒后恢复)" (STEALTH_RESTORE_BY_KEY → buff `data.stealthRestore`; Battle._stealthSwitch).
*/
const stealth = () => ({
spawn(b, e, a, ab) {
const n = STEALTH_RESTORE_BY_KEY[ab.key];
b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true, data: n != null ? { stealthRestore: n } : {} });
},
});
/** 无法被阻挡. */
const unblockable = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:unblockable', flags: { unblockable: true }, persist: true }); } });
/**
@@ -883,9 +910,10 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) {
* (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) —
* then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every damage instance removes 1 — PRTS 特殊机制
* §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — 隐匿 (`stealthy`: targetable only while
* blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten; drawn solid while blocked)
* and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing
* after `delay` s stands up in its first form with full HP, and every later knock-out starts it again ("一次又一次地站起").
* blocked and for 3 s after a block ends, the block the knock-out itself releases included — targeting.js
* enemyStealthed; drawn solid meanwhile) and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is
* the real death; a husk still standing after `delay` s stands up in its first form with full HP, and every later
* knock-out starts it again ("一次又一次地站起").
* `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields).
* User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it.
* fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands
@@ -923,6 +951,13 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
hitCount(b, e, true);
b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } });
b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } });
// the 重生's 无法阻挡 ends a block at once (as applyStatus does for a status): a warrior knocked out while blocked
// leaves an ember whose 隐匿 is switched off until STEALTH_RESTORE (3) s after that block ended
// (Battle._stealthSwitch) — ~2 s after the 1 s 重生, which stays 无敌 + untargetable — so ranged operators and
// operator splash can finish it although its blocker took the next warrior meanwhile (players after 0.1.1: "the
// stealth monster revives forever"). [ASSUMED] the order: the 重生's cleanse first, then the ember's 隐匿 with the
// block-end switch (PRTS documents neither the order of 重生 vs that switch nor whether it survives the 重生)
b._unblock(e);
if (e.route) e.route.pts = null;
setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay });
b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') { rebirthCooldowns(e); if (onHusk) onHusk(b, e); } }, { owner: e });
@@ -1063,7 +1098,8 @@ const kitStun3 = (ab) => [nthAttackStatus(nthOf(ab.sk.stuncombat), 'stun', (ab.s
const kitSelfFear = (ab) => [selfFear(ab)];
/** 深池逐火战士 / 精锐战士 / 护卫: knock-out ⇒ 1 s 重生 ⇒ a walking, 隐匿, disarmed 余烬 / 火灰 of prop_max_hp hits for `interval` s
* (PRTS 深池逐火战士 天赋: "基础最大生命值临时变为5…具有特殊生命值机制,不进行攻击,获得隐匿、缴械,10s后若未被击倒则变回战士形态并恢复所有
* 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it. */
* 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it — also during the 3 s
* after a block ends, the warrior's own block that the knock-out releases included (Battle._stealthSwitch). */
const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })];
const kitPolluted = (ab) => [{
death(c, b, e) {
@@ -1311,7 +1347,8 @@ function kitInvisShield(ab, e) {
return [...kitDeathSpawn()(ab), skill(s, (b, e2) => {
const dur = s.bb.duration ?? T(ab, 'InvisibleShield.duration') ?? 0;
b.fx('telegraph', { x: e2.x, y: e2.y, r, kind: 'invisShield', id: e2.id });
for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true });
// "获得隐匿(解除阻挡0秒后恢复)" (PRTS 清明 / 堂皇 天赋): the veil's 隐匿 is back as soon as a block ends
for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } });
})];
}
@@ -1331,10 +1368,18 @@ function kitCrossbow(ab) {
}, { 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(), {
blocked(c, b, e, a) { a.power = true; }, // 隐匿效果失效 (blocked) → next attack ×scale
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; },
}];
@@ -1343,7 +1388,7 @@ function kitShadowKiller(ab) {
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) => !!(u.blockedBy || u.s.flags.reveal);
const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block)
return [stealth(), {
spawn(b, e2) { e2.profile.noAttack = true; },
tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击
@@ -1496,7 +1541,7 @@ function kitParasite(ab) {
function kitBoneSpike() {
return [stealth(), {
before(c, b, e) {
if (e.blockedBy || e.s.flags.reveal) return; // 隐匿状态下同时攻击数个目标
if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block)
const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius));
if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS);
},
+7 -4
View File
@@ -8,10 +8,12 @@
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
// items = an ally operator's equipped item ids (absent without any).
// flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it
// is blocked or revealed), an ally's = 隐匿 / 迷彩 whatever it blocks.
// is blocked or revealed, nor in the seconds after a block before it hides again — targeting.js enemyStealthed), an
// ally's = 隐匿 / 迷彩 whatever it blocks.
import { UF, ANIM } from '../../shared/constants.js';
import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js';
import { enemyStealthed } from './targeting.js';
const r2 = (v) => Math.round(v * 100) / 100;
const r1 = (v) => Math.round(v * 10) / 10;
@@ -60,9 +62,10 @@ export function flagsOf(u) {
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
if (f.freeze) bits |= UF.FROZEN;
// 隐匿 or 迷彩 (buffs.js camou), shown the see-through way. An ally keeps it while blocking (targeting.js
// canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed (PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉从而
// 失去隐匿"; targeting.js canTargetEnemy): a blocked 逐火 余烬 is drawn solid while the team beats it
if (u.side === 'enemy' ? (f.stealth && !f.reveal && !u.blockedBy) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH;
// canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed and until it hides again after a block (PRTS
// 作战机制 §隐匿 "在被阻挡时开关会被关掉从而失去隐匿,阻挡状态解除后3s开关重新被开启"; targeting.js enemyStealthed): a
// blocked 逐火 余烬 is drawn solid while the team beats it, and for 3 s after it slips away
if (u.side === 'enemy' ? (f.stealth && enemyStealthed(u)) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH;
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
if (f.invulnerable) bits |= UF.INVULN;
+18 -1
View File
@@ -55,12 +55,29 @@ export function tileKeyOf(u) {
return r * COLS + c;
}
/** Key of the buff that keeps an enemy's 隐匿 source `sourceKey` switched off after a block (Battle._stealthSwitch). */
export const stealthOffKey = (sourceKey) => `stealthOff:${sourceKey}`;
/**
* Is enemy `e`'s 隐匿 in effect — no ally selection picks it, operator splash skips it (Battle.foesInRadius), the b.snap
* stealth bit is set? Not while it is revealed (反隐, flag `reveal`), not while blocked, and not while every 隐匿 source
* it holds is still switched off after its last block (flag `stealthOff`: PRTS 作战机制 §隐匿 "在被我方单位阻挡后会解除隐匿,
* 不被阻挡的3秒后重新进入隐匿" — constants.js STEALTH_RESTORE, or the source's own "(解除阻挡N秒后恢复)").
*/
export function enemyStealthed(e) {
const f = e.s.flags;
if (!f.stealth || f.reveal || e.blockedBy) return false;
if (!f.stealthOff) return true;
for (const b of e.buffs) if (b.flags && b.flags.stealth && !e.findBuff(stealthOffKey(b.key))) return true;
return false;
}
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
const f = e.s.flags;
if (f.untargetable || (f.sleep && !(profile && profile.hitSleep))) return false;
if (f.stealth && !f.reveal && !e.blockedBy) return false;
if (f.stealth && enemyStealthed(e)) return false;
if (e.isFlying && !(profile && profile.canHitFly)) return false;
if (profile && profile.groundOnly && e.isFlying) return false;
return true;
+1 -1
View File
@@ -572,7 +572,7 @@ describe('a blocked 隐匿 enemy is drawn solid (b.snap stealth bit: PRTS 作战
const tuple = () => h.b.snapshot().units.find((t) => t[0] === e.id);
assert.ok(tuple()[7] & UF.STEALTH, 'in b.snap too');
assert.ok(h.runUntil(() => !!e.blockedBy, 10), 'walks into the wall');
assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns once the block ends)');
assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns 3 s after the block ends, §22.8)');
assert.equal(flagsOf(e) & UF.STEALTH, 0, 'blocked: drawn solid');
assert.equal(tuple()[7] & UF.STEALTH, 0);
assert.ok(flagsOf(e) & UF.BLOCKED);