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>
This commit is contained in:
shenlei
2026-09-22 16:17:00 +09:00
co-authored by Claude Sonnet 5
parent c93894313a
commit 7df97e01f9
7 changed files with 354 additions and 139 deletions
+72 -3
View File
@@ -12,10 +12,15 @@
- `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`
@@ -81,8 +86,8 @@
| 项目 | 状态 | 备注 |
|---|---|---|
| Preconditions | PARTIAL | active/失焦/按键残留、锁门、私店、情绪和锁门 held WASD 已覆盖;同步、受击、死亡和传送门未全覆盖 |
| Branch structure | PARTIAL | W/S、A/D、八方向释放顺序已覆盖;相机旋转与所有禁止移动分支未闭合 |
| Algorithms/formulas | PARTIAL | 方向优先级归一化一致;固定 300 像素 Dst 与根运动被连续速度替代 |
| 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 驱动 |
@@ -95,7 +100,16 @@
- 用真实 `camera.heading()`、模型动作速度和网络 fake 记录同一方向序列的输入事件、移动目标、停止请求、动画状态和包时间线。
- 补充死亡/传送/受击以及私店、情绪、移动技能和普通锁定的多门叠加顺序;各单门 held WASD 恢复已覆盖基础路径。
- 继续对照 `.msa` 的 motion event、`AccumulationMovement` 根运动和当前 `get_move_motion_speeds` fallback,决定是否需要根运动适配层。
- 核对相机旋转副作用与角色方向叠加,以及真实角色模型在 W/S 快切时根节点/骨骼姿态不翻转。
- 真实角色模型在 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
@@ -153,3 +167,58 @@
- 继续对照 `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` 修复后全部
退出码 0Godot 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
仍未闭合。