Files
ark-stronghold-protocol/server/sim/battle/displacement.js
T
sganggs cdf2bd5c88 feedback7 R22-UNBAL: a pushed / pulled enemy is held in the official 失衡 (UNBALANCE) state — no walking, no normal attack — for the push's 位移时间 of its 受力等级 or the pull's force window; a 静态刚体 hit by a force gets the 0.1 s floor; the client's slide takes the same game time (idea from PR #392 by @xcdoge, re-implemented from the sources); golden: 69 scenarios move, 35 with kills / leaks / deaths / boss HP (below)
Seen: the sim moved a displaced enemy at once and it walked / swung again on the next tick; players compared with the
official game (PR #392: 「被推/拉完的敌人会先站住一小会儿再走」). Research (scratchpad report r3): PRTS 失衡位移机制 — a force > 0
puts an enemy in the UNBALANCE state machine with a 0.1 s 失衡硬直 floor, ending when its speed is ≤ 0.1 m/s, on 浮空 or a
forced state change; 异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」; 异常效果 — not an abnormal status (水遁忍者's
check still runs); 游戏数据基础 推力-位移近似对应表 位移时间 (whole frames at 30 fps); 推与拉 拉力 「作用时间默认为 1 s;若本次受力
等级 < −1,作用时间改为 0.5 s」, 急停 keeps the state to the window's end; 特殊机制 静态刚体 (0.1 s 保底); 术语释义 浮空.

Now: constants.js PUSH_UNBALANCE {−2: 0.2, −1: 0.4, 0: 0.8, 1: 0.9, 2: 32/30, 3: 35/30}, PULL_UNBALANCE 1 /
PULL_UNBALANCE_WEAK 0.5, UNBALANCE_MIN 0.1. battle/displacement.js: push() holds for its level's row (the −2 correction
included), kept when the 特效 column or a wall shortens the slide [ASSUMED for the wall], the 0.1 s floor when a wall
stops it at the first step [ASSUMED]; pull() holds for its window whatever the travel (≤ −3: none; an enemy the puller
blocks: still no pull [ASSUMED, unchanged]); a 静态刚体 (data staticBody, not 失衡免疫 noDisplace) hit by force level
≥ −2 gets 0.1 s and no movement (_staticForce); _unbalance sets enemy.unbalanceUntil (a longer running one kept) and ends
the attack clip's stand. ai.js updateEnemy: no walking (恐惧 / 诱导 too — PR #392's fear exemption is not taken), no new
normal attack, a swing short of its frame cut, 浮空 clears it; a 重生 clears it (archetypes.js reborn). Not a stun: no
status, no immunity, SP and the attack cooldown run. No global skill lock: enemy abilities keep ticking ([ASSUMED] scope).
The move stays instant (the unbalanced() talents keyed off the jump unchanged). The displace fx carries `dur` (game s);
render/units.js slideTo divides it by ctx.animRate (2× default: a 0.8 s push slides 0.4 real s); DISPLACE_SLIDE·√D is
only the fallback for an fx without it. Not taken from #392: UNBALANCE_TRAVEL 0.6387·√tiles / the client's 0.3194 (derived
from the page's μ = 0.5 单位假设, one frame high on every push row, the wrong shape for pulls) and the fear exemption.

Tests: test/sim/unbalance.test.js (8: the push rows and walking after them; no attack in the hold, a feared enemy held;
the −2 correction's 0.2 s; 特效 / wall-shortened rows and the first-step floor; pull windows incl. the 急停 and ≤ −3; 静态刚体
0.1 s without movement, 失衡免疫 never; 浮空 clears; the fx `dur` — the file fails on the parent); test/render/unitview.test.js
(+1: the slide at 2× for a push and a pull, the fallback — fails on the parent); feedback1b-displacement B5 (the drone is
now held 0.1 s per force, never moved). test/sim + test/content + test/render: 3433 pass. Docs: SIM.md (push / pull,
displace, assumptions), PLAYING.md 推开 / 拉拽, research 13 §0 / §5.

