feedback1 integrate: WT 蒂比's 起飞 is 对地规避 - ground enemies no longer select or hit her while airborne

Conflicts resolved keeping both sides:
- buffs.js FLAG_KEYS: liftoff beside the integration's selfBound.
- Battle.js: targeting import union (extendedGrid + evadesGround); _blockerFor keeps the
  fenced-tile gate (WU) and adds liftoff; applyStatus keeps the 无敌 + 无法选中 refusal and
  adds the 对地规避 refusal.
- enemies.js: header DOT entry from WT with the integration's INVISIBLE entry; import union;
  enemy_1273_stmgun_2 keeps the integration's rewrite (it filters through canTargetAlly,
  which now includes liftoff).
- tier2.js 蒂比 comment: the integration's 受到伤害前触发 text plus the 起飞 line.
- tier5.js 隐德来希 血镰: liftoff flag with the integration's foesInRadius.
- SIM.md: blocking keeps the fenced-tile passage and adds air-only blocking while airborne;
  the 污染秽蚀 / 燃烧区域 sentence goes before the chimera aura; statuses keep both refusals.
This commit is contained in:
sganggs committed 2026-10-03 13:36:17 +08:00
commit 987126cc1a
13 files changed
+709 -70

No files matched your search

+9 -4
View File
@@ -28,7 +28,7 @@ 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 } from './targeting.js';
import { absoluteRangeKeys, canTargetEnemy, extendedGrid, evadesGround } from './targeting.js';
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
import { ProjectileSystem } from './projectiles.js';
@@ -1127,12 +1127,15 @@ export class Battle {
* 阻挡'则无法阻挡敌人". The gate reads the tile's ground passability; on the stages the fenced tiles are the only low
* tiles ground units cannot pass. Air blocking (blockFly against flyers) stays [ASSUMED: PRTS restricts the rule to
* 地面阻挡]. Nothing walks onto such a tile, so it matters for an enemy pushed or pulled against the fence
* (Battle.displace stops it at the tile edge, 0.5 from the fenced unit — inside the ground block radius).
* (Battle.displace stops it at the tile edge, 0.5 from the fenced unit — inside the ground block radius). An airborne
* unit (起飞, flag `liftoff`: "不阻挡地面敌人…可以阻挡飞行敌人") blocks flyers only.
*/
_blockerFor(u, e, w) {
if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false;
if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false;
if (!e.isFlying && (!u.ground || this.grid.tile(u.tileR, u.tileC).pass !== 'ALL')) return false;
// ground enemies: only a ground unit that has not taken off (起飞 "不阻挡地面敌人": flag `liftoff`), standing on a tile
// ground units can pass (not a fenced 围墙 / 围栏 tile)
if (!e.isFlying && (!u.ground || u.s.flags.liftoff || this.grid.tile(u.tileR, u.tileC).pass !== 'ALL')) return false;
const cap = u.s.blockCnt;
if (cap <= 0) return false;
let used = 0;
@@ -1291,12 +1294,14 @@ export class Battle {
* `statusApplied` reports the final duration and `entered` (the target did not carry the status before).
* A unit that is 无敌 and 无法选中 at once (a 重生 in progress, a hovering or 永久无敌 leader part) takes no status from
* the other side, `force` included — PRTS 无敌 "无法被不同阵营选中": so a status carried by the very hit that knocked an
* enemy out does not land after its 重生's cleanse (DESIGN §21.4).
* enemy out does not land after its 重生's cleanse (DESIGN §21.4). A ground enemy's status never lands on an airborne
* 起飞 ally (对地规避: targeting.js evadesGround) unless `opts.ignoreSelect`.
*/
applyStatus(target, key, opts = {}) {
if (!target || !target.alive) return false;
const src = opts.source;
if (src && src.side && src.side !== target.side && target.s.flags.invulnerable && target.s.flags.untargetable) return false;
if (!opts.ignoreSelect && target.s.flags.liftoff && evadesGround(src, target)) return false;
const tpl = STATUS[key] || { flags: { [key]: true } };
let duration = opts.duration == null ? Infinity : Number(opts.duration);
let value = opts.value;
+6 -2
View File
@@ -28,11 +28,15 @@ export const MUL_KEYS = Object.freeze([
'trueTakenMul', 'elemTakenMul', 'elementalTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
'atkScaleMul', 'physDealtMul', 'artsDealtMul',
]);
/** Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. */
/**
* Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. `liftoff` = 起飞 of an ally (蒂比's skills): blocks
* no ground enemy (Battle._blockerFor), 对地规避 against ground enemies (targeting.js evadesGround); it stays a ground
* unit on its tile (`unit.ground` unchanged).
*/
export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou', 'liftoff',
// 自缚 (the unit's own immobility: 守墓石像's 转换模式, the 自缚 leaders) beside its `noMove` — 束缚 sets noMove too, and
// only 自缚 makes a unit "不视为可达目标" for 余 S2's teleport (PRTS 余 S2 备注)
'selfBound',
+20 -10
View File
@@ -53,12 +53,16 @@
// The huge leaders (SELF_BOUND: 胄 ×2, 管 ×2, 昆图斯, 阿利斯泰尔, 萨米的意志 — the 巨型单位 with a data `hitArea`) are
// 自缚 + 无法被阻挡 (PRTS 天赋): a persistent noMove + unblockable buff from spawn, so they never walk their route.
// Every leader (tag boss) ignores 侵蚀 gauge damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
// An airborne (起飞) operator is no selection of a ground leader or part (对地规避: canTargetAlly, the damage pipeline,
// the 碎铳之簧 bounce); still reach it (`ignoreSelect`): the 刺胄之弹 / 剑 / 锤 blasts (flying units, 无来源 DoT; 掷剑 /
// 掷锤 pick their operator "(无视无法选择)"), the 盲信之誓 chains ("无视无法选择"), the 法术护盾 counter on its attacker
// (a direct pick) and the ticks of a debuff already on it (【自然涌动】: a tick selects nobody).
// LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner).
// fx kinds: 'beam' 'shell' 'explode' 'telegraph' 'charge' 'link' 'dash' 'column' 'tide' 'rockfall' 'tentacle' 'equip'
// 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}).
import { MOVE_SCALE } from '../constants.js';
import { canTargetAlly, aggroCmp } from '../targeting.js';
import { canTargetAlly, aggroCmp, evadesGround } from '../targeting.js';
import { compileRoute } from '../ai.js';
import { normalizeRoute } from '../simdata.js';
import {
@@ -320,13 +324,17 @@ export function fairOrder(b, e, list, P) {
return out.concat(rest);
}
/** Stun + phys DoT on the 3×3 around (r, c). */
/**
* Stun + phys DoT on the 3×3 around (r, c), credited to `src` (胄). The blast is a flying unit's — 刺胄之弹 / 斩胄之剑 /
* 破胄之锤 (PRTS 行动方式 飞行) — and its damage 无来源, so 对地规避 does not stop it: `ignoreSelect` (an airborne 起飞 ally in
* the 3×3 is stunned and hurt like the others, although the credited 胄 walks).
*/
function stunBlast(b, src, r, c, stun, dot, dur, kind) {
b.fx('explode', { x: c, y: r, r: 1.5, kind, tiles: 'box' });
for (const u of alliesInTiles(b, r, c, 'box', 1)) {
if (stun > 0) b.applyStatus(u, 'stun', { duration: stun, source: src });
if (stun > 0) b.applyStatus(u, 'stun', { duration: stun, source: src, ignoreSelect: true });
if (dot > 0 && dur > 0) b.addBuff(u, { key: `boss:${kind}Dot`, duration: dur, refresh: 'replace', interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(src, unit, { amount: dot, type: 'phys', canDodge: false, tags: ['enemyAbility', kind] }) });
onTick: ({ battle, unit }) => battle.dealDamage(src, unit, { amount: dot, type: 'phys', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', kind] }) });
}
}
/** Distance from point p to segment a–b. */
@@ -587,11 +595,11 @@ function kitGun(ab, e, b) {
});
if (s3) list.push({
iv: s3.bb.interval ?? 1,
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line ("无视无法选择、迷彩": 起飞 too)
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp)) continue;
b2.fx('link', { x: e2.x, y: e2.y, from: e2.id, to: sp.id, kind: 'faithLink', dur: s3.bb.interval ?? 1 });
for (const u of b2.allies()) if (segDist(u.x, u.y, e2.x, e2.y, sp.x, sp.y) <= LINK_WIDTH) hurt(b2, e2, u, s3.bb.value ?? 0, 'phys');
for (const u of b2.allies()) if (segDist(u.x, u.y, e2.x, e2.y, sp.x, sp.y) <= LINK_WIDTH) hurt(b2, e2, u, s3.bb.value ?? 0, 'phys', { ignoreSelect: true, tags: ['faithLink'] });
}
},
});
@@ -636,8 +644,10 @@ function kitSpring(ab, e) {
if (P.barrier <= 1e-6) { P.barrier = 0; drop(b); }
} else if (ty === 'phys') {
c.dmg.mul *= scale;
const s = c.source;
if (s && s.side === 'ally' && s.alive) { hurt(b, e2, s, e2.s.atk * (T(ab, '1.atk_scale') ?? 0), 'phys'); elem(b, e2, s, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }
// the counter "对来源造成…无来源物理附加伤害" picks its attacker directly — no selection, so an airborne 起飞
// attacker takes it too (PRTS 异常效果 无法选择: "'直接选中'的能力…不受这些仅在选择时生效的异常效果制约")
const s = c.source, o = { ignoreSelect: true, tags: ['springCounter'] };
if (s && s.side === 'ally' && s.alive) { hurt(b, e2, s, e2.s.atk * (T(ab, '1.atk_scale') ?? 0), 'phys', o); elem(b, e2, s, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0), o); }
}
} else if (kind === 'element') { // 元素护盾: phys/arts heavily reduced
if (ty === 'phys' || ty === 'arts') c.dmg.mul *= scale;
@@ -687,7 +697,7 @@ function kitSpring(ab, e) {
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k));
const prev = t;
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u) && !evadesGround(e2, u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
}
} else { // 十连击
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
@@ -1066,7 +1076,7 @@ function kitDeer(ab, e) {
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'naturalSurge', dur });
b.applyStatus(t, 'stun', { duration: dur, source: e2 });
b.addBuff(t, { key: 'boss:surge', duration: dur, interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: e2.s.atk * (lasso.bb.atk_scale ?? 0), type: 'arts', canDodge: false, tags: ['enemyAbility'] }) });
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: e2.s.atk * (lasso.bb.atk_scale ?? 0), type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) });
}
},
},
+62 -27
View File
@@ -33,7 +33,15 @@
// keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the
// enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a
// special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed
// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值).
// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值). An airborne ally (起飞, flag `liftoff`) is no target of a
// ground enemy (对地规避, targeting.js evadesGround): targetsNear / allTargets skip it through canTargetAlly, the picks
// and one-shot areas that bypass it (周围四格 additions, chain and bounce jumps, shells, barrages, 沙狱, death blasts)
// filter it, and the engine refuses a ground enemy's damage and statuses on it. Not selections, so they still reach
// it (`ignoreSelect`): abilities PRTS marks "无视无法选择" (【污染秽蚀】, 【盲信之誓】, 萨卡兹悖谬暴虐兵长's 暴击 splash),
// direct picks of the attacker (碎铳之簧's counter — PRTS 异常效果 "'直接选中'的能力…不受这些仅在选择时生效的异常效果
// 制约"), the blasts of flying units credited to a ground leader (刺胄之弹, 斩胄之剑 / 破胄之锤) and the ticks of a debuff
// already on it (出血, 沙狱, “庞贝”'s burning, 淤困, 【自然涌动】 — a tick selects nobody). Auras of ground enemies still
// apply [ASSUMED].
//
// Special types (factions.json):
// FLY — engine (FLY motion, ranged-only targeting). Flyer kits below (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …).
@@ -43,8 +51,9 @@
// create those units (death spawns, embers, blades, 再生).
// ELEMENT — element damage on hit = ATK × ep_damage_ratio into the ally's gauge. 侵蚀 (erosion) is an engine
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
// bleeding (removed by healing), pulsing auras.
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit, "可对空,无视无法选择"
// — `ignoreSelect`, 起飞 allies included; 毒雾, 燃烧区域: a ground enemy's zone skips a 起飞 ally — PRTS
// 集团军重型火炮 "碰撞不受迷彩制约,不可对空", no 无视无法选择), bleeding (removed by healing), pulsing auras.
// 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
@@ -69,7 +78,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 } from '../targeting.js';
import { canTargetAlly, sortAllyTargets, aggroCmp, evadesGround } from '../targeting.js';
import { mitigate, periodicDamage } from '../damage.js';
// ---------------------------------------------------------------------------------------------------------------
@@ -363,17 +372,21 @@ function onTick(b, dt) {
// ---------------------------------------------------------------------------------------------------------------
// helpers (exported for bosses.js)
/** Element damage (erosion mapped onto the engine gauge). */
export function elem(b, src, tgt, el, amount) {
/** Element damage (erosion mapped onto the engine gauge); `ignoreSelect` / `tags` as hurt(). */
export function elem(b, src, tgt, el, amount, { ignoreSelect = false, tags = [] } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
const element = el === 'erosion' ? EROSION : el;
return b.dealDamage(src, tgt, { type: 'element', element, amount, tags: ['enemyAbility'] });
return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/** Skill / ability damage (no dodge unless asked). */
export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true } = {}) {
/**
* Skill / ability damage (no dodge unless asked). `ignoreSelect` = no selection the target's 无法选择 effects stop
* (abilities that "无视无法选择", direct picks, a flying unit's blast credited to a ground leader): it also reaches an
* airborne 起飞 ally, which a ground enemy's damage otherwise skips (damage.js, targeting.js evadesGround).
*/
export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true, ignoreSelect = false } = {}) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, tags: ['enemyAbility', ...tags] });
return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, ignoreSelect, tags: ['enemyAbility', ...tags] });
}
/** Allies whose tile is within `n` of (r, c): 'plus' = Manhattan (周围四格), 'box' = Chebyshev (周围8格). */
@@ -960,7 +973,10 @@ function statue(ab) {
};
}
/** Bleeding on hit (逐腐兽): arts damage per second, removed by healing. */
/**
* Bleeding on hit (逐腐兽): arts damage per second, removed by healing. A tick selects nobody (`ignoreSelect`): it keeps
* hurting an ally that took off (起飞) after the bleed landed; so do the other enemy debuff DoTs (沙狱, burnDot, 淤困).
*/
function bleed(ab) {
const dmg = T(ab, 'Bleeding.attack@bleeding_damage') ?? 0, dur = T(ab, 'Bleeding.attack@duration') ?? 0;
return {
@@ -968,7 +984,7 @@ function bleed(ab) {
if (!(dmg > 0 && dur > 0)) return;
b.addBuff(c.target, {
key: 'ab:bleed', duration: dur, refresh: 'replace', interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(e, unit, { amount: dmg, type: 'arts', canDodge: false, tags: ['enemyAbility', 'bleed'] }),
onTick: ({ battle, unit }) => battle.dealDamage(e, unit, { amount: dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'bleed'] }),
});
},
};
@@ -977,8 +993,8 @@ function bleed(ab) {
/**
* 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害
* (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every
* ally inside (flyers, stealthed and untargetable ones included); a unit inside several zones takes one tick per second
* (`mem.pollutedAt`: the last tick it took).
* ally inside (flyers, stealthed, untargetable and airborne 起飞 ones included — `ignoreSelect`; an airborne 蒂比 stands on
* her low tile: `low`); a unit inside several zones takes one tick per second (`mem.pollutedAt`: the last tick it took).
*/
function pollution(b, src, x, y, r, life, low, high) {
zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) {
@@ -986,15 +1002,22 @@ function pollution(b, src, x, y, r, life, low, high) {
const v = u.ground ? low : high;
if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue;
u.mem.pollutedAt = b.time;
hurt(b, src, u, v, 'true', { tags: ['pollution'] });
hurt(b, src, u, v, 'true', { tags: ['pollution'], ignoreSelect: true });
}
} });
}
/** Damage zone (arts per tick). */
/**
* Damage zone (`amount` per tick, tagged `kind`) on every ally inside. Unlike 【污染秽蚀】 it does not ignore 无法选择:
* a ground enemy's zone skips an airborne 起飞 ally (the damage pipeline; PRTS 集团军重型火炮 【燃烧区域】 "碰撞不受迷彩
* 制约,不可对空" — 迷彩 only). A sourceless zone (假想敌:蚀裂's 毒雾) reaches everyone inside.
*/
function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0) {
zone(b, { x, y, r, life, iv, kind, tick(units) {
for (const u of units) { hurt(b, src, u, amount, type); if (el && elAmount > 0) elem(b, src, u, el, elAmount); }
for (const u of units) {
hurt(b, src, u, amount, type, { tags: [kind] });
if (el && elAmount > 0) elem(b, src, u, el, elAmount, { tags: [kind] });
}
} });
}
@@ -1149,7 +1172,7 @@ function kitTidmag(ab) {
const t0 = c.targets[0];
if (!t0) return;
const l = c.targets.slice();
for (const u of alliesInTiles(b, t0.tileR, t0.tileC, 'plus', 1)) if (!l.includes(u)) l.push(u);
for (const u of alliesInTiles(b, t0.tileR, t0.tileC, 'plus', 1)) if (!l.includes(u) && !evadesGround(e, u)) l.push(u);
c.targets = l;
},
}];
@@ -1395,6 +1418,11 @@ function kitRush(ab) {
}];
}
/**
* 萨卡兹悖谬暴虐兵长: blocked only by a blocker with ≥ 3 free block; its first hit also strikes the units around its target
* (PRTS 技能0 暴击 "对目标和周围4格的我方单位造成攻击力150%的物理普通伤害(无视无法选择,无视迷彩)※此技能仅能触发一次"):
* the splash ignores 无法选择, so it reaches an airborne 起飞 ally too (`ignoreSelect`).
*/
function kitFirstAoe(ab) {
const scale = T(ab, 'AOEAttack.atk_scale') ?? 0;
return [blockWeight(3), {
@@ -1402,7 +1430,9 @@ function kitFirstAoe(ab) {
if (a.done) return;
a.done = true;
b.fx('explode', { x: c.target.x, y: c.target.y, r: 1, kind: 'aoeAttack' });
for (const u of b.alliesInRadius(c.target.x, c.target.y, 1)) if (u !== c.target) hurt(b, e, u, e.s.atk * scale, 'phys');
for (const u of b.alliesInRadius(c.target.x, c.target.y, 1)) {
if (u !== c.target) hurt(b, e, u, e.s.atk * scale, 'phys', { ignoreSelect: true, tags: ['aoeAttack'] });
}
},
}];
}
@@ -1456,7 +1486,7 @@ function kitParasite(ab) {
b.addBuff(host, {
// "受到的元素损伤提高至130%": a 元素损伤 multiplier on the element hit (ensureParasiteHook), not 元素伤害
key: 'ab:parasite', visible: true, interval: 1, data: { src: e, spread: PARASITE_SPREAD, epMul: elMul },
onTick: ({ battle, unit }) => { if (e.alive) battle.dealDamage(e, unit, { amount: e.s.atk * scale, type: 'arts', canDodge: false, tags: ['enemyAbility', 'parasite'] }); },
onTick: ({ battle, unit }) => { if (e.alive) battle.dealDamage(e, unit, { amount: e.s.atk * scale, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'parasite'] }); },
});
},
death(c, b, e, a) { if (a.host) b.removeBuff(a.host, 'ab:parasite'); },
@@ -1719,12 +1749,14 @@ function kitTeapot(ab) {
if (c.reason !== 'killed') return;
const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0, iv = T(ab, 'DeadBoom.interval') ?? 1;
const r = e.base.rangeRadius || 2, atk = e.s.atk, x = e.x, y = e.y;
// a selection (PRTS 烹泉 "死亡爆炸(…无视迷彩,不可对空)", no 无视无法选择): an airborne 起飞 ally is skipped
const hit = (units) => units.filter((u) => !evadesGround(e, u));
// 被击倒后爆炸造成范围法术伤害 (one blast of ATK ×1 [ASSUMED scale]) …
b.fx('explode', { x, y, r, kind: 'teaBoom', id: e.id });
for (const u of b.alliesInRadius(x, y, r)) hurt(b, null, u, atk, 'arts');
for (const u of hit(b.alliesInRadius(x, y, r))) hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] });
// … 使我方攻击速度大幅降低(可被抵抗): the steam lingers `duration` s; allies inside keep the ASPD cut (re-applied
// every `interval` s)
const slow = (units) => { for (const u of units) b.addBuff(u, { key: 'ab:teaBoom', duration: iv + 0.1, refresh: 'extend', mods: { aspd }, visible: true }); };
const slow = (units) => { for (const u of hit(units)) b.addBuff(u, { key: 'ab:teaBoom', duration: iv + 0.1, refresh: 'extend', mods: { aspd }, visible: true }); };
slow(b.alliesInRadius(x, y, r));
if (dur > 0) zone(b, { x, y, r, life: dur, iv, kind: 'teaSteam', tick: slow });
},
@@ -1747,7 +1779,7 @@ function kitLeaderMisc(key, ab, e) {
dealt(c, b, e2) {
if (!(dot.dur > 0)) return;
b.addBuff(c.target, { key: 'ab:burnDot', duration: dot.dur, refresh: 'replace', interval: dot.iv, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: dot.dmg, type: 'arts', canDodge: false, tags: ['enemyAbility'] }) });
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: dot.dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) });
},
}, {
iv: T(ab, 'rangedamage.interval') ?? 10,
@@ -1802,9 +1834,11 @@ function kitLeaderMisc(key, ab, e) {
if (!t) return;
const dur = ss.bb.duration ?? 0, x = t.x, y = t.y;
b.fx('zone', { x, y, r: SANDSTORM_RADIUS, dur, kind: 'sandStorm', id: e2.id });
// the 沙狱弹道 "击中…范围内的所有我方单位(弹道可对空)" selects: an airborne 起飞 ally is skipped (no 无视无法选择)
for (const u of b.alliesInRadius(x, y, SANDSTORM_RADIUS)) {
if (evadesGround(e2, u)) continue;
b.addBuff(u, { key: 'ab:sandStorm', duration: dur, refresh: 'replace', interval: 1, visible: true, mods: { atkPct: ss.bb.atk ?? 0 },
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: ss.bb.damage ?? 0, type: 'arts', canDodge: false, tags: ['enemyAbility', 'sandStorm'] }) });
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: ss.bb.damage ?? 0, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'sandStorm'] }) });
}
}, { cond: (b, e2) => allTargets(b, e2).length > 0 }),
];
@@ -1822,7 +1856,7 @@ function kitLeaderMisc(key, ab, e) {
case 'enemy_1513_dekght': {
// 攻击时使目标与周围四格的单位受到物理伤害; 【蓄力攻击】 charge `duration` s, then ATK×atk_scale on the target's cross
const s = ab.sk.ChargeAttack;
return [kitDekght(ab), { before(c, b, e2) { const t = c.targets[0]; if (t) for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) if (!c.targets.includes(u)) c.targets.push(u); } },
return [kitDekght(ab), { before(c, b, e2) { const t = c.targets[0]; if (t) for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) if (!c.targets.includes(u) && !evadesGround(e2, u)) c.targets.push(u); } },
skill(s, (b, e2) => {
const t = e2.blockedBy;
const dur = s.bb.duration ?? 0, r = t.tileR, cc = t.tileC;
@@ -1938,7 +1972,7 @@ function kitLeaderMisc(key, ab, e) {
let prev = c.target;
const hit = new Set([prev]);
for (let k = 1; k < n; k++) {
const nx = b.alliesInRadius(prev.x, prev.y, jr).find((u) => !hit.has(u));
const nx = b.alliesInRadius(prev.x, prev.y, jr).find((u) => !hit.has(u) && !evadesGround(e2, u));
if (!nx) break;
hit.add(nx);
hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts');
@@ -2586,7 +2620,8 @@ export const KITS = Object.freeze({
if (!lock) return;
b.fx('telegraph', { x: lock.x, y: lock.y, r: 1, kind: 'barrage', id: e.id });
for (let i = 0; i < n; i++) {
const t = alliesInTiles(b, lock.tileR, lock.tileC, 'box', 1).sort((p, q) => q.hp - p.hp)[0];
// "对碰撞范围内的1名当前生命值最高的我方单位" — a selection: never an airborne 起飞 ally (PRTS “帝国的甲胄”)
const t = alliesInTiles(b, lock.tileR, lock.tileC, 'box', 1).filter((u) => !evadesGround(e, u)).sort((p, q) => q.hp - p.hp)[0];
if (t) hurt(b, e, t, e.s.atk, 'phys');
}
}, { owner: e });
+14 -8
View File
@@ -35,17 +35,21 @@ function onDefaultSkill(chess) {
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
}
/** 蒂比 take-off (起飞): airborne — blocks flyers only (flags.blockFly of the skill), releases ground enemies. */
/**
* 起飞 of 蒂比's skills (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人"): the skill's flags
* `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects
* her, while what selects nobody still lands — 无视无法选择 abilities, direct picks, flying units' blasts, 无来源 damage, the
* ticks of a debuff already on her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的
* 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰, 地面干员 bonds / items still count her).
*/
const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true });
/** 蒂比 take-off: the ground enemies she blocked walk on. */
function tippiTakeOff({ battle, unit }) {
unit.mem.tippiGround = unit.ground;
unit.ground = false;
battle.releaseBlocked(unit);
battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id });
}
/** 蒂比 landing when the airborne skill ends. */
/** 蒂比 landing when the airborne skill ends: the flyers she held are released. */
function tippiLand({ battle, unit }) {
if (unit.mem.tippiGround != null) unit.ground = unit.mem.tippiGround;
unit.mem.tippiGround = null;
battle.releaseBlocked(unit);
}
@@ -601,6 +605,8 @@ export default {
// every damage instance restarts that timer (dodged or not; a 流失 does not — PRTS 备注).
// S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration — skill range, ATK +atk, blocks
// flyers only; no triple shot, and incoming attacks never set it off.
// Both take off as 起飞 (LIFTOFF_FLAGS): she blocks no ground enemy and none selects her (对地规避), so a ground enemy's
// selected damage refused on the way never reaches her `hit` and neither sets off S2 nor restarts 片场工作指南.
chess_char_2_13_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const s2 = onDefaultSkill(chess);
@@ -608,7 +614,7 @@ export default {
trait: { blockFly: false },
skill: {
kind: 'duration', trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never by the engine: the hit handler below
mods: { atkPct: num(bb.atk) }, flags: { blockFly: true },
mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS,
targeting: { rangeGrid: def?.skill?.rangeGrid ?? null },
attack: { hits: 3 },
onStart: tippiTakeOff,
@@ -616,7 +622,7 @@ export default {
},
skills: {
skchr_tippi_1: {
kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: { blockFly: true },
kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: LIFTOFF_FLAGS,
targeting: { rangeGrid: def?.skill?.rangeGrid ?? null },
onStart: tippiTakeOff,
onEnd: tippiLand,
+2 -2
View File
@@ -915,8 +915,8 @@ const KITS = {
for (const a of unit.mem.sickles || []) {
if (!a.alive || !a.deployed) continue;
// PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空);
// 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly)
const air = !a.ground && !!a.s.flags.blockFly;
// 起飞 = an airborne skywalker (蒂比's skills: flag `liftoff`; still a 地面单位, so she can carry one)
const air = !!a.s.flags.liftoff;
for (const e of battle.foesInRadius(a.x, a.y, RING1)) {
if (e.isFlying && !air) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] });
+22 -2
View File
@@ -1,6 +1,7 @@
// server/sim/damage.js — damage & heal pipeline, shields, dodge, element gauges (DESIGN §5.5).
//
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
// dealDamage order: (element → gauge path) | invulnerable? / 对地规避 (a ground enemy's damage to an airborne 起飞 ally —
// targeting.js evadesGround; not `ignoreSelect` damage: no selection, e.g. a debuff's tick) → 'hit' hook (mutable DamageInfo, may set cancel)
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
@@ -49,6 +50,7 @@
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
import { BOSS_HIT_LIMIT } from '../../shared/constants.js';
import { evadesGround } from './targeting.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
@@ -84,6 +86,10 @@ export function makeDamageInfo(d = {}) {
cancel: false,
noSp: !!d.noSp,
ignoreSleep: !!d.ignoreSleep,
// no selection 无法选择 effects stop (an ability that "无视无法选择" such as PRTS 【污染秽蚀】, a direct pick, a flying
// unit's blast credited to a ground leader, the tick of a debuff already on the unit): reaches an airborne 起飞 ally
// whatever the source's 行动方式
ignoreSelect: !!d.ignoreSelect,
sourceless: !!d.sourceless,
attackId: d.attackId ?? 0,
};
@@ -115,6 +121,19 @@ function sleepBlocks(target, source, dmg) {
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
}
/**
* 起飞 (flag `liftoff`): a ground enemy's damage never reaches the airborne ally — it cannot select it (对地规避, PRTS 作战机制
* "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"), so its splash, area abilities and element fills skip it, and a shot
* already in flight when it took off lands on nothing [ASSUMED: PRTS 伤害流程 7 "取消掉隐匿/无敌状态下的攻击" read for
* 对地规避]. Checked before the `hit` hook only: 蒂比 S2 takes off inside the hook of the hit that set it off, which then
* resolves as usual (dodged if physical / arts — PRTS 备注). Sourceless damage and `ignoreSelect` damage still land: no
* selection (targeting.js evadesGround) — 无视无法选择 abilities, direct picks, flying units' blasts, and the ticks of a
* debuff already on it (PRTS 异常效果: 无法选择 effects "仅在选择时生效"; the debuff's mods stay too).
*/
function liftoffEvades(target, source, dmg) {
return !!target.s.flags.liftoff && !dmg.ignoreSelect && evadesGround(source, target);
}
/**
* Pure mitigation formula (exported for tests). `target` = the target's stats; `ign.elementalRes` = its 元素抗性
* (epDamageResistance) for 'elemental' damage.
@@ -201,7 +220,7 @@ export function dealDamage(battle, source, target, dmgIn) {
// gets the stats and the kill (PRTS 伤害分类 无来源 ③)
const hs = dmg.sourceless ? null : source;
let ts = target.s;
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg) || liftoffEvades(target, hs, dmg)) return 0;
if (battle._hooks.hit) {
battle.emit('hit', { source: hs, target, dmg, credit: source });
if (dmg.cancel || !target.alive || !target.deployed) return 0;
@@ -360,6 +379,7 @@ export function applyElement(battle, source, target, dmg) {
if (!el || !(el in target.elem)) return 0;
if (!hasHp(target)) return 0; // a killing blow's rider: the target is dead, nothing fills or bursts (header)
if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (liftoffEvades(target, dmg.sourceless ? null : source, dmg)) return 0;
if (burstLocked(target, el)) return 0;
if (battle._hooks.elementHit) {
battle.emit('elementHit', { source, target, dmg });
+24 -2
View File
@@ -10,7 +10,7 @@
// or not — ai.js acquireTargets), (2) profile priority (fly/lowDef/…),
// (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units
// (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy
// priority: sortAllyTargets.
// priority: sortAllyTargets. 起飞 (an ally's flag `liftoff`) = 对地规避: no ground enemy selects it (evadesGround).
import { COLS, ROWS } from './constants.js';
import { normDir, rotateOffset } from './dir.js';
@@ -73,16 +73,38 @@ export function canTargetEnemy(attacker, e, profile) {
* "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and
* off the enemy paths — PRTS). 迷彩 (flag `camou`, term ba.camou "不阻挡时不成为敌方普通攻击的目标") works the same way:
* PRTS 异常效果 gives both anomalies the note "与'阻挡时解除'没有直接关系" [ASSUMED: enemy skills and splash selectors
* treat 迷彩 like target selection — officially splash and selectors without a projectile ignore it].
* treat 迷彩 like target selection — officially splash and selectors without a projectile ignore it]. An airborne ally
* (起飞, flag `liftoff`) is never a target of a ground enemy (evadesGround).
*/
export function canTargetAlly(e, a, ranged) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
const f = a.s.flags;
if (f.untargetable || f.sleep) return false;
if (ranged && (f.stealth || f.camou) && e.blockedBy !== a) return false;
if (f.liftoff && evadesGround(e, a)) return false;
return true;
}
/**
* 对地规避 of an airborne ally (起飞, buff flag `liftoff` — 蒂比's skills): true when `src` is an enemy whose 行动方式 is
* ground, which then cannot select `a` (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人";
* PRTS 术语释义 起飞 "包含对地规避(无法被不同阵营行动方式为地面的单位选中)"; PRTS 异常效果 MOTION_TARGET_FREE, a 无法选择
* effect that a selector whose 行动方式 is not ground ignores). Flyers, 近地悬浮 and 浮空 enemies (Unit.isFlying — PRTS
* 行动方式: a hovering unit "是真正的飞行单位", a levitated one is seen as a flyer) still select it; it stays a ground unit
* itself ("起飞的干员仍然是地面单位"), so they need no 对空 check. Used by every enemy selection (canTargetAlly), the damage
* pipeline and Battle.applyStatus (a ground enemy's area damage, statuses and hits under way skip it — PRTS 作战机制 "AOE
* 的判定是对攻击范围内的每个可以被选中的敌人进行判定"), and the content picks and one-shot areas that bypass canTargetAlly
* (chain / bounce jumps, 周围四格 additions, shells, barrages, 沙狱, death blasts). Not selections, so they still reach it:
* sourceless damage and DamageInfo / applyStatus `ignoreSelect` — abilities that "无视无法选择" (【污染秽蚀】, 【盲信之誓】,
* 萨卡兹悖谬暴虐兵长's 暴击 splash), direct picks (碎铳之簧's counter on its attacker: PRTS 异常效果 "'直接选中'的能力…不受这些
* 仅在选择时生效的异常效果制约"), the blasts of flying units credited to a ground leader (刺胄之弹, 斩胄之剑 / 破胄之锤) and
* the ticks of a debuff a ground enemy put on it before it took off (出血, 沙狱, burning DoTs, 【自然涌动】: a tick selects
* nobody). A shot already in flight is refused [ASSUMED] (damage.js).
*/
export function evadesGround(src, a) {
return !!(a && a.s && a.s.flags.liftoff && src && src.side === 'enemy' && !src.isFlying);
}
const PRIORITY_FNS = {
fly: (e) => (e.isFlying ? 0 : 1),
lowDef: (e) => e.s.def,