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>
19 KiB
movement.keyboard-motion
Scope
比较键盘输入、WASD 快速切换、移动请求、方向/速度计算、动作选择和停止清理。
Reference call chain
UserInterface/PythonPlayerInputKeyboard.cppUserInterface/PythonPlayerInput.cppUserInterface/InstanceBaseMovement.cppUserInterface/InstanceBaseMotion.cppGameLib/ActorInstanceMotion.cppGameLib/ActorInstanceMotionEvent.cppGameLib/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.gdproject/game_camera.gdproject/net_play.gdproject/net_world.gdproject/test_wasd_steering_parity.gdproject/test_no_auto_move_regression.gdproject/player_motion_test.gdproject/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_speedsfallback,决定是否需要根运动适配层。 - 真实角色模型在 W/S 快切时根节点/骨骼姿态不翻转的模型级测试仍缺。
- 新增(2026-09-22 发现,未修复):
CPythonPlayer::__CanMove()还委托CActorInstance::CanAct(),后者在IsParalysis()/IsFaint()/IsSleep()时也拒绝移动 (ActorInstanceBattle.cppCanAct/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
仍未闭合。