Golden — every moved battle has pushes / pulls (probe of the callers) and its enemies are now held; outcome moves:
roster-002 (野鬃: killed 29→33, leaks 15→11), roster-003 (野鬃: deaths 3→7), roster-007 (薄绿: killed 110→118, leaks 53→47),
roster-008 (薄绿: 45→47 / 42→40), roster-010 (歌蕾蒂娅: killed 45→44, leaks 57→58), roster-011 (莫斯提马: 78→79 / 4→3),
roster-031 (钩索师 pulls, tier4: killed 64→72, leaks 12→4, deaths 6→5), roster-041 (钩索师: deaths 5→4), roster-043 (山:
deaths 15→14), roster-044 (山: killed 107→103, leaks 12→16, deaths 15→14); bond-deputShip-low (薄绿: 18→21 killed, leaks
3→0, deaths 1→0), bond-kazimierzShip-high (野鬃: deaths 3→2); boss-boss_3-pair (锏: p2 deaths 3→2), boss-boss_4-pair
(莫斯提马 / 歌蕾蒂娅: killed 11→12, boss HP 7442429→7375628, p2 leaks 2→1, deaths 19→18), boss-boss_6-pair (boss HP
6932482→6865243, p2 deaths 19→17), hidden-boss_8-pair (boss HP 12585770→12585798), hidden-boss_9-pair (锏: boss HP
3630612→3636991); standin-05 / 08 / 10 (预备干员-特种 pulls: killed 5→8 / 42→44 / 28→29, leaks down by 3 / 2 / 1);
diy-101 (傀影: killed 22→21, leaks 92→93), diy-114 / 115 / 116 (黑键: 106→99 & 83→90 / 154→156 & 78→70 / 3→2 & 120→121),
diy-137 (预备干员-特种: 68→79 / 79→68); matches through those battles — coop2-HARD-4, coop2-NORMAL-14-standins,
coop2-NORMAL-3, coop2-NORMAL-70-diy, coop3-ABYSS-5, coop4-ABYSS-7, solo-ABYSS-2 (LP 2→4 at round 10), solo-HARD-2
(ai_0 now survives round 13: eliminated 13 → alive), solo-HARD-9-boosted (LP 399→398), solo-NORMAL-1. Other moves (damage,
heals, snapshots only): roster-009/012/017/019/032/039/040/042/046, 13 bonds, boss-boss_2-pair, hidden-boss_10-pair,
standin-03, diy-053/084/085/102/103/108/109, coop2-ABYSS-10-boosted, solo-ABYSS-1.
2026-10-08 21:54:06 +08:00

