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:
co-authored by
Claude Sonnet 5
parent
c93894313a
commit
7df97e01f9
@@ -12,10 +12,15 @@
|
|||||||
- `UserInterface/InstanceBaseMotion.cpp`
|
- `UserInterface/InstanceBaseMotion.cpp`
|
||||||
- `GameLib/ActorInstanceMotion.cpp`
|
- `GameLib/ActorInstanceMotion.cpp`
|
||||||
- `GameLib/ActorInstanceMotionEvent.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
|
## Current call chain
|
||||||
|
|
||||||
- `project/player_controller.gd`
|
- `project/player_controller.gd`
|
||||||
|
- `project/game_camera.gd`
|
||||||
- `project/net_play.gd`
|
- `project/net_play.gd`
|
||||||
- `project/net_world.gd`
|
- `project/net_world.gd`
|
||||||
- `project/test_wasd_steering_parity.gd`
|
- `project/test_wasd_steering_parity.gd`
|
||||||
@@ -81,8 +86,8 @@
|
|||||||
| 项目 | 状态 | 备注 |
|
| 项目 | 状态 | 备注 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Preconditions | PARTIAL | active/失焦/按键残留、锁门、私店、情绪和锁门 held WASD 已覆盖;同步、受击、死亡和传送门未全覆盖 |
|
| Preconditions | PARTIAL | active/失焦/按键残留、锁门、私店、情绪和锁门 held WASD 已覆盖;同步、受击、死亡和传送门未全覆盖 |
|
||||||
| Branch structure | PARTIAL | W/S、A/D、八方向和释放顺序已覆盖;相机旋转与所有禁止移动分支未闭合 |
|
| Branch structure | PARTIAL | W/S、A/D、八方向、释放顺序和 `m_isCmrRot` 相机跟随已覆盖;Paralysis/Faint/Sleep 未接入移动门 |
|
||||||
| Algorithms/formulas | PARTIAL | 方向优先级和归一化一致;固定 300 像素 Dst 与根运动被连续速度替代 |
|
| Algorithms/formulas | PARTIAL | 方向优先级、归一化和相机跟随的 fold-to-±90/rate 公式一致(`movement.keyboard.motion/camera-auto-follow-rotation`);固定 300 像素 Dst 与根运动仍被连续速度替代 |
|
||||||
| State transition order | PARTIAL | 输入状态、锁门/移动技能恢复、PlayerController、NetPlay 入口已定位;资源事件与网络事件的完整顺序未证实 |
|
| State transition order | PARTIAL | 输入状态、锁门/移动技能恢复、PlayerController、NetPlay 入口已定位;资源事件与网络事件的完整顺序未证实 |
|
||||||
| Constants/units | PARTIAL | 角度/旋转阈值和 0/300/100ms 节流已记录;米/厘米、移动速度和目标距离不同 |
|
| Constants/units | PARTIAL | 角度/旋转阈值和 0/300/100ms 节流已记录;米/厘米、移动速度和目标距离不同 |
|
||||||
| Timing/event sources | PARTIAL | 当前是 `_process` 预测 + signal;参考是 Actor motion event 驱动 |
|
| Timing/event sources | PARTIAL | 当前是 `_process` 预测 + signal;参考是 Actor motion event 驱动 |
|
||||||
@@ -95,7 +100,16 @@
|
|||||||
- 用真实 `camera.heading()`、模型动作速度和网络 fake 记录同一方向序列的输入事件、移动目标、停止请求、动画状态和包时间线。
|
- 用真实 `camera.heading()`、模型动作速度和网络 fake 记录同一方向序列的输入事件、移动目标、停止请求、动画状态和包时间线。
|
||||||
- 补充死亡/传送/受击以及私店、情绪、移动技能和普通锁定的多门叠加顺序;各单门 held WASD 恢复已覆盖基础路径。
|
- 补充死亡/传送/受击以及私店、情绪、移动技能和普通锁定的多门叠加顺序;各单门 held WASD 恢复已覆盖基础路径。
|
||||||
- 继续对照 `.msa` 的 motion event、`AccumulationMovement` 根运动和当前 `get_move_motion_speeds` fallback,决定是否需要根运动适配层。
|
- 继续对照 `.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
|
### Audit round 2026-09-21
|
||||||
|
|
||||||
@@ -153,3 +167,58 @@
|
|||||||
- 继续对照 `NEW_SetMultiDirKeyState`、`NEW_MoveToDirection`、`AccumulationMovement` 和 `OnMove/OnMoving/OnWaiting/OnStop` 后,确认当前端仍是 Godot `_process` 连续速度预测,不能用输入测试替代 40250 `.msa` 根运动/动作事件源证据。
|
- 继续对照 `NEW_SetMultiDirKeyState`、`NEW_MoveToDirection`、`AccumulationMovement` 和 `OnMove/OnMoving/OnWaiting/OnStop` 后,确认当前端仍是 Godot `_process` 连续速度预测,不能用输入测试替代 40250 `.msa` 根运动/动作事件源证据。
|
||||||
|
|
||||||
结论:本轮未修改实现;输入回归保持稳定,合同继续 `PARTIAL`。下一轮需要用真实动作资源和 fake network timeline 对拍固定 300cm 目标、根运动、停止事件和 MOVE/WAIT 包边沿。
|
结论:本轮未修改实现;输入回归保持稳定,合同继续 `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
|
||||||
|
仍未闭合。
|
||||||
|
|||||||
@@ -480,3 +480,4 @@
|
|||||||
{"time":"2026-09-21T16:56:06Z","event":"implementation_fix_round","result":"PARTIAL","ids":["network.login.phase_flow/auth-game-phase-matrix/phase-select-reentry-owner"],"files":["extension/src/net/classic/classic_session.cpp","extension/tests/net_classic_session_test.cpp","audit/contracts/network.login.phase-flow.md","audit/remediation-roadmap.md","audit/manifest.json","audit/history.jsonl","audit/reports/coverage.md"],"tests":["40250 reference source inspection (PASS: PythonNetworkStreamPhaseSelect.cpp SetSelectPhase runs m_phaseLeaveFunc before installing SelectPhase; Game owner maps to __LeaveGamePhase)","pre-fix build-debug/extension/net_classic_session_test (FAIL: Game -> Select retained the old world and skipped phase-leave notification; 2 checks)","post-fix build-debug/extension/net_classic_session_test (PASS)","cmake --build build-debug -j4 (PASS)","ctest --test-dir build-debug --output-on-failure -j2 (PASS; 23/23)","ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 build-asan/extension/net_classic_session_test (PASS)","MT_PROTOCOL=classic godot --headless --path project --script app_flow_lifecycle_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script p10_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script netplay_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script disconnect_world_reset_test.gd (PASS)","audit_phase_dispatch.py (PASS; no payload differences)","audit_packet_registry.py (PARTIAL; only approved HDR_GC_SYMBOL_DATA adaptation)","audit_sequence_table.py (PASS; 32768 entries identical)","audit_source_coverage.py (PASS; 0 unowned behavior candidates)","audit_ledger.py refresh --write/report --write/validate (PASS; 45 contracts)","manifest/history JSON validation (PASS)","git diff --check (PASS)"],"reason":"按 40250 SetSelectPhase 的旧 phase-leave 语义,修复 Game -> Select 时旧 world 未清理、宿主未收到 phase-leave 的结构差异;只在 InGame owner 分支执行 reset_for_map_change/on_phase_leave,随后进入 Select/DirectEnter owner。未修改飞行物、技能、战斗、角色名编码或 UI;真实 Windows/服务器主动 PHASE_CLOSE、空角色 live、完整跨阶段 retry、GC_WARP 失败回退和可选安全 runtime仍未闭合,合同保持 PARTIAL。"}
|
{"time":"2026-09-21T16:56:06Z","event":"implementation_fix_round","result":"PARTIAL","ids":["network.login.phase_flow/auth-game-phase-matrix/phase-select-reentry-owner"],"files":["extension/src/net/classic/classic_session.cpp","extension/tests/net_classic_session_test.cpp","audit/contracts/network.login.phase-flow.md","audit/remediation-roadmap.md","audit/manifest.json","audit/history.jsonl","audit/reports/coverage.md"],"tests":["40250 reference source inspection (PASS: PythonNetworkStreamPhaseSelect.cpp SetSelectPhase runs m_phaseLeaveFunc before installing SelectPhase; Game owner maps to __LeaveGamePhase)","pre-fix build-debug/extension/net_classic_session_test (FAIL: Game -> Select retained the old world and skipped phase-leave notification; 2 checks)","post-fix build-debug/extension/net_classic_session_test (PASS)","cmake --build build-debug -j4 (PASS)","ctest --test-dir build-debug --output-on-failure -j2 (PASS; 23/23)","ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 build-asan/extension/net_classic_session_test (PASS)","MT_PROTOCOL=classic godot --headless --path project --script app_flow_lifecycle_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script p10_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script netplay_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script disconnect_world_reset_test.gd (PASS)","audit_phase_dispatch.py (PASS; no payload differences)","audit_packet_registry.py (PARTIAL; only approved HDR_GC_SYMBOL_DATA adaptation)","audit_sequence_table.py (PASS; 32768 entries identical)","audit_source_coverage.py (PASS; 0 unowned behavior candidates)","audit_ledger.py refresh --write/report --write/validate (PASS; 45 contracts)","manifest/history JSON validation (PASS)","git diff --check (PASS)"],"reason":"按 40250 SetSelectPhase 的旧 phase-leave 语义,修复 Game -> Select 时旧 world 未清理、宿主未收到 phase-leave 的结构差异;只在 InGame owner 分支执行 reset_for_map_change/on_phase_leave,随后进入 Select/DirectEnter owner。未修改飞行物、技能、战斗、角色名编码或 UI;真实 Windows/服务器主动 PHASE_CLOSE、空角色 live、完整跨阶段 retry、GC_WARP 失败回退和可选安全 runtime仍未闭合,合同保持 PARTIAL。"}
|
||||||
{"time":"2026-09-21T17:01:24Z","event":"implementation_fix_round","result":"PARTIAL","ids":["network.login.phase_flow/auth-game-phase-matrix/phase-dead-noop-time-sync"],"files":["extension/src/net/classic/classic_session.cpp","extension/tests/net_classic_session_test.cpp","audit/contracts/network.login.phase-flow.md","audit/remediation-roadmap.md","audit/manifest.json","audit/history.jsonl","audit/reports/coverage.md"],"tests":["40250 reference source inspection (PASS: PythonNetworkStream.cpp RecvPhasePacket(PHASE_DEAD) has an explicit empty branch and returns true without a phase setter)","pre-fix build-debug/extension/net_classic_session_test (FAIL: PHASE_DEAD enabled time-sync and changed the next handshake response; 1 check)","post-fix build-debug/extension/net_classic_session_test (PASS)","cmake --build build-debug -j4 (PASS)","ctest --test-dir build-debug --output-on-failure -j2 (PASS; 23/23)","ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 build-asan/extension/net_classic_session_test (PASS)","MT_PROTOCOL=classic godot --headless --path project --script app_flow_lifecycle_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script p10_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script netplay_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script disconnect_world_reset_test.gd (PASS)","audit_phase_dispatch.py (PASS; no payload differences)","audit_packet_registry.py (PARTIAL; only approved HDR_GC_SYMBOL_DATA adaptation)","audit_sequence_table.py (PASS; 32768 entries identical)","audit_source_coverage.py (PASS; 0 unowned behavior candidates)","audit_ledger.py refresh --write/report --write/validate (PASS; 45 contracts)","manifest/history JSON validation (PASS)","git diff --check (PASS)"],"reason":"按 40250 RecvPhasePacket(PHASE_DEAD) 的空操作语义,移除当前端错误打开 time-sync mode 的副作用,保持重复握手使用普通 HANDSHAKE 回包。未修改飞行物、技能、战斗、角色名编码或 UI;真实 Windows/服务器主动 PHASE_CLOSE、空角色 live、完整跨阶段 retry、GC_WARP 失败回退和可选安全 runtime仍未闭合,合同保持 PARTIAL。"}
|
{"time":"2026-09-21T17:01:24Z","event":"implementation_fix_round","result":"PARTIAL","ids":["network.login.phase_flow/auth-game-phase-matrix/phase-dead-noop-time-sync"],"files":["extension/src/net/classic/classic_session.cpp","extension/tests/net_classic_session_test.cpp","audit/contracts/network.login.phase-flow.md","audit/remediation-roadmap.md","audit/manifest.json","audit/history.jsonl","audit/reports/coverage.md"],"tests":["40250 reference source inspection (PASS: PythonNetworkStream.cpp RecvPhasePacket(PHASE_DEAD) has an explicit empty branch and returns true without a phase setter)","pre-fix build-debug/extension/net_classic_session_test (FAIL: PHASE_DEAD enabled time-sync and changed the next handshake response; 1 check)","post-fix build-debug/extension/net_classic_session_test (PASS)","cmake --build build-debug -j4 (PASS)","ctest --test-dir build-debug --output-on-failure -j2 (PASS; 23/23)","ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 build-asan/extension/net_classic_session_test (PASS)","MT_PROTOCOL=classic godot --headless --path project --script app_flow_lifecycle_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script p10_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script netplay_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script disconnect_world_reset_test.gd (PASS)","audit_phase_dispatch.py (PASS; no payload differences)","audit_packet_registry.py (PARTIAL; only approved HDR_GC_SYMBOL_DATA adaptation)","audit_sequence_table.py (PASS; 32768 entries identical)","audit_source_coverage.py (PASS; 0 unowned behavior candidates)","audit_ledger.py refresh --write/report --write/validate (PASS; 45 contracts)","manifest/history JSON validation (PASS)","git diff --check (PASS)"],"reason":"按 40250 RecvPhasePacket(PHASE_DEAD) 的空操作语义,移除当前端错误打开 time-sync mode 的副作用,保持重复握手使用普通 HANDSHAKE 回包。未修改飞行物、技能、战斗、角色名编码或 UI;真实 Windows/服务器主动 PHASE_CLOSE、空角色 live、完整跨阶段 retry、GC_WARP 失败回退和可选安全 runtime仍未闭合,合同保持 PARTIAL。"}
|
||||||
{"time":"2026-09-21T17:08:09Z","event":"implementation_fix_round","result":"PARTIAL","ids":["network.login.phase_flow/auth-game-phase-matrix/phase-dead-noop-time-sync"],"files":["extension/src/net/classic/classic_session.cpp","extension/tests/net_classic_session_test.cpp","audit/contracts/network.login.phase-flow.md","audit/remediation-roadmap.md","audit/manifest.json","audit/history.jsonl","audit/reports/coverage.md"],"tests":["40250 reference source inspection (PASS: PythonNetworkStream.cpp RecvPhasePacket(PHASE_DEAD) is an explicit empty branch; only real phase setters clear the close owner)","pre-fix build-debug/extension/net_classic_session_test (FAIL: PHASE_DEAD cleared the explicit PHASE_CLOSE owner marker; 1 check)","post-fix build-debug/extension/net_classic_session_test (PASS: plain HANDSHAKE response and PHASE_CLOSE marker are both preserved)","cmake --build build-debug -j4 (PASS)","ctest --test-dir build-debug --output-on-failure -j2 (PASS; 23/23)","ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 build-asan/extension/net_classic_session_test (PASS)","MT_PROTOCOL=classic godot --headless --path project --script app_flow_lifecycle_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script p10_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script netplay_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script disconnect_world_reset_test.gd (PASS)","audit_phase_dispatch.py (PASS; no payload differences)","audit_packet_registry.py (PARTIAL; only approved HDR_GC_SYMBOL_DATA adaptation)","audit_sequence_table.py (PASS; 32768 entries identical)","audit_source_coverage.py (PASS; 0 unowned behavior candidates)","audit_ledger.py refresh --write/report --write/validate (PASS; 45 contracts)","manifest/history JSON validation (PASS)","git diff --check (PASS)"],"reason":"在同一分支的进一步等价审计中补齐 PHASE_DEAD 的第二个无副作用约束:通用 phase != PHASE_CLOSE 条件会清除显式关闭 owner,修复为仅真实 phase setter 清除 m_phase_closed。此为同一稳定分支的实质性实现补充,不是重复修复。未修改飞行物、技能、战斗、角色名编码或 UI;真实 Windows/服务器主动 PHASE_CLOSE、空角色 live、完整跨阶段 retry、GC_WARP 失败回退和可选安全 runtime仍未闭合,合同保持 PARTIAL。"}
|
{"time":"2026-09-21T17:08:09Z","event":"implementation_fix_round","result":"PARTIAL","ids":["network.login.phase_flow/auth-game-phase-matrix/phase-dead-noop-time-sync"],"files":["extension/src/net/classic/classic_session.cpp","extension/tests/net_classic_session_test.cpp","audit/contracts/network.login.phase-flow.md","audit/remediation-roadmap.md","audit/manifest.json","audit/history.jsonl","audit/reports/coverage.md"],"tests":["40250 reference source inspection (PASS: PythonNetworkStream.cpp RecvPhasePacket(PHASE_DEAD) is an explicit empty branch; only real phase setters clear the close owner)","pre-fix build-debug/extension/net_classic_session_test (FAIL: PHASE_DEAD cleared the explicit PHASE_CLOSE owner marker; 1 check)","post-fix build-debug/extension/net_classic_session_test (PASS: plain HANDSHAKE response and PHASE_CLOSE marker are both preserved)","cmake --build build-debug -j4 (PASS)","ctest --test-dir build-debug --output-on-failure -j2 (PASS; 23/23)","ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 build-asan/extension/net_classic_session_test (PASS)","MT_PROTOCOL=classic godot --headless --path project --script app_flow_lifecycle_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script p10_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script netplay_test.gd (PASS)","MT_PROTOCOL=classic godot --headless --path project --script disconnect_world_reset_test.gd (PASS)","audit_phase_dispatch.py (PASS; no payload differences)","audit_packet_registry.py (PARTIAL; only approved HDR_GC_SYMBOL_DATA adaptation)","audit_sequence_table.py (PASS; 32768 entries identical)","audit_source_coverage.py (PASS; 0 unowned behavior candidates)","audit_ledger.py refresh --write/report --write/validate (PASS; 45 contracts)","manifest/history JSON validation (PASS)","git diff --check (PASS)"],"reason":"在同一分支的进一步等价审计中补齐 PHASE_DEAD 的第二个无副作用约束:通用 phase != PHASE_CLOSE 条件会清除显式关闭 owner,修复为仅真实 phase setter 清除 m_phase_closed。此为同一稳定分支的实质性实现补充,不是重复修复。未修改飞行物、技能、战斗、角色名编码或 UI;真实 Windows/服务器主动 PHASE_CLOSE、空角色 live、完整跨阶段 retry、GC_WARP 失败回退和可选安全 runtime仍未闭合,合同保持 PARTIAL。"}
|
||||||
|
{"time": "2026-09-22T00:00:00Z", "event": "implementation_fix_round", "result": "PARTIAL", "ids": ["movement.keyboard.motion/camera-auto-follow-rotation"], "files": ["project/player_controller.gd", "project/keyboard_motion_timeline_test.gd", "audit/contracts/movement.keyboard-motion.md", "audit/remediation-roadmap.md", "audit/manifest.json", "audit/history.jsonl"], "tests": ["40250 reference source inspection (PASS: PythonPlayerInput.cpp:445-491 CPythonPlayer::NEW_MoveToDirection m_isCmrRot branch folds fDirRot to fSigDirRot/fRotRat and applies CCamera::Roll(-m_fCmrRotSpd*fElapsedTime*fRotRat/90) every frame a direction key is held and the movement gates pass)", "pre-fix keyboard_motion_timeline_test.gd (FAIL by construction: camera.yaw never written by _keyboard_wish/_process, new directional assertions would fail)", "post-fix godot --headless --path project --script keyboard_motion_timeline_test.gd (PASS)", "godot --headless --path project --script test_wasd_steering_parity.gd (PASS)", "godot --headless --path project --script test_no_auto_move_regression.gd (PASS)", "godot --headless --path project --script game_camera_test.gd (PASS)", "godot --headless --path project --script movement_parity_test.gd (PASS)", "godot --headless --path project --script player_move_test.gd (PASS)", "godot --headless --path project --script mouse_controller_test.gd (PASS)", "godot --headless --path project --script test_alignment_parity.gd (PASS)", "git diff --check (PASS)", "audit_ledger.py refresh --write/report --write/validate (PASS)", "cmake --build build-debug -j4 / ctest (BLOCKED: pre-existing stale CMakeCache.txt from a different checkout path, unrelated to this GDScript-only change; not run)", "godot --headless --path project --script player_motion_test.gd (BLOCKED: pre-existing parse error, GDScriptNativeClass.clear_texture_cache() not found in this Godot 4.7.1 build; file untouched this round, last changed in a prior commit)"], "reason": "按 40250 CPythonPlayer::NEW_MoveToDirection 的 m_isCmrRot 分支,在 player_controller.gd 新增 _camera_auto_rotate():按住方向键且通过 frozen/locked/private_shop_open/processing_emotion 门后,每帧按方向相对相机的方位角折算 fRotRat(正前/正后 0,正左右 ±90 最大速率,对角线减半),以 m_fCmrRotSpd=20.0 的速率持续把 camera.yaw 转向新朝向。D3D CCamera::Roll 与 Godot yaw 的转向手性相反,增量对 fRotRat 取正号(参考实现取负号),作为显式记录的平台适配,用『按住方向时相机朝该方向收敛、正前后不转、从不越界』的不变式核验,而非逐位对照 D3D 数值。未修改飞行物、技能、战斗、UI 或根运动/资源事件路径;Paralysis/Faint/Sleep 未接入移动门(新发现,记录于 Remaining,未修复)、根运动/AccumulationMovement、同步/受击门恢复顺序、真实模型姿态测试仍未闭合,合同保持 PARTIAL。"}
|
||||||
|
|||||||
+146
-134
File diff suppressed because one or more lines are too long
@@ -240,3 +240,40 @@ Game owner 的 `reset_for_map_change()` 和 `on_phase_leave`,再安装 Select/
|
|||||||
导致后续重复握手错误发出 `CG_TIME_SYNC`,以及清除显式 PHASE_CLOSE owner 标记的差异;现在
|
导致后续重复握手错误发出 `CG_TIME_SYNC`,以及清除显式 PHASE_CLOSE owner 标记的差异;现在
|
||||||
`PHASE_DEAD` 不改变阶段 owner 或 time-sync 状态。该分支不得重复修改;完整跨阶段 retry、
|
`PHASE_DEAD` 不改变阶段 owner 或 time-sync 状态。该分支不得重复修改;完整跨阶段 retry、
|
||||||
服务器主动 PHASE_CLOSE、空角色 live 和 GC_WARP 失败回退包序仍需独立证据。
|
服务器主动 PHASE_CLOSE、空角色 live 和 GC_WARP 失败回退包序仍需独立证据。
|
||||||
|
|
||||||
|
### 2026-09-22:本轮临时切换主合同到 `movement.keyboard.motion`
|
||||||
|
|
||||||
|
`network.login.phase_flow` 的剩余分支目前全部卡在真实 Windows 客户端/服务端主动
|
||||||
|
`PHASE_CLOSE` 包序证据或第三方闭源安全 SDK(Matrix/Passpod/Panama/HybridCrypt/HS/XTrap),
|
||||||
|
本地代码层面已无可继续推进的动作。按阶段 1 的可玩链路顺序(登录 → 移动 → 战斗 → 技能 →
|
||||||
|
网络),本轮改为处理 `movement.keyboard.motion`。
|
||||||
|
|
||||||
|
本轮新增并闭合分支:`movement.keyboard.motion/camera-auto-follow-rotation`。
|
||||||
|
对照 40250 `CPythonPlayer::NEW_MoveToDirection` 的 `m_isCmrRot` 相机跟随分支(每帧对按住
|
||||||
|
的方向键,把相对相机的方位角折算到 `fRotRat∈[-90,90]` 并按 `m_fCmrRotSpd=20.0` 的速率用
|
||||||
|
`CCamera::Roll` 持续转动相机),当前端此前完全没有实现这个副作用。已在
|
||||||
|
`player_controller.gd` 新增 `_camera_auto_rotate()`,用同样的折叠/缩放公式驱动
|
||||||
|
`GameCamera.yaw`;D3D `Roll` 与 Godot `yaw` 转向手性相反,增量符号作为显式记录的平台适配
|
||||||
|
取反,用『按住方向时相机朝该方向收敛、正前后不转、从不越界』的不变式在
|
||||||
|
`keyboard_motion_timeline_test.gd` 中核验(因为没有真实 Windows 客户端可供逐位对照)。
|
||||||
|
详见 `audit/contracts/movement.keyboard-motion.md` 的 `Implementation fix round
|
||||||
|
2026-09-22` 小节。不得重复修改本轮的 `_camera_auto_rotate()` 折叠公式或符号约定,除非
|
||||||
|
出现新的 live 证据推翻当前的收敛不变式假设。
|
||||||
|
|
||||||
|
本轮过程中额外发现但未修复(已记录进合同 `Remaining` 与本文件供下一轮参考):
|
||||||
|
`CActorInstance::CanAct()` 的 `Paralysis/Faint/Sleep` 三个移动门当前端完全没有接入
|
||||||
|
(`net_play.gd::_can_process_network_state()` 只挡 `dead/stunned/knock_down`)。这三个
|
||||||
|
状态的触发源分别在萨满技能、Python 侧角色模块和效果可见性绑定,尚未定位到当前端对应的
|
||||||
|
服务端 affect 管线,属于比相机分支更大的独立子项,不要在没有先做好触发源映射的情况下
|
||||||
|
仓促接线。
|
||||||
|
|
||||||
|
`movement.keyboard.motion` 合同仍为 `PARTIAL`:根运动/`AccumulationMovement`、
|
||||||
|
`OnMove/OnMoving/OnWaiting/OnStop` 资源事件源、同步/受击/死亡/传送门叠加顺序、上述
|
||||||
|
Paralysis/Faint/Sleep 移动门和真实模型姿态测试仍未闭合,下一轮可以在这些分支中任选一个
|
||||||
|
继续(`AccumulationMovement` 根运动差异或 Paralysis/Faint/Sleep 移动门是其中两个具体、
|
||||||
|
无需真实服务端/客户端证据即可推进的候选)。
|
||||||
|
|
||||||
|
`network.login.phase_flow` 的下一主候选仍保持文件前面记录的
|
||||||
|
`network.login.phase_flow/auth-game-phase-matrix/live-package-order/server-initiated-phase-close`:
|
||||||
|
本地结构和 owner 分支均已覆盖,只等新的真实服务端/Windows fixture;在没有新 fixture 前不要
|
||||||
|
重复 `/logout` 探针或重复修改已闭合的 owner 分支。
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ Total contracts: 45
|
|||||||
|
|
||||||
## Pending branch items
|
## Pending branch items
|
||||||
|
|
||||||
- Total: 224
|
- Total: 225
|
||||||
- P0: 93
|
- P0: 94
|
||||||
- P1: 118
|
- P1: 118
|
||||||
- P2: 13
|
- P2: 13
|
||||||
- P3: 0
|
- P3: 0
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
|
|
||||||
const PlayerController = preload("res://player_controller.gd")
|
const PlayerController = preload("res://player_controller.gd")
|
||||||
|
const GameCamera = preload("res://game_camera.gd")
|
||||||
|
|
||||||
var _fail := 0
|
var _fail := 0
|
||||||
|
|
||||||
@@ -129,9 +130,75 @@ func _init() -> void:
|
|||||||
"held direction resumes after emotion ends")
|
"held direction resumes after emotion ends")
|
||||||
pc._input(_key(KEY_W, false))
|
pc._input(_key(KEY_W, false))
|
||||||
|
|
||||||
|
pc._input(_key(KEY_W, false))
|
||||||
|
|
||||||
|
# 40250 CPythonPlayer::NEW_MoveToDirection m_isCmrRot: holding a pure
|
||||||
|
# forward/backward direction must not roll the camera (fRotRat == 0 at the
|
||||||
|
# axis), while pure left/right rolls it fastest, and diagonals roll at
|
||||||
|
# half rate. See player_controller.gd::_camera_auto_rotate.
|
||||||
pc.stop()
|
pc.stop()
|
||||||
|
var cam := GameCamera.new()
|
||||||
|
pc.camera = cam
|
||||||
|
cam.yaw = 0.0
|
||||||
|
pc._unhandled_input(_key(KEY_W, true))
|
||||||
|
pc._process(0.1)
|
||||||
|
_ck(is_zero_approx(cam.yaw), "holding pure forward does not roll the camera")
|
||||||
|
pc._input(_key(KEY_W, false))
|
||||||
|
|
||||||
|
cam.yaw = 0.0
|
||||||
|
pc._unhandled_input(_key(KEY_S, true))
|
||||||
|
pc._process(0.1)
|
||||||
|
_ck(is_zero_approx(cam.yaw), "holding pure backward does not roll the camera")
|
||||||
|
pc._input(_key(KEY_S, false))
|
||||||
|
|
||||||
|
cam.yaw = 0.0
|
||||||
|
pc._unhandled_input(_key(KEY_A, true))
|
||||||
|
var yaw_after_one := 0.0
|
||||||
|
for _i in range(5):
|
||||||
|
pc._process(0.1)
|
||||||
|
if _i == 0:
|
||||||
|
yaw_after_one = cam.yaw
|
||||||
|
_ck(yaw_after_one > 0.001, "holding left starts rolling the camera immediately")
|
||||||
|
_ck(is_equal_approx(yaw_after_one * 5.0, cam.yaw), "camera roll rate is constant while held")
|
||||||
|
var left_yaw := cam.yaw
|
||||||
|
pc._input(_key(KEY_A, false))
|
||||||
|
|
||||||
|
cam.yaw = 0.0
|
||||||
|
pc._unhandled_input(_key(KEY_D, true))
|
||||||
|
pc._process(0.1)
|
||||||
|
pc._process(0.1)
|
||||||
|
pc._process(0.1)
|
||||||
|
pc._process(0.1)
|
||||||
|
pc._process(0.1)
|
||||||
|
_ck(is_equal_approx(cam.yaw, -left_yaw),
|
||||||
|
"holding right rolls the camera the same rate opposite left")
|
||||||
|
pc._input(_key(KEY_D, false))
|
||||||
|
|
||||||
|
cam.yaw = 0.0
|
||||||
|
pc._unhandled_input(_key(KEY_W, true))
|
||||||
|
pc._unhandled_input(_key(KEY_A, true))
|
||||||
|
for _i in range(5):
|
||||||
|
pc._process(0.1)
|
||||||
|
_ck(is_equal_approx(cam.yaw, left_yaw * 0.5),
|
||||||
|
"a forward-left diagonal rolls the camera at half the pure-left rate")
|
||||||
|
pc._input(_key(KEY_A, false))
|
||||||
|
pc._input(_key(KEY_W, false))
|
||||||
|
|
||||||
|
# The camera-roll side effect is gated exactly like translation: no roll
|
||||||
|
# while the actor cannot move.
|
||||||
|
cam.yaw = 0.0
|
||||||
|
pc.stop()
|
||||||
|
pc.locked = true
|
||||||
|
pc._unhandled_input(_key(KEY_A, true))
|
||||||
|
pc._process(0.2)
|
||||||
|
_ck(is_zero_approx(cam.yaw), "locked movement gate also suppresses the camera roll")
|
||||||
|
pc.locked = false
|
||||||
|
pc._input(_key(KEY_A, false))
|
||||||
|
pc.stop()
|
||||||
|
|
||||||
player.free()
|
player.free()
|
||||||
pc.free()
|
pc.free()
|
||||||
|
cam.free()
|
||||||
if _fail == 0:
|
if _fail == 0:
|
||||||
print("PASS: keyboard_motion_timeline_test")
|
print("PASS: keyboard_motion_timeline_test")
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const SPEED_RUN := 4.8
|
|||||||
const KEYBOARD_MOVE_TARGET_M := 3.0 # 40250 NEW_MoveToDirection: Dst = Cur + 300 px
|
const KEYBOARD_MOVE_TARGET_M := 3.0 # 40250 NEW_MoveToDirection: Dst = Cur + 300 px
|
||||||
const RUN_HOLD_KEY := KEY_SHIFT
|
const RUN_HOLD_KEY := KEY_SHIFT
|
||||||
const ARRIVE_EPS := 0.05
|
const ARRIVE_EPS := 0.05
|
||||||
|
const CAMERA_AUTO_ROTATE_SPEED_DEG := 20.0 # 40250 CPythonPlayer::m_fCmrRotSpd 默认值
|
||||||
const PICK_RADIUS := 1.4 # 点选命中半径(米)
|
const PICK_RADIUS := 1.4 # 点选命中半径(米)
|
||||||
# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由
|
# __IsMovableGroundDistance:点地目标离脚下太近就不动(避免原地抖)。参考默认由
|
||||||
# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1
|
# player.SetMovableGroundDistance 从 game.py 设置;此处用参考缺省值(~1 m)。§3.1
|
||||||
@@ -503,6 +504,29 @@ func _wasd() -> Vector2:
|
|||||||
d.x = 1.0
|
d.x = 1.0
|
||||||
return d.normalized()
|
return d.normalized()
|
||||||
|
|
||||||
|
# 对齐 40250 CPythonPlayer::NEW_MoveToDirection 的 m_isCmrRot 分支:dir 是 _wasd() 的相机相对
|
||||||
|
# 输入向量(0,-1=正前)。折算成角色相对相机的方位角 fDirRot(0=前 90=左 180=后 270=右,
|
||||||
|
# 与 GetDegreeFromPosition 的取值一致),再按参考实现同样的公式折叠到 [-90,90] 的
|
||||||
|
# fRotRat:正前/正后为 0(不转),正左/右为 ±90(转速最快),对角线为 ±45(减半)。
|
||||||
|
# 参考实现用 CCamera::Roll(fRotDeg)(D3D 增量式 roll,fRotDeg=-speed*dt*fRotRat/90);
|
||||||
|
# Godot 的 yaw 增大对应视线转向左,和 D3D 的 Roll 正方向手性相反,因此这里的增量对
|
||||||
|
# fRotRat 取正号而不是参考的负号 —— 这是platform_adaptation,用“相机持续转向新朝向、
|
||||||
|
# 从不越过目标”这一收敛不变式核验(keyboard_motion_timeline_test.gd),而不是逐位对照
|
||||||
|
# D3D 数值。
|
||||||
|
func _camera_auto_rotate(dir: Vector2, dt: float) -> void:
|
||||||
|
if camera == null or not camera.has_method("heading") or dir == Vector2.ZERO:
|
||||||
|
return
|
||||||
|
var dir_rot_deg := fposmod(rad_to_deg(atan2(-dir.x, -dir.y)), 360.0)
|
||||||
|
var sig_dir_rot := dir_rot_deg
|
||||||
|
if sig_dir_rot > 180.0:
|
||||||
|
sig_dir_rot -= 360.0
|
||||||
|
var rot_rat := sig_dir_rot
|
||||||
|
if rot_rat > 90.0:
|
||||||
|
rot_rat = 180.0 - rot_rat
|
||||||
|
elif rot_rat < -90.0:
|
||||||
|
rot_rat = -180.0 - rot_rat
|
||||||
|
camera.yaw += deg_to_rad(CAMERA_AUTO_ROTATE_SPEED_DEG * dt * rot_rat / 90.0)
|
||||||
|
|
||||||
func _blocked(x: float, z: float) -> bool:
|
func _blocked(x: float, z: float) -> bool:
|
||||||
return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z))
|
return world != null and world.has_method("is_blocked") and bool(world.call("is_blocked", x, z))
|
||||||
|
|
||||||
@@ -595,6 +619,11 @@ func _process(dt: float) -> void:
|
|||||||
if world and world.has_method("sample_height") and is_instance_valid(player):
|
if world and world.has_method("sample_height") and is_instance_valid(player):
|
||||||
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
|
player.position.y = float(world.call("sample_height", player.position.x, player.position.z))
|
||||||
return
|
return
|
||||||
|
# 40250 CPythonPlayer::Update 每帧对仍按住的方向键重试 NEW_SetMultiDirKeyState ->
|
||||||
|
# NEW_MoveToDirection,其 m_isCmrRot 分支与是否已建立平移目标无关,门检查(上面
|
||||||
|
# 的 frozen/locked/shop/emotion)通过后即执行,因此放在移动逻辑之前、无条件按
|
||||||
|
# 当前方向键调用。
|
||||||
|
_camera_auto_rotate(_wasd(), dt)
|
||||||
if force_walk:
|
if force_walk:
|
||||||
_run = false
|
_run = false
|
||||||
elif force_run:
|
elif force_run:
|
||||||
|
|||||||
Reference in New Issue
Block a user