Files
mtgodot-poc/audit/contracts/movement.keyboard-motion.md
T
shenleiandClaude Sonnet 5 7df97e01f9 fix(movement): camera auto-rotates to face WASD input direction
40250 parity: CPythonPlayer::NEW_MoveToDirection's m_isCmrRot branch
continuously rolls the camera toward the held movement direction every
frame while a direction key is down, independent of whether a new
translation target was just set. Add _camera_auto_rotate() to replicate
this (forward/back = no rotation, strafe = max rate, diagonal = half
rate), called unconditionally after the frozen/locked/shop/emotion gate
in _process(). Godot's yaw-increase handedness is opposite the
reference's D3D Roll(), so the sign is flipped as a platform adaptation;
verified by convergence invariants in keyboard_motion_timeline_test.gd
rather than bit-for-bit angle matching.

Closes movement.keyboard.motion/camera-auto-follow-rotation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:17:00 +09:00

225 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# movement.keyboard-motion
## Scope
比较键盘输入、WASD 快速切换、移动请求、方向/速度计算、动作选择和停止清理。
## Reference call chain
- `UserInterface/PythonPlayerInputKeyboard.cpp`
- `UserInterface/PythonPlayerInput.cpp`
- `UserInterface/InstanceBaseMovement.cpp`
- `UserInterface/InstanceBaseMotion.cpp`
- `GameLib/ActorInstanceMotion.cpp`
- `GameLib/ActorInstanceMotionEvent.cpp`
- `GameLib/GameUtil.cpp`(`CameraRotationToCharacterRotation`/`CharacterRotationToCameraRotation`)
- `EterLib/Camera.cpp`(`CCamera::Roll`/`CalculateRoll`,增量式)
- `GameLib/ActorInstanceBattle.cpp`(`CanAct`/`CanMove`)
- `GameLib/ActorInstance.cpp`(`IsParalysis`/`IsFaint`/`IsSleep`)
## Current call chain
- `project/player_controller.gd`
- `project/game_camera.gd`
- `project/net_play.gd`
- `project/net_world.gd`
- `project/test_wasd_steering_parity.gd`
- `project/test_no_auto_move_regression.gd`
- `project/player_motion_test.gd`
- `project/keyboard_motion_timeline_test.gd`
## Audit findings (2026-09-20)
40250 的键盘路径是事件驱动的:`NEW_SetSingleDIKKeyState` → `NEW_SetSingleDirKeyState` 更新四个方向位 → `NEW_SetMultiDirKeyState`。无方向立即 `NEW_Stop`;有方向按 `Up > Down`、`Left > Right` 计算八方向角,再调用 `NEW_MoveToDirection`。`NEW_MoveToDirection` 依次执行取消点地预约、主角存在/私店/锁定/移动技能/同步门、设置 `m_isGoing = FALSE`、设置 advancing rotation、启动 walk/run motion,并把当前位置向量加 300 像素目标。
当前端已按事件维护方向位,W/S 优先级、释放切换、失焦、进场残留和直立旋转均有回归证据;本轮新增 `keyboard_motion_timeline_test.gd`,实际驱动 `InputEventKey` 和 `_process`,覆盖“加载期按键不继承 → W+S 优先 → 释放 W 暴露 S → 释放最后方向停止”的时序。
仍存在明确的实现层差异,不能升级为 STATIC_VERIFIED:
- 40250 在按键事件中设置固定 300 像素 `Dst`,随后由 `.msa`/`CActorInstance::AccumulationMovement` 按动作根运动和 `m_fMovSpd` 推进;当前端在 `_process` 中按米制连续速度推进,模型可用时读取 `get_move_motion_speeds`,否则回退 `SPEED_WALK/SPEED_RUN`。方向角可一致,但目标距离、速度、到达时刻和阻挡后的状态不同。
- 40250 的 `CInstanceBase::Update → AccumulationMovement → Transform` 才会触发 `OnMove/OnMoving/OnWaiting/OnStop`;当前端 `moved` 和 `anim_state` 来自每帧预测位置,`NetPlay` 再按 0/300/100ms 节流发 `FUNC_MOVE/FUNC_WAIT`。已有节流测试,但没有证明每个动作资源停止/切换事件与网络状态事件一一对应。
- 40250 `NEW_MoveToDirection` 的 `__IsSyncing`、`isLock`、`IsUsingMovingSkill` 分支是“忽略移动但可能保留原动作”的同步门;当前端 `frozen`/`locked`/`moving_skill` 在 `_process` 早返回,`_goto` 被门挡时只对点地目标建立预约,键盘方向本身不建立预约。被击退、情绪、技能锁和解锁恢复的顺序尚未全部对齐。
- 40250 的相机旋转可在 `NEW_MoveToDirection` 内先改变相机再叠加角色方向;当前端读取 `camera.heading()` 后直接构造前后左右向量,没有实现同一套相机旋转副作用。
- 当前端用 Euler 分量强制保持 `rotation.x/z = 0`,解决了 W/S 快切翻跟头;这是有效的 Godot 平台适配,但还缺“角色根节点/骨骼动画旋转不被姿态资源覆盖”的真实模型测试。
因此现有测试证明的是输入回归和网络节流的已覆盖路径,不是完整 40250 移动/动作/资源链等价。
## Baseline tests
`test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd`、`netplay_test.gd` 和 `keyboard_motion_timeline_test.gd` 当前通过,证明已覆盖的回归路径没有复现自动移动、W/S 粘键、翻滚和 0/300/100ms 网络节流问题;这不替代完整 40250 分支等价审计。
## Deep audit round 2026-09-20
本轮重新对照 `NEW_SetSingleDirKeyState`、`NEW_MoveToDirection`、`AccumulationMovement`、`RotationProcess` 和当前 `PlayerController`,并重跑输入与动作测试:
- 当前 `active` 切换、W/S 优先级、KeyUp 全局接收、失焦清键、`rotation.x/z=0` 和跨 `±PI` 的单向转向均有可重复测试;这解释了此前 W/S 快切翻跟头已经被当前平台适配压住。
- 40250 方向键移动把目标设置为当前位置前方固定 300 像素,再由动作根运动和 `m_fMovSpd` 推进;当前端仍用 `_process` 的米制连续速度推进,并在模型缺失时使用固定速度 fallback。因此“方向一样”不代表到达时刻、动作结束事件和服务端 MOVE/WAIT 边沿一致。
- 当前 `frozen/locked/moving_skill` 在 `_process` 中早返回或只转向;40250 的同步、私店、受击、情绪、移动技能和 `NEW_Stop` 分支会分别决定是否清 `m_isGoing`、是否保留动作及何时恢复。本轮没有端到端证明这些门在解锁后与 40250 同一帧恢复。
- 当前摄像机方向只参与构造移动向量,没有复刻 40250 `NEW_MoveToDirection` 内相机旋转与角色 advancing rotation 的完整副作用;真实角色模型测试 `player_motion_test.gd` 虽打印 PASS,但本轮退出码为 139 并报告 6 个 ObjectDB 泄漏,不能作为干净证据。
- `test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd`、`keyboard_motion_timeline_test.gd` 本轮退出码 0,覆盖的输入回归通过;因此只保留“输入问题已回归通过”,不把根运动、资源事件、锁门和退出生命周期标记为等价。
结论:WASD 输入状态和翻转回归已稳定,但 40250 的固定目标/根运动、动作事件源、锁门恢复、相机副作用和真实模型退出安全仍有差异,合同保持 `PARTIAL`。
### Active UserInterface actor-transform review
- `InstanceBaseEvent.cpp` 将 `CInstanceBase` 的 event handler 直接绑定到 `CActorInstance`;`InstanceBaseTransform.cpp` 提供 cm 像素位置、rotation/blend direction、LookAt fly target/destination 和当前/advancing rotation 访问。
- `PythonPlayerEventHandler.h` 明确消费 `OnSyncing/Waiting/Moving/Move/Stop/Warp/ClearAffects/SetAffect/ResetAffect/Attack/UseSkill/Hit`,并在 bow fly handler 中把 `OnSetFlyTarget` 与动作帧 `OnShoot` 转成网络副作用。当前端 `PlayerController`/`NetPlay` 有移动/技能信号,但未证明远端/本地都按同一 event handler 顺序消费,自动移动和快速 WASD 仍需状态时序 oracle。
- 本轮完成 InstanceBase/PlayerEventHandler 静态核对,键盘合同继续为 `PARTIAL`。
## Implementation round 2026-09-20T19:03Z
本轮将键盘方向目标按 `InstanceBaseMovement.cpp::NEW_MoveToDirection` 的固定距离
规则收敛:方向事件现在记录当前角色位置加 300 像素(当前 Godot 单位为 3.0m)的
`_direction_dst`,后续帧只向该目标推进,目标到达后停止,不再每帧从当前位置延长
路径;移动技能仍保留只转向不平移的分支。死亡、受击/锁定门和方向释放会清理该目标。
新增的时间线断言验证了固定目标、到达停止、W/S 优先级、KeyUp 暴露、最后方向释放和
进入游戏不继承输入;`keyboard_motion_timeline_test.gd`、`player_move_test.gd`、
`test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd` 均退出码 0。
这只修复了目标创建/消费语义,不宣称完整根运动等价:当前端仍由 Godot `_process`
按速度推进,尚未接入 40250 `.msa`/`AccumulationMovement` 的真实动作事件、相机副作用、
同步/情绪/受击解锁恢复和完整 MOVE/WAIT 时间线,合同继续为 `PARTIAL`。
## Equivalence matrix
| 项目 | 状态 | 备注 |
|---|---|---|
| Preconditions | PARTIAL | active/失焦/按键残留、锁门、私店、情绪和锁门 held WASD 已覆盖;同步、受击、死亡和传送门未全覆盖 |
| Branch structure | PARTIAL | W/S、A/D、八方向、释放顺序和 `m_isCmrRot` 相机跟随已覆盖;Paralysis/Faint/Sleep 未接入移动门 |
| Algorithms/formulas | PARTIAL | 方向优先级、归一化和相机跟随的 fold-to-±90/rate 公式一致(`movement.keyboard.motion/camera-auto-follow-rotation`);固定 300 像素 Dst 与根运动仍被连续速度替代 |
| State transition order | PARTIAL | 输入状态、锁门/移动技能恢复、PlayerController、NetPlay 入口已定位;资源事件与网络事件的完整顺序未证实 |
| Constants/units | PARTIAL | 角度/旋转阈值和 0/300/100ms 节流已记录;米/厘米、移动速度和目标距离不同 |
| Timing/event sources | PARTIAL | 当前是 `_process` 预测 + signal;参考是 Actor motion event 驱动 |
| Resource/data sources | PARTIAL | 当前模型速度可从动作资源读取但有 fallback;`.msa` event/AccumulationMovement 尚未一一对照 |
| Protocol side effects | PARTIAL | `FUNC_MOVE/FUNC_WAIT` 发送和节流有测试;阻止/重复/相机副作用仍未闭合 |
| Interruption/failure/cleanup | PARTIAL | stop、失焦、换图、锁定/移动技能恢复已覆盖;受击/情绪/传送和完整解锁矩阵仍需测试 |
## Remaining
- 用真实 `camera.heading()`、模型动作速度和网络 fake 记录同一方向序列的输入事件、移动目标、停止请求、动画状态和包时间线。
- 补充死亡/传送/受击以及私店、情绪、移动技能和普通锁定的多门叠加顺序;各单门 held WASD 恢复已覆盖基础路径。
- 继续对照 `.msa` 的 motion event、`AccumulationMovement` 根运动和当前 `get_move_motion_speeds` fallback,决定是否需要根运动适配层。
- 真实角色模型在 W/S 快切时根节点/骨骼姿态不翻转的模型级测试仍缺。
- **新增(2026-09-22 发现,未修复)**:`CPythonPlayer::__CanMove()` 还委托
`CActorInstance::CanAct()`,后者在 `IsParalysis()/IsFaint()/IsSleep()` 时也拒绝移动
(`ActorInstanceBattle.cpp` `CanAct`/`CanMove`)。当前端 `net_play.gd::_can_process_network_state()`
只挡 `dead`/`stunned`/`knock_down`,没有 Paralysis/Faint/Sleep 三个状态的移动门。
三者的触发源分别是萨满技能 `__Shaman_SetParalysis`(`InstanceBaseEffect.cpp:821`)、
Python 侧 `SetFaint`(`PythonCharacterModule.cpp:1094/1101`)和效果可见性绑定的
`SetSleep`(`InstanceBaseEffect.cpp:933`),当前端尚未定位这些触发源对应的服务端
affect/状态位,需要先把三者映射到现有 affect 管线再决定移动门怎么接,属于比本轮
camera 分支更大的独立子项,留给后续轮次。
### Audit round 2026-09-21
本轮重新执行 `keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd` 和
`test_no_auto_move_regression.gd`,三项均通过;W/S 优先级、KeyUp 暴露被按住方向、最后
方向释放停止、进入游戏不继承加载期按键、角色 X/Z 姿态归零、跨 `±PI` 转向和技能结束后
不自动恢复移动均有回归证据。
沿 `ActorInstanceBattle.cpp::ComboAttack`、`ActorInstanceMotion.cpp::AccumulationMovement`
和 `PythonPlayerEventHandler::OnMove/OnMoving/OnWaiting/OnStop` 再次核对后,没有把这些
输入测试升级为完整等价:当前端仍用连续速度预测替代固定 300 像素 Dst + 根运动,动作资源
事件与移动网络事件也不是同一来源;同步/受击/情绪/移动技能解锁恢复、相机旋转副作用和
真实模型根节点测试仍是下一轮差异项。合同保持 `PARTIAL`,本轮未改动实现。
### Implementation fix round 2026-09-21T11:45Z
本轮按 40250 `CPythonPlayer::Update` 在 `m_isDirKey` 下每帧重试
`NEW_SetMultiDirKeyState(m_isLeft,m_isRight,m_isUp,m_isDown)` 的语义修复了当前端的
锁门恢复差异:方向键仍按住时,`frozen/locked` 不再丢弃方向状态;移动技能接管移动时
会清除旧的平移目标但保留 held WASD,技能结束或锁定解除后的首个可移动更新重新创建
固定 300cm 目标。方向释放、死亡和停止仍会清除 pending 状态,避免旧目标在技能结束后
莫名续走。
`keyboard_motion_timeline_test.gd` 新增锁定恢复和移动技能恢复断言;与
`test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd`、
`player_motion_test.gd` 一起退出码均为 0。真实模型测试同时改为清理 native model/animation
节点并清空测试用的 GR2/MSA 缓存,确认本轮测试不再出现 ObjectDB 泄漏和 exit 139。
这轮只闭合了 held WASD 在锁门/移动技能边沿的状态恢复,不宣称完整移动等价:当前端仍
用 Godot 连续速度预测替代 `.msa`/`AccumulationMovement` 根运动,`OnMove/OnMoving/`
`OnWaiting/OnStop` 事件源、同步/受击/传送边界、相机副作用和完整网络时间线
仍保持 `PARTIAL`。
### Implementation fix round 2026-09-21T12:05Z
本轮补齐了 40250 `__CanMove` 的两个此前缺失的主角状态门:
`CPythonPlayer::IsOpenPrivateShop()` 与 `__IsProcessingEmotion()`。当前端新增
`PlayerController.set_private_shop_open()` / `set_processing_emotion()`;门开启时会
清理点地预约和旧方向目标,但保留 WASD 的方向位,和 `NEW_SetSingleDirKeyState`
保留 `m_isDirKey`、`CPythonPlayer::Update` 解锁后重新调用
`NEW_SetMultiDirKeyState` 的行为一致。`NetPlay._on_entity_info()` 只对主角读取
`shop_sign` / `acting_emotion`,因此查看或打开其他角色的商店不会锁住本地输入。
`keyboard_motion_timeline_test.gd` 新增摆摊中、情绪处理中“无平移且保留按键”,以及
状态解除后第一帧恢复移动的断言;本轮 targeted tests 通过。私店/情绪两个移动门已闭合,
但根运动、动作事件源、同步/受击/传送边界、相机副作用和完整网络时间线仍保持
`PARTIAL`。
## Audit round 2026-09-21T04:45Z — keyboard/root-motion boundary
本轮重新执行 `keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd`、
`test_no_auto_move_regression.gd` 和 `player_motion_test.gd`,全部退出码为 0:
- 进入游戏不继承加载期按键、W/S 优先与释放切换、最后方向停止、技能结束不续走、快速 WASD 不翻跟头、跨 `±PI` 单向转向和真实模型动作装配均保持通过。
- 继续对照 `NEW_SetMultiDirKeyState`、`NEW_MoveToDirection`、`AccumulationMovement` 和 `OnMove/OnMoving/OnWaiting/OnStop` 后,确认当前端仍是 Godot `_process` 连续速度预测,不能用输入测试替代 40250 `.msa` 根运动/动作事件源证据。
结论:本轮未修改实现;输入回归保持稳定,合同继续 `PARTIAL`。下一轮需要用真实动作资源和 fake network timeline 对拍固定 300cm 目标、根运动、停止事件和 MOVE/WAIT 包边沿。
### Implementation fix round 2026-09-22 — `movement.keyboard.motion/camera-auto-follow-rotation`
本轮处理的具体分支:40250 相机旋转副作用(此前 Remaining 与 Branch structure 行标注
的 `没有实现同一套相机旋转副作用`)。
**参考实现**(`UserInterface/PythonPlayerInput.cpp:445-491` `CPythonPlayer::NEW_MoveToDirection`):
`IsOpenPrivateShop()`/`isLock() && !IsUsingMovingSkill()` 提前返回(不跑相机分支)之后,
若 `m_isCmrRot`(硬编码为 true,从未被关闭):
把方向角 `fDirRot`(0=前 90=左 180=后 270=右,来自 `NEW_GetMultiKeyDirRotation` →
`NEW_GetMouseDirRotation` → `GetDegreeFromPosition` 的屏幕空间角)折成有符号
`fSigDirRot ∈ (-180,180]`,再折叠到 `fRotRat ∈ [-90,90]`(正前/正后为 0,正左/右为
±90 最大速率,对角线线性减半),套公式
`fRotDeg = -m_fCmrRotSpd(20.0) * fElapsedTime * fRotRat / 90`,通过 `CCamera::Roll(fRotDeg)`
(D3D 增量式 roll,非绝对值)应用;这一步与是否已建立平移目标无关,且 `CPythonPlayer::Update`
每帧对仍按住的方向键重试整条链路,所以只要方向键按住、且通过门检查,相机就持续转动。
**当前实现(修复前)**:`player_controller.gd::_keyboard_wish()` 只读 `camera.heading()`
构造世界方向向量,从不写回 `camera.yaw`——完全没有这个副作用。
**修复**:新增 `PlayerController._camera_auto_rotate(dir, dt)`(`player_controller.gd`),
在 `_process()` 的 `frozen/locked/private_shop_open/processing_emotion` 门之后、其余
移动逻辑之前,用当前帧 `_wasd()` 的方向向量无条件调用(门未通过时函数整体在早退分支
里不会被调用,等价于参考实现的 `IsOpenPrivateShop`/`isLock` 早退)。公式与参考实现的
折算/折叠逻辑逐项相同(`fSigDirRot`/`fRotRat` 折叠、`CAMERA_AUTO_ROTATE_SPEED_DEG=20.0`
对应 `m_fCmrRotSpd` 默认值、按 `dt` 与 `fRotRat/90` 线性缩放)。**平台适配**:D3D
`CCamera::Roll` 的正方向与 Godot `yaw` 增大(视线转向左)手性相反,因此增量对 `fRotRat`
取正号而非参考实现的负号;这一符号翻转用不变式核验,而不是逐位对照 D3D 数值——
"持续按住某一方向时,相机朝该方向收敛(正前/正后不转,正左右转速最快,对角线减半),
从不反向或越界",与参考实现的稳态行为一致。移动技能中的转向、以及触屏/摇杆
`mobile_axis` 路径本轮未改动(后者是纯前端手势适配,参考实现没有对应的按键状态机,
留作独立分支,不在本次改动范围内)。
**测试**:`keyboard_motion_timeline_test.gd` 新增六项断言(`GameCamera` 实例挂到
`pc.camera`,不加入场景树,只读写 `yaw`):纯前/后不转、纯左/右等速反向、多帧速率恒定、
对角线为纯左的一半速率、`locked` 门下按住方向键相机不转。修复前四项方向性断言必然
失败(`cam.yaw` 恒为初始值 0),修复后全部通过。
**测试结果**:`keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd`、
`test_no_auto_move_regression.gd`、`game_camera_test.gd`、`movement_parity_test.gd`、
`player_move_test.gd`、`mouse_controller_test.gd`、`test_alignment_parity.gd` 修复后全部
退出码 0(Godot 4.7.1 headless)。`player_motion_test.gd`(旧文件名,非本轮新增)在本环境
用 `godot --headless --path project --script player_motion_test.gd` 单独运行时因脚本自身的
`GDScriptNativeClass.clear_texture_cache()` 静态方法找不到而解析失败——本轮未改动此文件,
`git log` 确认最后一次改动早于本会话;这是与本分支无关的预置环境问题,未修复,仅记录。
`build-debug/` 的 `CMakeCache.txt` 记录的是另一个已废弃的检出路径
(`/Users/shen/Work/Code/Metin2/...`),导致 `cmake --build build-debug` 在本环境直接报错,
无法据此重跑 ctest/ASAN 套件;本轮改动只涉及 GDScript,未触及 `extension/` 下任何 C++,
判断为与本分支无关的预置构建环境问题,同样未修复,仅记录供后续轮次或维护者决定是否
重新配置构建目录。
**equivalence 更新**:Branch structure、Algorithms/formulas 两行的相机旋转子项由未闭合
改为已闭合(见上表),`Remaining` 移除相机副作用项、新增 Paralysis/Faint/Sleep 移动门
子项(见上)。合同整体仍为 `PARTIAL`——根运动/资源事件/同步受击门/Paralysis-Faint-Sleep
仍未闭合。