206 lines
12 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/battle/displacement.js — Battle methods: displacement of enemies: 受力等级 (force − weight), push (radial /
// 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,
PUSH_UNBALANCE, PULL_UNBALANCE, PULL_UNBALANCE_WEAK, UNBALANCE_MIN,
} from '../constants.js';
import { fin } from './util.js';
import { hypot } from '../detmath.js';
/**
* Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效
* push (PRTS 推与拉's 特效 column, PUSH_TILES_EFFECT: 见行者), else the 弹道 column.
*/
export function pushTiles(level, effect = false) {
const l = Math.round(fin(level, -99));
if (l <= -3) return 0;
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):
* force − its current 重量等级 (massLevel incl. 失重) — PRTS 游戏数据基础 §重量公式, 推与拉 (user playtest #6 item 14).
*/
forceLevel(e, force) {
return Math.round(fin(force, 0)) - (e && e.s ? e.s.massLevel : 0);
}
/**
* Push enemy `e` with 力度 `force` (PRTS 推与拉 §推力): the official distance of its 受力等级 (constants.js PUSH_TILES:
* a 中力 push moves a weight-1 enemy 1.7 tiles, a weight-2 one 0.44, weight 3 0.12, weight ≥ 4 not at all). Radial
* (the default: away from `from`, the pusher's centre — "沿着干员向自身中心点的射线方向"; the client's buff template
* knockback[relative]: 琳琅诗怀雅 S3, 山 S3, 莫斯提马 S3) or directional (`dir` = the pusher's direction {x, y}; template
* knockback[dir], Knockback {_useSourceDirection: true, _decreaseForceLevelWhenNotInDirection: 2}: 推击手, 野鬃 S2
* "往攻击方向"); a directional push on a target more than 45° off `dir` or nearer than 0.25 tile becomes radial with
* 受力等级 −2 ("特殊修正"). `fixed` waives both corrections (圣聆初雪 S1 "朝部署方向": KnockBackWithCharacterDirection,
* PRTS 备注 "不会因角度过大或距离过近而变化方向与力度"); `fixedAngle` only the angle one (PRTS 见行者 S2 备注 "不会因为角度过大
* 而改变推动的方向或削减力度" — the < 0.25 tile rule still applies). `inward` = a radial push towards `from` (薄绿 S2's "拖拽", PRTS 备注 "实际为
* 反方向(指向薄绿方向)的推开"), never nearer than PULL_STOP_RADIUS to its centre [ASSUMED: "至面前"]. `effect` = a 特效
* push (PRTS 推与拉: one frame less of travel than a 弹道 push — constants.js PUSH_TILES_EFFECT / PUSH_EFFECT_SKILLS).
* Returns the tiles moved.
*/
push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) {
if (!this._displaceable(e)) { this._staticForce(e, force); 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);
let ux = 0, uy = 0;
const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0;
const dl = hypot(dirX, dirY);
if (dl > 0) {
ux = dirX / dl; uy = dirY / dl;
if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) {
level -= 2;
if (d > 1e-6) { ux = vx / d; uy = vy / d; }
}
} else if (d > 1e-6) { ux = vx / d; uy = vy / d; }
else if (!inward && from && Array.isArray(from.fwd)) { ux = from.fwd[1]; uy = from.fwd[0]; }
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)); }
// 失衡 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;
}
/**
* Pull enemy `e` with 力度 `force` towards the point `to` (PRTS 推与拉 §拉力 / §捕网): 受力等级 ≥ 0 — all the way, until it
* is within `stop` tiles of `center` (急停; `center` defaults to `to`, `stop` to PULL_STOP_RADIUS) or reaches `to`;
* −1 — PULL_WEAK_SHARE of its starting distance to `to`; −2 — PULL_CRAWL tiles; ≤ −3 — nothing. `pullToFront` aims at
* the official 拉力起点 in front of an operator. Returns the tiles moved.
*/
pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) {
if (!to) return 0;
if (!this._displaceable(e)) { this._staticForce(e, force); 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;
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)) { 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;
const cx = fin(center?.x, tx), cy = fin(center?.y, ty), r = Math.max(0, fin(stop, 0));
const wx = e.x - cx, wy = e.y - cy, wu = wx * ux + wy * uy, w2 = wx * wx + wy * wy;
if (w2 <= r * r) full = 0;
else {
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 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). */
pushDistance(e, force, { effect = false } = {}) {
return this._displaceable(e) ? pushTiles(this.forceLevel(e, force), effect) : 0;
}
/** Pull `e` "至面前" of ally `unit` (拉力起点 PULL_ORIGIN tiles ahead along its direction, 急停 around its centre). */
pullToFront(e, unit, force) {
if (!unit) return 0;
const f = Array.isArray(unit.fwd) ? unit.fwd : [0, 1];
return this.pull(e, force, { to: { x: unit.x + f[1] * PULL_ORIGIN, y: unit.y + f[0] * PULL_ORIGIN }, center: unit, stop: PULL_STOP_RADIUS });
}
/**
* Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (flag `noDisplace`:
* 近地悬浮, 浮空, the 胄 parts …) and not a 静态刚体 (data `staticBody`). PRTS 特殊机制 静态刚体: such a unit "可以进入
* 失衡状态 … 但物理层面上无法产生任何速度或移动" — 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 — 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 of 力度 `force` on a 静态刚体 (data `staticBody`; not 失衡免疫 `noDisplace` — PRTS 特殊机制 静态刚体: unlike
* 失衡免疫 it "可以进入失衡状态并启用物理,但物理层面上无法产生任何速度或移动", leaving it 「在失去施力后立刻」 with 「0.1 秒保底持续
* 时间」): a force > 0 (受力等级 ≥ −2) gives the UNBALANCE_MIN state, no movement. Leaders (the boss pool) stay out.
*/
_staticForce(e, force) {
if (!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && e.def && e.def.staticBody)) return;
if (this.forceLevel(e, force) >= -2) this._unbalance(e, 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) or a 重生.
* No global skill lock: enemy abilities keep ticking (the 「使用技能」 part is not modelled — docs/SIM.md).
*/
_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).
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响"), 静态刚体 (data `staticBody`) and leaders
* ⇒ 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, { 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);
if (!(len > 0)) return 0;
const eff = Math.min(fin(distance, 0), 2 * COLS);
if (!(eff > 0)) return 0;
const ux = dxv / len, uy = dyv / len;
let moved = 0;
const stepLen = 0.1;
while (moved + 1e-9 < eff) {
const s = Math.min(stepLen, eff - moved); // (the last step is a partial one: 0.12 tiles moves 0.12, not 0.2)
const nx = e.x + ux * s, ny = e.y + uy * s;
const r = Math.round(ny), c = Math.round(nx);
const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
if (!ok) break;
e.x = nx; e.y = ny; moved += s;
}
if (moved > 0) {
this._unblock(e);
// 失衡 ends the attack clip it stood for (PRTS 状态机: the states are exclusive — UNBALANCE, then DEFAULT → MOVE)
this._cutAttackStand(e);
e.atkStandUntil = -Infinity;
if (e.route) e.route.pts = null;
// `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;
}
}