Files
mtgodot-poc/project/keyboard_motion_timeline_test.gd
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

208 lines
7.2 KiB
GDScript

# keyboard_motion_timeline_test —— 40250 键盘事件 -> 本地移动/停止时序回归。
# godot --headless --path project --script keyboard_motion_timeline_test.gd
extends SceneTree
const PlayerController = preload("res://player_controller.gd")
const GameCamera = preload("res://game_camera.gd")
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _key(code: Key, pressed: bool) -> InputEventKey:
var event := InputEventKey.new()
event.keycode = code
event.pressed = pressed
return event
func _init() -> void:
var player := Node3D.new()
var pc := PlayerController.new()
get_root().add_child(player)
get_root().add_child(pc)
pc.player = player
pc.default_run = false
pc.force_walk = true
var moved := []
var states := []
pc.moved.connect(func(pos: Vector3): moved.append(pos))
pc.anim_state.connect(func(state: String): states.append(state))
# A key pressed while the game phase is inactive must not be inherited.
pc.active = false
pc._unhandled_input(_key(KEY_W, true))
pc.active = true
pc._process(0.1)
_ck(is_zero_approx(player.position.length()), "inactive key-down does not move on game entry")
# 40250 updates direction immediately on each key event. W wins while W+S
# overlap; releasing W exposes the still-held S direction.
pc._unhandled_input(_key(KEY_W, true))
pc._process(0.1)
var after_w := player.position.z
_ck(after_w < -0.01 and not moved.is_empty(), "W key-down starts motion and emits moved")
pc._unhandled_input(_key(KEY_S, true))
pc._process(0.1)
_ck(player.position.z < after_w, "W keeps priority while W and S overlap")
pc._input(_key(KEY_W, false))
pc._process(0.1)
var after_release_w := player.position.z
_ck(after_release_w > after_w - 0.01, "releasing W switches to held S")
pc._input(_key(KEY_S, false))
var before_stop := player.position
pc._process(0.1)
_ck(player.position == before_stop, "releasing the last direction stops translation")
_ck(not states.is_empty() and states[states.size() - 1] == "wait",
"releasing the last direction emits wait state")
# Holding one direction must consume the reference's fixed 300-pixel target;
# it must not keep extending the destination every frame.
pc._unhandled_input(_key(KEY_W, true))
var fixed_target_z := pc._direction_dst.z
_ck(is_equal_approx(fixed_target_z, player.position.z - 3.0),
"W creates a fixed 300-pixel destination from the event position")
for _i in range(40):
pc._process(0.1)
_ck(absf(player.position.z - fixed_target_z) <= 0.06,
"held W stops at the fixed 300-pixel destination")
_ck(not pc._direction_active, "fixed keyboard destination is consumed once")
pc._input(_key(KEY_W, false))
# Reference CPythonPlayer keeps the direction bit while movement is locked
# and retries it from Update after the lock is released.
pc.locked = true
pc._unhandled_input(_key(KEY_W, true))
var locked_pos := player.position
pc._process(0.2)
_ck(player.position == locked_pos and pc._direction_resume_pending,
"locked held direction is deferred, not discarded")
pc.locked = false
pc._process(0.1)
_ck(player.position.z < locked_pos.z and not pc._direction_resume_pending,
"held direction resumes on the first unlocked update")
pc._input(_key(KEY_W, false))
# A moving skill owns the same motion edge: it may turn, but its old walking
# destination must not resume until the held direction is retried afterward.
pc.stop()
pc._unhandled_input(_key(KEY_W, true))
pc._process(0.1)
pc.moving_skill = true
var skill_pos := player.position
pc._process(0.1)
_ck(player.position == skill_pos and pc._direction_resume_pending,
"moving skill suppresses translation and defers held direction")
pc.moving_skill = false
pc._process(0.1)
_ck(player.position.z < skill_pos.z and not pc._direction_resume_pending,
"held direction resumes after moving skill ends")
pc._input(_key(KEY_W, false))
# 40250 __CanMove also rejects a main actor that owns a private shop or is
# processing an emotion. The direction bits remain latched and Update()
# retries after the server/entity state releases either gate.
pc.stop()
pc.set_private_shop_open(true)
pc._unhandled_input(_key(KEY_W, true))
var shop_pos := player.position
pc._process(0.1)
_ck(player.position == shop_pos and pc._direction_resume_pending,
"private shop blocks translation without losing held direction")
pc.set_private_shop_open(false)
pc._process(0.1)
_ck(player.position.z < shop_pos.z and not pc._direction_resume_pending,
"held direction resumes after private shop closes")
pc._input(_key(KEY_W, false))
pc.stop()
pc.set_processing_emotion(true)
pc._unhandled_input(_key(KEY_W, true))
var emotion_pos := player.position
pc._process(0.1)
_ck(player.position == emotion_pos and pc._direction_resume_pending,
"emotion processing blocks translation without losing held direction")
pc.set_processing_emotion(false)
pc._process(0.1)
_ck(player.position.z < emotion_pos.z and not pc._direction_resume_pending,
"held direction resumes after emotion ends")
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()
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()
pc.free()
cam.free()
if _fail == 0:
print("PASS: keyboard_motion_timeline_test")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)