diff --git a/docs/SIM.md b/docs/SIM.md index 1715d61f..1a3a2ddc 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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) | | `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 | -| `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 its fear exemption are not taken | -| `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 | +| `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 μ = 0.5 单位假设) and its fear exemption are not taken | +| `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) | | `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 | @@ -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.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 diff --git a/docs/design/client.md b/docs/design/client.md index d0f5eb4b..960eacef 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -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 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. + + +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. diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 8054a960..af88581b 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -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 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. + +### 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. diff --git a/docs/research/13-knockback-official.md b/docs/research/13-knockback-official.md index c7598e02..b3c0692b 100644 --- a/docs/research/13-knockback-official.md +++ b/docs/research/13-knockback-official.md @@ -112,7 +112,26 @@ p ×77(预览栏) d ×76(装置) S ×66(起点) I / E ×33(路 < 1 ⇒ 滑得更久)。PR #392 的 `0.6387·√格` / `0.3194` 是用 PRTS 页面的「μ = 0.5 单位假设」推出来的,页面没有写出、也对不上 位移时间表(每档高约一帧),未采用。`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 的「回到出发格」); 倒地的单位交给 `setDown` 放到它的格子上。 diff --git a/public/js/render/app.js b/public/js/render/app.js index 3616883d..1d8dd820 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1464,7 +1464,9 @@ export async function createFieldView(host, options = {}) { if (e[1] === 'displace') { const d = e[4] && typeof e[4] === 'object' ? e[4] : 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 // 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 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 - 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 downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list diff --git a/public/js/render/units.js b/public/js/render/units.js index 81ad9a30..55b0e66d 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -837,7 +837,7 @@ export class UnitView { 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 (this.anim === ANIM.DIE && this.alive) this.die(); if (this.actor && this.alive) this.actor.setBase(this._baseFromAnim()); @@ -880,15 +880,14 @@ export class UnitView { 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); // 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 - // our facing comes from the snapshot's vx — which points back down the route the enemy resumes after the - // displacement, i.e. against the push. The slide therefore owns the facing until it lands. - if (this.isEnemy) this.visFacing = dx < 0 ? -1 : 1; + // Push and pull fx carry keepFacing by default: the victim retains its pre-hit facing through the slide. Raw + // displacements have no flag and still face their travel; snapshots take over when the slide ends. + if (this.isEnemy && !opts.keepFacing) this.visFacing = dx < 0 ? -1 : 1; const v0 = 2 * D / T; const at = Number.isFinite(opts.at) ? opts.at : -Infinity; 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, - 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) sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * 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 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 diff --git a/server/sim/battle/displacement.js b/server/sim/battle/displacement.js index 911e48be..0c40910a 100644 --- a/server/sim/battle/displacement.js +++ b/server/sim/battle/displacement.js @@ -52,9 +52,12 @@ export class BattleDisplacement { * 而改变推动的方向或削减力度" — 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). + * 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. */ - 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; } let level = this.forceLevel(e, force); 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 // 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 }); + const moved = this.displace(e, { x: ux, y: uy }, dist, { dur: hold, keepFacing }); if (hold > 0) this._unbalance(e, moved > 0 ? hold : UNBALANCE_MIN); 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 * 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. + * 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; // 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; @@ -110,7 +117,7 @@ export class BattleDisplacement { 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; + const moved = dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist, { dur: hold, keepFacing }) : 0; this._unbalance(e, hold); 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 * 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; const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0); const len = hypot(dxv, dyv); @@ -202,7 +209,7 @@ export class BattleDisplacement { 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 }); + this.fx('displace', { x: e.x, y: e.y, id: e.id, ...(dur > 0 ? { dur } : {}), ...(keepFacing ? { keepFacing: true } : {}) }); } return moved; } diff --git a/test/content/facing.test.js b/test/content/facing.test.js index 421cac11..788ace54 100644 --- a/test/content/facing.test.js +++ b/test/content/facing.test.js @@ -195,6 +195,8 @@ test('薄绿 (3_08) 聚能涡旋 pushes the target towards her centre whatever h fill(u); assert.ok(h.runUntil(() => u.skill.active, 5)); 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'); }; for (const dir of ['UP', 'RIGHT']) { diff --git a/test/content/kits_alt_t4.test.js b/test/content/kits_alt_t4.test.js index 13ff6751..b5ba92db 100644 --- a/test/content/kits_alt_t4.test.js +++ b/test/content/kits_alt_t4.test.js @@ -533,6 +533,8 @@ test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1 assert.equal(sk.length, 1); 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(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 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']); 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})`); + assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true), + `${id}: S2 pull carries the common facing metadata`); } }); diff --git a/test/content/kits_t1t2.test.js b/test/content/kits_t1t2.test.js index 5c1dca3d..41720a7d 100644 --- a/test/content/kits_t1t2.test.js +++ b/test/content/kits_t1t2.test.js @@ -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)); const e = h.enemies()[0]; 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); const idb = 'chess_char_1_19_b', t = tal(idb); diff --git a/test/content/kits_t4.test.js b/test/content/kits_t4.test.js index 6e4d49ec..a80fc021 100644 --- a/test/content/kits_t4.test.js +++ b/test/content/kits_t4.test.js @@ -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'); 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(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true), + 'Mostima S3 inherits the common push facing rule'); checkInvariants(h.b); }); diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 30e32113..46ab9af6 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -567,19 +567,50 @@ describe('a push / pull slide (推拉: the official impulse under friction)', () 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 () => { - // 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: 被推开敌人的方向反过来了). + test('an unmarked raw displacement still faces its travel direction', async () => { 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 }); - assert.equal(v.visFacing, -1, 'walking left before the push'); - v.slideTo(7.7, 12); // pushed to the right - assert.equal(v.visFacing, 1, 'faces the push at once'); + assert.equal(v.visFacing, -1, 'walking left before the pull'); + v.slideTo(7.7, 12); // unmarked raw move to the right + 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); - assert.equal(v.visFacing, 1, 'still facing the push in flight'); - v.slideTo(5.2, 12); // now pulled back to the left - assert.equal(v.visFacing, -1, 'and the other way for a pull'); + assert.equal(v.visFacing, 1, 'still facing the movement in flight'); + v.slideTo(5.2, 12); // another unmarked raw move to the left + 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 () => { diff --git a/test/sim/displace-fx.test.js b/test/sim/displace-fx.test.js index 680e3743..3d492bbc 100644 --- a/test/sim/displace-fx.test.js +++ b/test/sim/displace-fx.test.js @@ -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'); }); +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'); + } +});