Preserve enemy facing through push and pull slides (#418, #427)

This commit is contained in:
sganggs committed 2026-10-09 17:16:49 +08:00
1 parent 584cedc1b4
commit 4980ee786f
13 files changed
+153 -30

No files matched your search

+10 -2
View File
@@ -907,8 +907,8 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
| `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/ops/chess_char_5_05-ulpia.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) | | `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/ops/chess_char_5_05-ulpia.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — but not for a skill that costs no SP, which the reset re-arms as at every deployment (伊内丝 S3 casts again on a 突袭 landing) — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies | | `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — but not for a skill that costs no SP, which the reset re-arms as at every deployment (伊内丝 S3 casts again on a 突袭 landing) — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 | | `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0). **失衡 (UNBALANCE)** (PR #392's idea, its timing re-done from the sources): the move stays instant, then the enemy is held in the 失衡 state — a state machine, not a status (PRTS 失衡位移机制; 异常效果 「失衡…不属于异常效果」: no stun, no immunity, SP untouched), `enemy.unbalanceUntil`, read by `ai.js updateEnemy`: no walking (恐惧 / 诱导 included — no fear exemption) and no normal attack started, a swing short of its frame cut, the attack clip's stand ended; and no skill (失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」): content skills wait (`enemies/helpers.js canCast(e, sil, battle)`, `unbalancedNow`), so do blinks (`blinkForward`) and scripted moves (枯朽之种's dive, the 碎铳之簧 / 铳 charges), and 乌顶巨角卢鲁's clash waits for its end. Left running on purpose: checks the state does not stop (异常效果: 水遁忍者's damage — 「仍然能正常造成伤害」), passive auras and terrain / periodic effects (no skills), death / damage-taken triggers, skill cooldowns (only the cast waits) — and 弧光锋卫's bleed, which is the state's own (天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」: every 0.066 s for as long as it lasts — until 0.2.2 one hit per tile moved [ASSUMED]). Length, game seconds: a push — the 位移时间 of its 受力等级, PRTS 游戏数据基础 推力-位移近似对应表 (`constants.js PUSH_UNBALANCE`: −2 → 0.2, −1 → 0.4, 0 → 0.8, 1 → 0.9, 2 → 32/30, ≥ 3 → 35/30), the level push() computes (the −2 correction included), kept when the 特效 column or a wall shortens the slide ([ASSUMED] for the wall; a wall at the first step → the 0.1 s floor [ASSUMED]); a pull — its force window, 推与拉 「作用时间默认为 1 s;若本次受力等级 < −1,作用时间改为 0.5 s」 (`PULL_UNBALANCE` / `_WEAK`), to its end after the 急停 too (「目标将仍保持失衡状态至拉力作用时间结束为止」), none at ≤ −3; a 静态刚体 hit by a force > 0 — no movement, the state while the force acts (特殊机制 静态刚体: 「在失去施力后立刻」 out, 「0.1 秒保底」): a push 0.1 s (`UNBALANCE_MIN`; a push's force is instant), a pull its window; 失衡免疫 and leaders never. 浮空 ends it (术语释义 浮空), so do a 重生 (重生 / 逐火's 余烬) and a route APPEAR relocation — forced state switches (失衡位移机制). The end compares with 1e-9 so a float-summed 35/30 s keeps its 35 frames. The `displace` fx carries the length (`dur`): the client's slide takes as long (render/units.js slideTo). PR #392's 0.6387·√tiles (from the page's μ = 0.5 单位假设) and iLine truncated | `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect, keepFacing})`, `pull(enemy, force, {to, center, stop, keepFacing})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0). **失衡 (UNBALANCE)** (PR #392's idea, its timing re-done from the sources): the move stays instant, then the enemy is held in the 失衡 state — a state machine, not a status (PRTS 失衡位移机制; 异常效果 「失衡…不属于异常效果」: no stun, no immunity, SP untouched), `enemy.unbalanceUntil`, read by `ai.js updateEnemy`: no walking (恐惧 / 诱导 included — no fear exemption) and no normal attack started, a swing short of its frame cut, the attack clip's stand ended; and no skill (失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」): content skills wait (`enemies/helpers.js canCast(e, sil, battle)`, `unbalancedNow`), so do blinks (`blinkForward`) and scripted moves (枯朽之种's dive, the 碎铳之簧 / 铳 charges), and 乌顶巨角卢鲁's clash waits for its end. Left running on purpose: checks the state does not stop (异常效果: 水遁忍者's damage — 「仍然能正常造成伤害」), passive auras and terrain / periodic effects (no skills), death / damage-taken triggers, skill cooldowns (only the cast waits) — and 弧光锋卫's bleed, which is the state's own (天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」: every 0.066 s for as long as it lasts — until 0.2.2 one hit per tile moved [ASSUMED]). Length, game seconds: a push — the 位移时间 of its 受力等级, PRTS 游戏数据基础 推力-位移近似对应表 (`constants.js PUSH_UNBALANCE`: −2 → 0.2, −1 → 0.4, 0 → 0.8, 1 → 0.9, 2 → 32/30, ≥ 3 → 35/30), the level push() computes (the −2 correction included), kept when the 特效 column or a wall shortens the slide ([ASSUMED] for the wall; a wall at the first step → the 0.1 s floor [ASSUMED]); a pull — its force window, 推与拉 「作用时间默认为 1 s;若本次受力等级 < −1,作用时间改为 0.5 s」 (`PULL_UNBALANCE` / `_WEAK`), to its end after the 急停 too (「目标将仍保持失衡状态至拉力作用时间结束为止」), none at ≤ −3; a 静态刚体 hit by a force > 0 — no movement, the state while the force acts (特殊机制 静态刚体: 「在失去施力后立刻」 out, 「0.1 秒保底」): a push 0.1 s (`UNBALANCE_MIN`; a push's force is instant), a pull its window; 失衡免疫 and leaders never. 浮空 ends it (术语释义 浮空), so do a 重生 (重生 / 逐火's 余烬) and a route APPEAR relocation — forced state switches (失衡位移机制). The end compares with 1e-9 so a float-summed 35/30 s keeps its 35 frames. The `displace` fx carries the length (`dur`): the client's slide takes as long (render/units.js slideTo). PR #392's 0.6387·√tiles (from the page's μ =Line truncated
| `displace(enemy, {x, y}, tiles, {dur})` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule, no 失衡 state of its own (push / pull add it; `dur` only rides on its `displace` fx); nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths | | `displace(enemy, {x, y}, tiles, {dur, keepFacing})` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule, no 失衡 state of its own (push / pull add it; `dur` only rides on its `displace` fx); nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths |
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) | | `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random; for distances, angles and powers use `hypot`, `sin`, `cos`, `atan2`, `powi` from `server/sim/detmath.js`, never `Math.hypot` / `sin` / `cos` / `atan2` / `pow` / `**` (their last bits differ between browsers) | | `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random; for distances, angles and powers use `hypot`, `sin`, `cos`, `atan2`, `powi` from `server/sim/detmath.js`, never `Math.hypot` / `sin` / `cos` / `atan2` / `pow` / `**` (their last bits differ between browsers) |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked | | `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
@@ -917,6 +917,14 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})` (every automatic placement: the 突袭 landing tile, tactical points, summon tiles — `build` is the effective deploy type, so never the 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "拒绝部署(待补充)"; the 特制水上平台 of the inactive act1 m05 is not modelled, its tiles stay NONE here [ASSUMED]); no melee unit on a hard-blocked 射击台 / mound tile), `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) | | `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})` (every automatic placement: the 突袭 landing tile, tactical points, summon tiles — `build` is the effective deploy type, so never the 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "拒绝部署(待补充)"; the 特制水上平台 of the inactive act1 m05 is not modelled, its tiles stay NONE here [ASSUMED]); no melee unit on a hard-blocked 射击台 / mound tile), `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
| `battle.data` | DataSource: `getChess(id, loadout?)`, `getEnemy(key)`, `getToken(id, ownerChessId, ownerLoadout?)`, `getStage(id)`, `getWave(id)` (normalised defs; raw record in `def.raw`; the per-battle loadout view, §12) | | `battle.data` | DataSource: `getChess(id, loadout?)`, `getEnemy(key)`, `getToken(id, ownerChessId, ownerLoadout?)`, `getStage(id)`, `getWave(id)` (normalised defs; raw record in `def.raw`; the per-battle loadout view, §12) |
`keepFacing` is presentation metadata emitted by `push` and `pull` by default: `displace` includes it in the fx so the
client retains the enemy's pre-hit facing through its slide. Directional Wild Mane S2 (#418) and radial Mostima S3 have
official footage directly showing unchanged facing. Mint S2 uses `push({ inward: true })`, while hook pulls such as
Gladiia S1/S2 use `pullToFront` → `pull`; the supplied drag clips directly show unchanged facing before and after the
shown pulls. Applying that observation to pulls against prior facing and other sources is [ASSUMED]. Either method can
pass `keepFacing: false` for a documented exception. Raw `displace` calls still face their travel direction. No facing
flag alters movement or 失衡.
--- ---
## 7. Skills ## 7. Skills
+5
View File
@@ -84,3 +84,8 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN)
Clipboard exports (`ui/clipboard.js`, §28.3) first use the secure Clipboard API. The textarea fallback for LAN Clipboard exports (`ui/clipboard.js`, §28.3) first use the secure Clipboard API. The textarea fallback for LAN
play stays inside the active modal/guide focus boundary; it copies only after focus and the complete selection play stays inside the active modal/guide focus boundary; it copies only after focus and the complete selection
are verified, and cleans up / restores focus on both success and failure. Failure exposes the existing manual-copy UI. are verified, and cleans up / restores focus on both success and failure. Failure exposes the existing manual-copy UI.
Push/pull slides preserve the target's pre-hit facing through the waiting and moving phases (`keepFacing`, §28.9).
Explicit false and unmarked raw displacements face the travel direction. Normal snapshot facing resumes on landing.
[ASSUMED] unrecorded push sources and reverse-facing hook pulls use the same display rule as the cited examples.
+12
View File
@@ -115,3 +115,15 @@ clipping switches, dirty textures and zoom rescaling still update. Redeployment
Redeploy countdown labels share rasterized textures keyed by text, colour and resolution; late web fonts refresh Redeploy countdown labels share rasterized textures keyed by text, colour and resolution; late web fonts refresh
them in place. The 64 targeted renderer tests passed. The author's performance measurements are not reproduced them in place. The 64 targeted renderer tests passed. The author's performance measurements are not reproduced
here; real Pixi checks are part of the integration pass. No battle rule or golden result is intentionally changed. here; real Pixi checks are part of the integration pass. No battle rule or golden result is intentionally changed.
### 28.9 Preserve facing during pushes and pulls — #418 / PR #427
Ported from [PR #427](https://github.com/sganggs/Stronghold-Protocol/pull/427) by @YinqiMargatroid.
Successful pushes/pulls mark their displacement events `keepFacing` so the renderer retains pre-hit facing
while waiting for the endpoint snapshot and throughout the slide. Raw displacement and explicit
`keepFacing: false` retain the old directional behavior. Position, distance and unbalance time are unchanged.
The contributor's source clips and their limitations are retained in research 13; those observations were not
independently replayed for this port. **[ASSUMED]** other push sources and pulls against the target's old facing
share the examples' display behavior; the drag template alone cannot establish that engine rule.
Tests cover real push/pull skill paths, default/override metadata, snapshot reversal, and an earlier attack
arriving after slide prestart in either field direction. Golden metadata deliberately omits the display flag.
+20 -1
View File
@@ -112,7 +112,26 @@ p ×77(预览栏) d ×76(装置) S ×66(起点) I / E ×33(路
< 1 ⇒ 滑得更久)。PR #392 的 `0.6387·√格` / `0.3194` 是用 PRTS 页面的「μ = 0.5 单位假设」推出来的,页面没有写出、也对不上 < 1 ⇒ 滑得更久)。PR #392 的 `0.6387·√格` / `0.3194` 是用 PRTS 页面的「μ = 0.5 单位假设」推出来的,页面没有写出、也对不上
位移时间表(每档高约一帧),未采用。`render/app.js` 在插值器开始显示「以带终点的快照结尾」的那一段时就用 `displace` fx 启动滑行: 位移时间表(每档高约一帧),未采用。`render/app.js` 在插值器开始显示「以带终点的快照结尾」的那一段时就用 `displace` fx 启动滑行:
到达那张快照之前画面停在原处,之后从原处减速滑入模拟的实时位置(终点,以及敌人随后继续走的路),落地不跳。 到达那张快照之前画面停在原处,之后从原处减速滑入模拟的实时位置(终点,以及敌人随后继续走的路),落地不跳。
(PR #380 合入时模拟层、协议、golden 未改;0.2.2 的失衡状态改了模拟层,`displace` fx 多带一个 `dur`。) (PR #380 的渲染方案由 0.2.2 的 `2669b6a` 移植,并非该 PR 直接合入;当时模拟层、协议、golden 未改。
0.2.2 的失衡状态另改了模拟层,`displace` fx 多带一个 `dur`。)
推击模型朝向的原版对照有两类:[野鬃 S2 原版约 1:29](https://b23.tv/jMhl7xx) 的
[关键片段](https://github.com/user-attachments/assets/28acc81e-7ade-4245-b4d8-70be52546257) 显示方向推击时“火与钢”继续面朝原方向,
[复刻录屏](https://github.com/user-attachments/assets/7383bdb8-3974-47f2-b5b3-303af609cd4d) 显示其滑行时翻转(#418);
[莫斯提马 S3 原版 p2 约 34:33](https://b23.tv/EguihiI) 的用户截取片段在相邻帧中显示,持斧敌人受径向击退并向右滑时仍保持原来的模型朝向。
PRTS「推与拉」把推力分为方向力和径向力;官方 `knockback[dir]` / `knockback[relative]` 均使用 `Knockback` 动作类。
拖拽有不同实现:[薄绿 S2 原版约 4:26](https://b23.tv/v5QQ6uv) 对应 PRTS「推与拉」所称的反向径向推击,
本项目用 `Battle.push({ inward: true })`;[歌蕾蒂娅 S1/S2 原版录像](https://b23.tv/YLYYSPJ) 展示钩索拖拽,
本项目走 `Battle.pullToFront` → `Battle.pull`。用户截取的两段视频直接展示被拖拽目标在动作前后没有转身,
作为这些实例的画面证据。歌蕾蒂娅片段的目标原本就面朝拖拽方向,因此这段画面无法单独区分
「始终保持原朝向」和「始终面朝拖拽方向」两种全局规则;将所见外推到反向拉拽属于推断。
官方客户端模板的 `DragTowardSource` 动作节点没有显式朝向参数,但这一点也不能排除引擎内部的转向处理。
因此 `Battle.push` 与 `Battle.pull` 默认在 `displace` fx 带 `keepFacing`,从等待终点快照到滑行结束保留原朝向。
其他未录制的推击来源及朝向与拉拽方向相反的拉力来源均属 **[ASSUMED]**,技能描述中的「推开」「击退」「拖拽」本身
没有规定模型朝向。`KnockBackWithCharacterDirection` 另有 `_dontChangeFaceByDirection` true / false,
若当前或未来技能明确要求转向,调用方可设置 `keepFacing: false`。直接 `displace` 的朝向逻辑不变;
位移位置与失衡时间也不变。
滑行途中死亡:滑行照常走完,尸体停在被推到的位置(模拟里它已经在那里;0.2.2 合入时改掉了原 PR 的「回到出发格」); 滑行途中死亡:滑行照常走完,尸体停在被推到的位置(模拟里它已经在那里;0.2.2 合入时改掉了原 PR 的「回到出发格」);
倒地的单位交给 `setDown` 放到它的格子上。 倒地的单位交给 `setDown` 放到它的格子上。
+6 -2
View File
@@ -1464,7 +1464,9 @@ export async function createFieldView(host, options = {}) {
if (e[1] === 'displace') { if (e[1] === 'displace') {
const d = e[4] && typeof e[4] === 'object' ? e[4] : null; const d = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = d && d.id != null ? views.get(d.id) : null; const v = d && d.id != null ? views.get(d.id) : null;
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), Number(d.dur) > 0 ? { dur: Number(d.dur) } : {}); if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
...(Number(d.dur) > 0 ? { dur: Number(d.dur) } : {}), keepFacing: d.keepFacing === true,
});
} }
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user // an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after // playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
@@ -1525,7 +1527,9 @@ export async function createFieldView(host, options = {}) {
const extra = e[4] && typeof e[4] === 'object' ? e[4] : null; const extra = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = extra && extra.id != null ? views.get(extra.id) : null; const v = extra && extra.id != null ? views.get(extra.id) : null;
// `dur`: the push / pull's 失衡 time in game seconds (battle/displacement.js) — the slide's length // `dur`: the push / pull's 失衡 time in game seconds (battle/displacement.js) — the slide's length
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), Number(extra.dur) > 0 ? { at: slideAt, dur: Number(extra.dur) } : { at: slideAt }); if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
at: slideAt, ...(Number(extra.dur) > 0 ? { dur: Number(extra.dur) } : {}), keepFacing: extra.keepFacing === true,
});
} }
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave) let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
+6 -7
View File
@@ -837,7 +837,7 @@ export class UnitView {
if (this.imp) this.imp.dirty = true; if (this.imp) this.imp.dirty = true;
} }
} }
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1; if (this.isEnemy && !this.slide?.keepFacing && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL)); if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
if (this.anim === ANIM.DIE && this.alive) this.die(); if (this.anim === ANIM.DIE && this.alive) this.die();
if (this.actor && this.alive) this.actor.setBase(this._baseFromAnim()); if (this.actor && this.alive) this.actor.setBase(this._baseFromAnim());
@@ -880,15 +880,14 @@ export class UnitView {
const rate = this.ctx.animRate?.() || 1; const rate = this.ctx.animRate?.() || 1;
const T = Math.max(0.05, (opts.dur > 0 ? opts.dur / rate : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f); const T = Math.max(0.05, (opts.dur > 0 ? opts.dur / rate : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f);
// v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update(). // v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update().
// The official turns a displaced unit towards the force (its _dontChangeFaceByDirection is an opt-in flag), while // Push and pull fx carry keepFacing by default: the victim retains its pre-hit facing through the slide. Raw
// our facing comes from the snapshot's vx — which points back down the route the enemy resumes after the // displacements have no flag and still face their travel; snapshots take over when the slide ends.
// displacement, i.e. against the push. The slide therefore owns the facing until it lands. if (this.isEnemy && !opts.keepFacing) this.visFacing = dx < 0 ? -1 : 1;
if (this.isEnemy) this.visFacing = dx < 0 ? -1 : 1;
const v0 = 2 * D / T; const v0 = 2 * D / T;
const at = Number.isFinite(opts.at) ? opts.at : -Infinity; const at = Number.isFinite(opts.at) ? opts.at : -Infinity;
this.slide = { this.slide = {
x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T, x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T,
at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, keepFacing: opts.keepFacing === true,
}; };
} }
@@ -1125,7 +1124,7 @@ export class UnitView {
// 9 % of a 0.18 s slide at 60 fps, then a jump onto the end) // 9 % of a 0.18 s slide at 60 fps, then a jump onto the end)
sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step); sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step);
sl.t += step; sl.t += step;
if (this.isEnemy) this.visFacing = sl.ux < 0 ? -1 : 1; if (this.isEnemy && !sl.keepFacing) this.visFacing = sl.ux < 0 ? -1 : 1;
const k = 1 - sl.done / sl.D; // the share of the way still to go const k = 1 - sl.done / sl.D; // the share of the way still to go
this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k; this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k;
// the clock or the spent velocity lands it on the sampled position // the clock or the spent velocity lands it on the sampled position
+14 -7
View File
@@ -52,9 +52,12 @@ export class BattleDisplacement {
* 而改变推动的方向或削减力度" — the < 0.25 tile rule still applies). `inward` = a radial push towards `from` (薄绿 S2's "拖拽", PRTS 备注 "实际为 * 而改变推动的方向或削减力度" — 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 特效 * 反方向(指向薄绿方向)的推开"), 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). * push (PRTS 推与拉: one frame less of travel than a 弹道 push — constants.js PUSH_TILES_EFFECT / PUSH_EFFECT_SKILLS).
* The displace fx keeps the enemy's pre-hit facing by default: official footage shows this for a directional push
* (野鬃 S2, #418) and a radial push (莫斯提马 S3). [ASSUMED] for other push sources; an explicitly different
* client action can pass `keepFacing: false`. Pulls use the same display rule; raw displacements remain unmarked.
* Returns the tiles moved. * Returns the tiles moved.
*/ */
push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) { push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false, keepFacing = true } = {}) {
if (!this._displaceable(e)) { this._staticForce(e, force, false); return 0; } if (!this._displaceable(e)) { this._staticForce(e, force, false); return 0; }
let level = this.forceLevel(e, force); let level = this.forceLevel(e, force);
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y); const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y);
@@ -76,7 +79,7 @@ export class BattleDisplacement {
// 失衡 for the row's 位移时间 — also when the 特效 column or a wall shortens the slide [ASSUMED for the wall]; a slide a // 失衡 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 待补充] // 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 hold = pushUnbalance(level);
const moved = this.displace(e, { x: ux, y: uy }, dist, { dur: hold }); const moved = this.displace(e, { x: ux, y: uy }, dist, { dur: hold, keepFacing });
if (hold > 0) this._unbalance(e, moved > 0 ? hold : UNBALANCE_MIN); if (hold > 0) this._unbalance(e, moved > 0 ? hold : UNBALANCE_MIN);
return moved; return moved;
} }
@@ -85,9 +88,13 @@ export class BattleDisplacement {
* Pull enemy `e` with 力度 `force` towards the point `to` (PRTS 推与拉 §拉力 / §捕网): 受力等级 ≥ 0 — all the way, until it * 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`; * 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 * −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. * the official 拉力起点 in front of an operator. The displace fx keeps the pre-hit facing by default, as for pushes.
* The supplied 歌蕾蒂娅 S1/S2 clip directly shows the target keeping its facing before and after a hook pull.
* [ASSUMED] for other pull directions/sources: the clip does not isolate travel against the prior facing.
* PRTS distinguishes 薄绿's inward push from a hook pull, not their model facing.
* Pass `keepFacing: false` for a documented exception. Returns the tiles moved.
*/ */
pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) { pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS, keepFacing = true } = {}) {
if (!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] // an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking]
if (e && center && center.side === 'ally' && e.blockedBy === center) return 0; if (e && center && center.side === 'ally' && e.blockedBy === center) return 0;
@@ -110,7 +117,7 @@ export class BattleDisplacement {
if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); } 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 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; const moved = dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist, { dur: hold, keepFacing }) : 0;
this._unbalance(e, hold); this._unbalance(e, hold);
return moved; return moved;
} }
@@ -177,7 +184,7 @@ export class BattleDisplacement {
* ⇒ no movement (_displaceable). The tiles it may cross follow its movement (`motion`): a hovering enemy walks the * ⇒ 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. * ground, so it stays on ground-passable tiles.
*/ */
displace(e, dir, distance, { dur = 0 } = {}) { displace(e, dir, distance, { dur = 0, keepFacing = false } = {}) {
if (!this._displaceable(e) || !dir) return 0; if (!this._displaceable(e) || !dir) return 0;
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0); const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0);
const len = hypot(dxv, dyv); const len = hypot(dxv, dyv);
@@ -202,7 +209,7 @@ export class BattleDisplacement {
e.atkStandUntil = -Infinity; e.atkStandUntil = -Infinity;
if (e.route) e.route.pts = null; 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) // `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 }); this.fx('displace', { x: e.x, y: e.y, id: e.id, ...(dur > 0 ? { dur } : {}), ...(keepFacing ? { keepFacing: true } : {}) });
} }
return moved; return moved;
} }
+2
View File
@@ -195,6 +195,8 @@ test('薄绿 (3_08) 聚能涡旋 pushes the target towards her centre whatever h
fill(u); fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5)); assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3); h.run(3);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
`${dir}: Mint S2's inward push inherits the common facing metadata`);
return h.enemy('enemy_d'); return h.enemy('enemy_d');
}; };
for (const dir of ['UP', 'RIGHT']) { for (const dir of ['UP', 'RIGHT']) {
+4
View File
@@ -533,6 +533,8 @@ test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1
assert.equal(sk.length, 1); assert.equal(sk.length, 1);
approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`); approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`);
assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`); assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
`${id}: S1 pull carries the common facing metadata`);
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it // 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it
approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`); approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`);
} }
@@ -555,6 +557,8 @@ test('歌蕾蒂娅 S2 缺水的掌握怒海: BAT +0.5 s, wider range, 2 targets
assert.equal(hits.length, bb['attack@max_target']); assert.equal(hits.length, bb['attack@max_target']);
for (const c of hits) approx(c.amount, u.s.atk * bb['attack@atk_scale'] * t1.atk_scale, 1e-6); for (const c of hits) approx(c.amount, u.s.atk * bb['attack@atk_scale'] * t1.atk_scale, 1e-6);
for (const e of atk.targets) assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`); for (const e of atk.targets) assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
`${id}: S2 pull carries the common facing metadata`);
} }
}); });
+2 -1
View File
@@ -609,7 +609,8 @@ test('1_19 野鬃: 夹枪冲锋 wider range, ATK +atk, pushes targets away; elit
approx(u.s.atk, u.base.atk * (1 + bb.atk)); approx(u.s.atk, u.base.atk * (1 + bb.atk));
const e = h.enemies()[0]; const e = h.enemies()[0];
assert.ok(e.x >= 6.5, `pushed to x=${e.x}`); assert.ok(e.x >= 6.5, `pushed to x=${e.x}`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace')); assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
'Wild Mane S2 inherits the common push facing rule');
done(h); done(h);
const idb = 'chess_char_1_19_b', t = tal(idb); const idb = 'chess_char_1_19_b', t = tal(idb);
+2
View File
@@ -121,6 +121,8 @@ test('莫斯提马 S3: ripple hits every enemy in range, ATK +90 %, knock-back;
assert.ok(es.every((e) => hits.some((c) => c.target === e)), 'all three enemies hit by one ripple'); assert.ok(es.every((e) => hits.some((c) => c.target === e)), 'all three enemies hit by one ripple');
approx(hits[0].amount, u.s.atk, 1e-6, 'full damage (res 0)'); approx(hits[0].amount, u.s.atk, 1e-6, 'full damage (res 0)');
assert.ok(es.some((e, i) => e.x > x0[i] + 0.2), 'knocked back'); assert.ok(es.some((e, i) => e.x > x0[i] + 0.2), 'knocked back');
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
'Mostima S3 inherits the common push facing rule');
checkInvariants(h.b); checkInvariants(h.b);
}); });
+41 -10
View File
@@ -567,19 +567,50 @@ describe('a push / pull slide (推拉: the official impulse under friction)', ()
assert.equal(v.x, 7.7, 'and it still lands'); assert.equal(v.x, 7.7, 'and it still lands');
}); });
test('a pushed enemy faces the way it is pushed, not the route it resumes', async () => { test('an unmarked raw displacement still faces its travel direction', async () => {
// The official turns a displaced unit towards the force (_dontChangeFaceByDirection is an opt-in flag); our facing
// comes from the snapshot's vx, which after a displacement points back down the route the enemy walks again —
// i.e. against the push (player report: 被推开敌人的方向反过来了).
const v = await view(10); const v = await view(10);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0, vx: -1.2 }); v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0, vx: -1.2 });
assert.equal(v.visFacing, -1, 'walking left before the push'); assert.equal(v.visFacing, -1, 'walking left before the pull');
v.slideTo(7.7, 12); // pushed to the right v.slideTo(7.7, 12); // unmarked raw move to the right
assert.equal(v.visFacing, 1, 'faces the push at once'); assert.equal(v.visFacing, 1, 'faces the movement at once');
for (let i = 0; i < 20; i++) v.update(v.slide.dur / 40, cam(), 0); for (let i = 0; i < 20; i++) v.update(v.slide.dur / 40, cam(), 0);
assert.equal(v.visFacing, 1, 'still facing the push in flight'); assert.equal(v.visFacing, 1, 'still facing the movement in flight');
v.slideTo(5.2, 12); // now pulled back to the left v.slideTo(5.2, 12); // another unmarked raw move to the left
assert.equal(v.visFacing, -1, 'and the other way for a pull'); assert.equal(v.visFacing, -1, 'and the other way for a raw move');
});
test('a flagged push or pull keeps the enemy facing left while sliding right, then resumes snapshot facing', async () => {
const v = await view(14);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -1 });
assert.equal(v.visFacing, -1, 'facing left before the push');
v.slideTo(6.7, 12, { at: 3, dur: 0.8, keepFacing: true });
assert.equal(v.visFacing, -1, 'unchanged when the push begins');
v.sync({ x: 6.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 20 }, 2.95);
v.update(1 / 60, cam(), 0);
assert.equal(v.visFacing, -1, 'a positive interpolated velocity cannot flip it before the destination snapshot');
v.sync({ x: 6.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -1 }, 3);
const dur = v.slide.dur;
step(v, dur / 2);
assert.ok(v.x > 5 && v.x < 6.7, `still slides right (${v.x})`);
assert.equal(v.visFacing, -1, 'unchanged in flight');
step(v, dur / 2);
assert.equal(v.slide, null, 'slide ends normally');
assert.equal(v.x, 6.7);
v.sync({ x: 6.6, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 1 });
assert.equal(v.visFacing, 1, 'normal snapshot facing resumes after the slide');
});
test('a buffered earlier attack sets the facing retained by a prestarted slide in either field direction', async () => {
for (const sign of [-1, 1]) {
const v = await view(30 + sign);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -sign });
v.slideTo(5 - sign, 12, { at: 3, dur: 0.8, keepFacing: true });
v.onAttack({ x: 5 + sign, y: 12 }, 2.9, 'melee');
assert.equal(v.visFacing, sign, 'the earlier attack supplies the real pre-push facing');
v.sync({ x: 5 - sign, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -sign * 20 }, 3);
step(v, v.slide.dur / 2);
assert.equal(v.visFacing, sign, 'the slide and sampled velocity retain that facing');
}
}); });
test('a snapshot in flight does not teleport it (the slide owns the position until it lands)', async () => { test('a snapshot in flight does not teleport it (the slide owns the position until it lands)', async () => {
+29
View File
@@ -31,3 +31,32 @@ test('a displacement puts a `displace` fx into the event stream the client recei
assert.equal(disp[4].y, undefined, 'e[4] carries no y either'); assert.equal(disp[4].y, undefined, 'e[4] carries no y either');
}); });
test('directional and radial pushes and hook pulls preserve facing; explicit overrides and raw moves do not', () => {
for (const directional of [false, true]) {
const h = makeBattle({
defs: { enemies: { enemy_walker: walker({ speed: 0.01 }) } },
enemies: [{ key: 'enemy_walker', pos: [10, 6] }],
content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_walker');
h.b.drainEvents();
const from = { x: 5, y: 10 };
const opts = directional ? { from, dir: { x: 1, y: 0 } } : { from };
assert.ok(h.b.push(e, 0, opts) > 0);
const fx = () => h.b.drainEvents().find((ev) => ev[0] === 'fx' && ev[1] === 'displace');
assert.equal(fx()?.[4]?.keepFacing, true, 'both official push direction classes preserve the prior facing');
assert.ok(h.b.pull(e, 0, { to: { x: 5, y: 10 } }) > 0);
assert.equal(fx()?.[4]?.keepFacing, true, 'hook pull preserves the pre-hit facing by default');
assert.ok(h.b.pull(e, 0, { to: { x: 9, y: 10 }, keepFacing: false }) > 0);
assert.equal(fx()?.[4]?.keepFacing, undefined, 'a pull source can opt out');
assert.ok(h.b.push(e, 0, { ...opts, keepFacing: false }) > 0);
assert.equal(fx()?.[4]?.keepFacing, undefined, 'a push source can opt out');
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.2) > 0);
assert.equal(fx()?.[4]?.keepFacing, undefined, 'raw displace remains unmarked');
}
});