1 parent
584cedc1b4
commit
4980ee786f
13 files changed
+153
-30
No files matched your search
+10
-2
@@ -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 iLine 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 |
|
||||
| `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, 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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` 放到它的格子上。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user