feedback7: merge fb7-unbalance — the official 失衡 (UNBALANCE) after a push / pull (the idea of PR #392 by @xcdoge, re-implemented from PRTS): no walking, attacking or skills while it lasts; pushes by the 推力-位移 time table, pulls by the force window (0.5 / 1 s), static bodies 0.1 s pushed / the window pulled; 弧光锋卫 bleeds 400 / 0.066 s while unbalanced; the client slide uses the same time

- The owner asked to verify the official rule before deciding (research: PRTS 失衡位移机制 / 推与拉 / 游戏数据基础 / 特殊机制,
  the client's Rigidbody2D data); the PR's 0.6387·√tiles formula and fear exemption were not taken.
- Grok review: MERGE AFTER FIXES — two HIGH (static pulls, 弧光锋卫's bleed), skills during the hold, husk 重生, exact
  frames — fixed on the branch (1db84d79).
- Golden union: bond-miraShip-low differs from HEAD (fewer enemy attacks: 169 → 166 events, same kills) though the
  branch alone left it — HEAD's ×1.1 练度 path now displaces an enemy there. Broad node --test 5681 (5679 pass, 0 fail,
  2 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 22:47:33 +08:00
commit 0ae2fac2b1
27 files changed
+1452 -1058

No files matched your search

+14 -7
View File
@@ -537,13 +537,17 @@ export function updateEnemy(b, e, dt) {
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
if (stunned || e.hidden || e.s.flags.fear) b._cutAttackStand(e);
// 失衡 (UNBALANCE, battle/displacement.js _unbalance): a state machine, not a status — 浮空 ends it (PRTS 术语释义 浮空
// 「触发浮空时清除受到的推/拉力…无法陷入失衡」); while it lasts the enemy neither walks nor starts a normal attack
if (e.s.flags.levitate && e.unbalanceUntil > b.time) e.unbalanceUntil = -Infinity;
const unbalanced = b.time < e.unbalanceUntil - 1e-9; // (the 1e-9: a float-summed end such as 35/30 s keeps its frame)
const prevCd = e.atkCd;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
// a stun / freeze / sleep / 浮空 — or leaving the field — takes the enemy out of its attack: a swing short of its damage
// frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden)) e.swing = false;
// a stun / freeze / sleep / 浮空 / 失衡 — or leaving the field — takes the enemy out of its attack: a swing short of its
// damage frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden || unbalanced)) e.swing = false;
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
const winding = !e.hidden && !stunned && enemyAttack(b, e, prevCd);
const winding = !e.hidden && !stunned && !unbalanced && enemyAttack(b, e, prevCd);
if (!e.alive) return;
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]. 沉睡 also holds 不可阻挡
// (PRTS 异常效果 SLEEPING = 无法行动+无敌+不可阻挡): a sleeper's blocker lets go — its swing was cut above; Battle.applyStatus
@@ -572,12 +576,13 @@ export function updateEnemy(b, e, dt) {
if (b.time < e.pauseUntil) return;
// standing for an attack clip (attackStand, GitHub #58): only the walking waits — a checkpoint's WAIT keeps running
// and DISAPPEAR / APPEAR legs still happen (advanceRoute); drawn idle (the client plays the clip, then Move again);
// a 恐惧 runs at once (it cannot attack)
const standing = winding || (b.time < e.atkStandUntil && !e.s.flags.fear);
// a 恐惧 runs at once (it cannot attack). 失衡 holds the walking too — under 恐惧 / 诱导 as well (失衡免疫 says 「失衡期间
// 无法自主移动」; no source exempts a fear)
const standing = winding || unbalanced || (b.time < e.atkStandUntil && !e.s.flags.fear);
if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; }
if (e.s.flags.fear && !e.hidden) { if (unbalanced) e.moving = false; else moveFeared(b, e, dt); return; }
if (e.mem.fearMove) endFear(e);
// 诱导 (ba.attract "无法被阻挡并向目标位置移动"): walks to the attract point instead of following its route
if (e.s.flags.attract) { if (standing) e.moving = false; else moveAttracted(b, e, dt); return; }
@@ -658,6 +663,8 @@ function advanceRoute(b, e, dt, R, standing = false) {
if (leg.t === 'appear') {
e.x = leg.c; e.y = leg.r;
b._setHidden(e, false);
// a relocation is a forced state switch: it ends a 失衡 (PRTS 失衡位移机制 「被强制切换至其他状态机」)
e.unbalanceUntil = -Infinity;
R.legIdx++; R.pts = null;
continue;
}
+70 -12
View File
@@ -2,7 +2,10 @@
// directional), pull, the raw mover displace and who can be moved at all.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST } from '../constants.js';
import {
COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST,
PUSH_UNBALANCE, PULL_UNBALANCE, PULL_UNBALANCE_WEAK, UNBALANCE_MIN,
} from '../constants.js';
import { fin } from './util.js';
import { hypot } from '../detmath.js';
@@ -16,6 +19,18 @@ export function pushTiles(level, effect = false) {
return (effect ? PUSH_TILES_EFFECT : PUSH_TILES)[Math.min(3, l)];
}
/** 失衡 (UNBALANCE) of a push of 受力等级 `level`, game seconds (constants.js PUSH_UNBALANCE: ≤ −3 → 0, ≥ 3 → the 3 value). */
export function pushUnbalance(level) {
const l = Math.round(fin(level, -99));
return l <= -3 ? 0 : PUSH_UNBALANCE[Math.min(3, l)];
}
/** 失衡 of a pull of 受力等级 `level`: its force window (PULL_UNBALANCE, 0.5 s below −1), none at ≤ −3 (force 0). */
export function pullUnbalance(level) {
const l = Math.round(fin(level, -99));
return l <= -3 ? 0 : l < -1 ? PULL_UNBALANCE_WEAK : PULL_UNBALANCE;
}
export class BattleDisplacement {
/**
* 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5):
@@ -40,7 +55,7 @@ export class BattleDisplacement {
* Returns the tiles moved.
*/
push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) {
if (!this._displaceable(e)) return 0;
if (!this._displaceable(e)) { this._staticForce(e, force, false); return 0; }
let level = this.forceLevel(e, force);
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y);
const vx = e.x - fx0, vy = e.y - fy0, d = hypot(vx, vy);
@@ -58,7 +73,12 @@ export class BattleDisplacement {
else return 0;
let dist = pushTiles(level, effect);
if (inward && !(dl > 0)) { ux = -ux; uy = -uy; dist = Math.min(dist, Math.max(0, d - PULL_STOP_RADIUS)); }
return this.displace(e, { x: ux, y: uy }, dist);
// 失衡 for the row's 位移时间 — also when the 特效 column or a wall shortens the slide [ASSUMED for the wall]; a slide a
// wall stops at the first step gets the 0.1 s floor, like a body that cannot move [ASSUMED: 碰撞、停止 is 待补充]
const hold = pushUnbalance(level);
const moved = this.displace(e, { x: ux, y: uy }, dist, { dur: hold });
if (hold > 0) this._unbalance(e, moved > 0 ? hold : UNBALANCE_MIN);
return moved;
}
/**
@@ -68,12 +88,17 @@ export class BattleDisplacement {
* the official 拉力起点 in front of an operator. Returns the tiles moved.
*/
pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) {
if (!this._displaceable(e) || !to) return 0;
if (!to) return 0;
// an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking]
if (center && center.side === 'ally' && e.blockedBy === center) return 0;
if (e && center && center.side === 'ally' && e.blockedBy === center) return 0;
if (!this._displaceable(e)) { this._staticForce(e, force, true); return 0; }
const level = this.forceLevel(e, force);
// 失衡 for the force window, whatever the travel: the 急停 zeroes the movement, the state lasts to the window's end
const hold = pullUnbalance(level);
if (!(hold > 0)) return 0;
const tx = fin(to.x, e.x), ty = fin(to.y, e.y);
const dx = tx - e.x, dy = ty - e.y, d0 = hypot(dx, dy);
if (!(d0 > 1e-6)) return 0;
if (!(d0 > 1e-6)) { this._unbalance(e, hold); return 0; }
const ux = dx / d0, uy = dy / d0;
// travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to`
let full = d0;
@@ -84,9 +109,10 @@ export class BattleDisplacement {
const disc = wu * wu - w2 + r * r;
if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); }
}
const level = this.forceLevel(e, force);
const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : level === -2 ? Math.min(full, PULL_CRAWL) : 0;
return dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist) : 0;
const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : Math.min(full, PULL_CRAWL);
const moved = dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist, { dur: hold }) : 0;
this._unbalance(e, hold);
return moved;
}
/** Official distance (tiles) a push of 力度 `force` would move `e` on open ground (0 when it cannot be displaced). */
@@ -107,12 +133,43 @@ export class BattleDisplacement {
* 失衡状态 … 但物理层面上无法产生任何速度或移动" — every air unit of the mode except “炎佑”, plus the boss 昆图斯 (build-data
* STATIC_BODIES; player report after 0.1.0, "飞机可以被薄绿的技能拉走"). A skill that reaches it still hits it (its targeting
* is the skill's own: 锏 S3, 薄绿, the 钩索师 …); only the movement is 0, so distance-based effects (drag damage, 见行者 S2's
* wall stun) come to nothing. [ASSUMED] the 0.1 s 失衡硬直 a 静态刚体 still gets is not modelled (no displacement models it).
* wall stun) come to nothing — but a force > 0 still puts it in the 失衡 state for its 0.1 s floor (_staticForce).
*/
_displaceable(e) {
return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && !(e.def && e.def.staticBody));
}
/**
* A push (`pull` false) / pull of 力度 `force` on a 静态刚体 (data `staticBody`; not 失衡免疫 `noDisplace` — PRTS 特殊机制
* 静态刚体: unlike 失衡免疫 it "可以进入失衡状态并启用物理,但物理层面上无法产生任何速度或移动", leaving it 「在失去施力后立刻」 with
* 「0.1 秒保底持续时间」): no movement, the state as long as the force acts — a push's is instant (推与拉 「作用时间:瞬间」) so
* the UNBALANCE_MIN floor, a pull's is its window (pullUnbalance: 1 s, 0.5 s below −1); a force > 0 (受力等级 ≥ −2) only.
* Leaders (the boss pool) stay out.
*/
_staticForce(e, force, pull = false) {
if (!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && e.def && e.def.staticBody)) return;
const level = this.forceLevel(e, force);
if (level >= -2) this._unbalance(e, pull ? Math.max(UNBALANCE_MIN, pullUnbalance(level)) : UNBALANCE_MIN);
}
/**
* Put enemy `e` in the 失衡 (UNBALANCE) state for `dur` game seconds from now (a running one is kept when it lasts longer):
* a state machine, not a status (PRTS 异常效果: no 阻止攻击 status — no stun, no immunity, SP untouched) — ai.js
* updateEnemy reads `unbalanceUntil`: the enemy neither walks (恐惧 / 诱导 included) nor starts a normal attack, a swing
* short of its frame is cut; checks that ignore the state (水遁忍者's damage, content abilities) go on. The state machine
* switch ends the attack clip it stood for (PRTS 状态机: the states are exclusive — UNBALANCE, then DEFAULT → MOVE). It
* ends with its time, or at once with 浮空 (术语释义 浮空 「触发浮空时清除受到的推/拉力…无法陷入失衡」, ai.js), a 重生 or a route
* APPEAR (forced state switches). No skill meanwhile either (失衡免疫 「…使用技能」): content skills, blinks and scripted moves
* check `enemies/helpers.js unbalancedNow` (docs/SIM.md lists what deliberately keeps running).
*/
_unbalance(e, dur) {
if (!(dur > 0) || !e || !e.alive) return;
const until = this.time + dur;
if (!(e.unbalanceUntil >= until)) e.unbalanceUntil = until;
this._cutAttackStand(e);
e.atkStandUntil = -Infinity;
}
/**
* Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of
* push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone).
@@ -120,7 +177,7 @@ export class BattleDisplacement {
* ⇒ no movement (_displaceable). The tiles it may cross follow its movement (`motion`): a hovering enemy walks the
* ground, so it stays on ground-passable tiles.
*/
displace(e, dir, distance) {
displace(e, dir, distance, { dur = 0 } = {}) {
if (!this._displaceable(e) || !dir) return 0;
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0);
const len = hypot(dxv, dyv);
@@ -144,7 +201,8 @@ export class BattleDisplacement {
this._cutAttackStand(e);
e.atkStandUntil = -Infinity;
if (e.route) e.route.pts = null;
this.fx('displace', { x: e.x, y: e.y, id: e.id });
// `dur` (game s): the 失衡 the push / pull gives — the client's slide takes that long (render/units.js slideTo)
this.fx('displace', dur > 0 ? { x: e.x, y: e.y, id: e.id, dur } : { x: e.x, y: e.y, id: e.id });
}
return moved;
}
+1
View File
@@ -137,6 +137,7 @@ export class BattleSpawns {
e.atkCd = 0;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
e.unbalanceUntil = -Infinity; // 失衡 (UNBALANCE) after a push / pull (battle/displacement.js _unbalance, ai.js)
e.atkStandCutAt = -Infinity; // display metadata: when its stand was last cut or ignored (snapshot standCut)
e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
+16
View File
@@ -128,6 +128,22 @@ export const PULL_CRAWL = 0.03;
export const PULL_ORIGIN = 0.5;
export const PULL_STOP_RADIUS = 0.6708;
export const PUSH_DIRECTIONAL_MIN_DIST = 0.25;
/**
* 失衡 (UNBALANCE) — the state machine a force > 0 puts an enemy in (PRTS 失衡位移机制 「当一名敌方单位受到一个任意来源的力,
* 并且受力大小 > 0 时,该敌方单位将进入失衡(UNBALANCE)状态机」; 异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」 —
* not an abnormal status: 异常效果 「失衡…不属于异常效果,它是一种状态机」, so it is no stun). Its length in game seconds:
* PUSH_UNBALANCE = a push by 受力等级 — PRTS 游戏数据基础 推力-位移近似对应表 「位移时间」 (whole frames at 30 fps: 6, 12, 24,
* 27, 32, 35; the 0.1 s floor is inside them); PULL_UNBALANCE / PULL_UNBALANCE_WEAK = a pull's force window — 推与拉 §拉力
* 「作用时间默认为 1 s;若本次受力等级 < −1,作用时间改为 0.5 s」, the state lasting to its end after the 急停 too (「目标将仍保持
* 失衡状态至拉力作用时间结束为止」); UNBALANCE_MIN = the 失衡硬直 floor (「立刻拥有 0.1 s 的“失衡硬直”。此期间无法解除失衡状态机
* ——哪怕已经没有被移动或者受力」) — what a 静态刚体 hit by a push gets (特殊机制 静态刚体 「失衡状态拥有 0.1 秒保底持续时间」; a pulled
* one stays for the pull's window, its force lasting that long).
* PR #392 by @xcdoge brought the state; its 0.6387·√tiles was derived from the page's μ = 0.5 单位假设, not printed.
*/
export const PUSH_UNBALANCE = Object.freeze({ '-2': 0.2, '-1': 0.4, 0: 0.8, 1: 0.9, 2: 32 / 30, 3: 35 / 30 });
export const PULL_UNBALANCE = 1;
export const PULL_UNBALANCE_WEAK = 0.5;
export const UNBALANCE_MIN = 0.1;
/**
* Fallback freeze when a second 寒冷 lands and neither the remaining cold nor the incoming one has a duration
+7 -7
View File
@@ -73,7 +73,7 @@ import { compileRoute } from '../ai.js';
import { normalizeRoute } from '../simdata.js';
import {
ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, unbalancedNow, absorbArts, nthOf,
} from './enemies.js';
import { hypot, powi } from '../detmath.js';
@@ -539,7 +539,7 @@ function kitBlade(ab, e, b, tpl) {
if (P.state === 'dive' && e2.alive && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) shotDown(b2, e2); // 仅1次
},
tick(b2, e2, a, dt) {
if (P.state !== 'dive' || !canCast(e2)) return; // a stunned blade does not fly on
if (P.state !== 'dive' || !canCast(e2, false, b2)) return; // a stunned blade does not fly on
const t = P.target;
if (!stepToward(e2, t.c, t.r, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
// arrival: 3×3 stun + DoT (无来源: credited to 胄, as the shell's), then the 初始模式 copy takes over
@@ -593,7 +593,7 @@ function kitGun(ab, e, b) {
dealt(c, b2, e2) { elem(b2, e2, c.target, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }, // 攻击时附带侵蚀损伤
tick(b2, e2, a, dt) {
const ch = P.charge;
if (!ch) return;
if (!ch || unbalancedNow(b2, e2)) return; // (失衡: no scripted move meanwhile)
const t = ch.target;
const sp = (e2.s.moveSpeed + (T(ab, '3.move_speed') ?? 0)) * MOVE_SCALE * dt;
const nx = e2.x, ny = e2.y;
@@ -711,7 +711,7 @@ function kitSpring(ab, e) {
tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
const d = e2.mem.ab.dash;
if (!d) return;
if (!d || unbalancedNow(b, e2)) return; // (失衡: no scripted move meanwhile)
// 追逐模式 lasts the whole gain (PRTS 末日布道 "令全场的“碎铳之簧”获得5秒增益…增益期间切换为追逐模式"; “碎铳之簧”
// 追逐模式 "持续追踪令其进入该形态的场上的假想敌:铳移动" — no arrival clause, unlike the 铳's own 冲锋模式): reaching
// the gun does not end it, the spring keeps following the gun until the 5 s are over (PR #347 by @CXUtk; until
@@ -820,7 +820,7 @@ function pipeCore(ab, e, b, tpl, { form, prefix }) {
tick(b2, e2, a, dt) {
P.acc += dt;
if (P.acc >= (e2.hpRatio < thr ? lo : hi)) { P.acc = 0; summon(b2, e2); }
if (!canCast(e2, false)) return;
if (!canCast(e2, false, b2)) return;
P.atk += dt;
if (P.atk < e2.s.interval) return;
const t = b2.rng.pick(echoesOf(b2, form)); // 优先攻击…形态余音
@@ -1052,7 +1052,7 @@ function kitLion(ab, e, b) {
b2.addBuff(e2, { key: 'boss:advance', persist: true, visible: true, mods: { defPct: T(ab, 'advance.def') ?? 0, resFlat: T(ab, 'advance.magic_resistance') ?? 0 } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'lionAdvance' });
}
if (!canCast(e2, false)) return;
if (!canCast(e2, false, b2)) return;
P.sp += dt * ENEMY_SP_PER_SEC;
if (P.sp >= cost && decree(b2, e2)) P.sp = 0;
// 【莫非王土】: ANIMATE_DELAY s after equipment first lies on the field, then once per 王权号令 cycle
@@ -1100,7 +1100,7 @@ function kitDeer(ab, e) {
b.addBuff(e2, { key: 'boss:madness', persist: true, visible: true, mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); // 物理和法术伤害降低
b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'deerMadness' });
}
if (!canCast(e2, false)) return;
if (!canCast(e2, false, b)) return;
P.acc += dt;
if (P.acc < e2.s.interval) return;
const cands = fairOrder(b, e2, allTargets(b, e2), P);
+2 -2
View File
@@ -119,7 +119,7 @@ import { LEADER_KITS } from './enemies/leaders.js';
import { hypot } from '../detmath.js';
export {
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, elem, hurt, alliesInTiles,
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, unbalancedNow, elem, hurt, alliesInTiles,
targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, auraAllies, targetAndArea, byPriority, zone,
remainingRoute, stayRoute, spawnChildren, stepToward, setHits, hitCount, isHitCount, setForm, lpLoss, expose,
expectedFinal, absorbArts,
@@ -260,7 +260,7 @@ function onTick(b, dt) {
}
if (a.fire && a.cd != null) {
a.left -= dt;
if (a.left <= 1e-9 && canCast(e, a.sil) && (!a.cond || safe(b, e, () => a.cond(b, e, a)))) {
if (a.left <= 1e-9 && canCast(e, a.sil, b) && (!a.cond || safe(b, e, () => a.cond(b, e, a)))) {
a.left = Math.max(TICK, a.cd);
a.casts = (a.casts ?? 0) + 1;
e.skillAnimUntil = b.time + 0.5;
+4
View File
@@ -7,6 +7,7 @@ import { MOVE_SCALE, COLS, STEALTH_RESTORE } from '../../constants.js';
import {
HUSK_REBIRTH, BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, DUCK_STEALTH_RESTORE, nthOf, stOf, safe, num, T, elem, hurt,
areaAllies, areaAlliesInTiles, zone, spawnChildren, stepToward, setHits, hitCount, setForm, absorbArts, auraBuff,
unbalancedNow,
} from './helpers.js';
import { hypot } from '../../detmath.js';
@@ -356,6 +357,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
a.state = 'reborn';
a.t0 = b.time;
a.noAtk = e.profile.noAttack;
e.unbalanceUntil = -Infinity; // a 重生 is a forced state change: it ends a 失衡 (PRTS 失衡位移机制 「例如复活」)
rebirthCleanse(b, e);
e.hp = Math.min(1, e.s.maxHp);
if (onKo) safe(b, e, () => onKo(b, e, a));
@@ -477,6 +479,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; // the husk's knock-out is the real death
a.state = 'husk';
a.rebornUntil = b.time + HUSK_REBIRTH;
e.unbalanceUntil = -Infinity; // a 重生 is a forced state change: it ends a 失衡 (PRTS 失衡位移机制 「例如复活」)
a.noAtk = e.profile.noAttack;
a.max = a.max ?? e.base.maxHp;
rebirthCleanse(b, e);
@@ -601,6 +604,7 @@ const skill = (s, fire, { cond = null, sil = false, cd = null, icd = null, id =
/** Blink past the blocker along the path (弑君者 / 卢西恩). Returns the start position. */
export function blinkForward(b, e, dist) {
if (unbalancedNow(b, e)) return null; // 失衡: 「无法…使用技能」 — no blink meanwhile
const from = { x: e.x, y: e.y };
const R = e.route;
let left = dist;
+2 -1
View File
@@ -4,7 +4,7 @@
import { TICK, MOVE_SCALE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../../constants.js';
import {
num, T, hurt, targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, byPriority, spawnChildren,
stepToward, setForm, expose,
stepToward, setForm, expose, unbalancedNow,
} from './helpers.js';
import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js';
import { hypot } from '../../detmath.js';
@@ -258,6 +258,7 @@ export const FLY_KITS = Object.freeze({
spawn(b, e) { e.profile.noAttack = true; },
tick(b, e, a, dt) {
if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); }
if (unbalancedNow(b, e)) return; // 失衡: no dive, no blast meanwhile
if (!a.t) {
const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y));
if (!l.length) return;
+9 -1
View File
@@ -132,7 +132,15 @@ export function T(ab, ...keys) { for (const k of keys) { const v = ab.t[k]; if (
export function silenced(e) { return !!e.s.flags.silence; }
export function canCast(e, sil) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence); }
/**
* Is enemy `e` in its 失衡 (UNBALANCE) state now (battle/displacement.js _unbalance; the 1e-9 keeps a float-summed
* end — 35/30 s — on its frame)? PRTS 异常效果图鉴/失衡免疫: 「失衡期间无法自主移动、发动攻击、使用技能」 — content skills
* (canCast), blinks and scripted moves wait for its end.
*/
export function unbalancedNow(b, e) { return !!(b && e && b.time < e.unbalanceUntil - 1e-9); }
/** Can enemy `e` use a skill now: alive, on the field, not stunned, not silenced (`sil`), not in its 失衡 state (`b` given). */
export function canCast(e, sil, b = null) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence) && !unbalancedNow(b, e); }
// ---------------------------------------------------------------------------------------------------------------
// helpers (exported for bosses.js)
+19 -10
View File
@@ -6,7 +6,7 @@ import { aggroCmp, areaSelectable } from '../../targeting.js';
import { periodicDamage } from '../../damage.js';
import {
nthOf, T, hurt, alliesInTiles, targetsNear, allTargets, areaAllies, areaAlliesInTiles, auraAllies, targetAndArea,
byPriority, setForm, auraBuff, watchDeaths,
byPriority, setForm, auraBuff, watchDeaths, unbalancedNow,
} from './helpers.js';
import {
unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff,
@@ -60,8 +60,6 @@ const XI_CROSS_REACH = 2;
* Until 0.1.3: 2.5 / 2.5 / −30, the aura only during the 重生 [ASSUMED]. */
const WOLF_RANGE = 1.25, WOLF_AWE_RADIUS = 1.5, WOLF_AWE_ASPD = -50;
/** 失衡 movement speed that turns 弧光锋卫's per-interval bleed into HP per tile moved [ASSUMED]. */
const UNBALANCE_SPEED = 5;
/** 乌顶巨角卢鲁 【角力对决】: the operator cannot be pushed (fixed tiles) ⇒ "更多伤害" multiplier [ASSUMED]. */
const ELK_FAIL_SCALE = 2;
@@ -722,7 +720,7 @@ function kitElk(ab) {
const ch = P.ch;
if (!ch) return;
if (e.s.flags.stun || !ch.t.alive || e.blockedBy !== ch.t) { stop(b, e); b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'chargeBroken' }); return; }
if (b.time + 1e-9 < ch.until) return;
if (b.time + 1e-9 < ch.until || unbalancedNow(b, e)) return; // a 失衡 that did not move it holds the clash (no skill meanwhile)
stop(b, e);
b.fx('explode', { x: ch.t.x, y: ch.t.y, r: 0.5, kind: 'elkClash' });
hurt(b, e, ch.t, e.s.atk * ((s && s.bb.atk_scale_s) ?? 1) * ELK_FAIL_SCALE, 'phys');
@@ -773,12 +771,23 @@ export const LEADER_KITS = Object.freeze({
enemy_2085_skzjxd: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)],
enemy_2085_skzjxd_2: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // (鸭爵 strategy) same
// 失衡 (pushed / pulled by operators)
enemy_1328_cbjedi: (ab) => [unbalanced((b, e, a, d) => { // 弧光锋卫 · takes damage in proportion to the distance moved while unbalanced
// PRTS 修正 "失衡移动时持续受到真实伤害", "处于失衡状态时,每0.066s受到400点无来源真实持续伤害" (伤害分类: 弧光锋卫失衡状态下的自残
// 伤害 is BUFF damage): damage, not a 流失 (player report D1 audit)
const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 1;
if (v > 0 && iv > 0) b.dealDamage(null, e, { ...periodicDamage((v * d) / (iv * UNBALANCE_SPEED)), tags: ['dot', 'periodic', 'unbalanced'] });
})],
enemy_1328_cbjedi: (ab) => { // 弧光锋卫 · bleeds while in its 失衡 state
// PRTS 修正 "失衡移动时持续受到真实伤害", 天赋 "处于失衡状态时,每0.066s受到400点无来源真实持续伤害" (data unbalanced_bleed.damage /
// .interval; 伤害分类: 弧光锋卫失衡状态下的自残伤害 is BUFF damage): damage, not a 流失 (player report D1 audit) — every `interval`
// s for as long as the state lasts (battle/displacement.js _unbalance: a push's 位移时间, a pull's force window even
// after the 急停 or with no movement). Until 0.2.2 one hit in proportion to the tiles moved [ASSUMED], from the position jump.
const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 0;
return [{
tick(b, e, a, dt) {
if (!(v > 0 && iv > 0) || !unbalancedNow(b, e)) { a.bleed = 0; return; }
a.bleed = (a.bleed ?? 0) + dt;
while (a.bleed >= iv - 1e-9 && e.alive) {
a.bleed -= iv;
b.dealDamage(null, e, { ...periodicDamage(v), tags: ['dot', 'periodic', 'unbalanced'] });
}
},
}];
},
enemy_10112_ymgds: (ab) => [unbalanced((b, e) => { // 冒失的小弟 · stunned after being unbalanced
const st = T(ab, 'StunAfterUnbalance.stun') ?? 0;
if (st > 0) b.applyStatus(e, 'stun', { duration: st, source: null });