Files
mtgodot-poc/project/keyboard_motion_timeline_test.gd
T
shenleiandClaude Sonnet 5 c5c183da37 fix(movement): remove unreferenced [0.25,3.0] clamp on local player's movSpd scale
CInstanceBase::SetMoveSpeed only guards moving_speed > 1100 -> 0; the local
player's set_server_speed() was additionally floor/ceil-clamping the scale to
an arbitrary [0.25, 3.0] band with no basis in the reference or in this
project's already-fixed remote-entity equivalent (EntityStore::motion_move_speed).
A heavy haste stack or slow debuff on the local player would silently diverge
from what everyone else sees. Removed the extra clamp; kept the moving_speed<=0
early-return guard against pre-spawn/malformed values.

audit: movement.keyboard.motion/server-speed-scale-clamp closed with reference
citation, regression test, and full related-suite pass; contract stays PARTIAL.

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

225 lines
8.1 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()
# CInstanceBase::SetMoveSpeed(UINT uMovSpd) applies uMovSpd/100.0f with no
# lower/upper clamp besides the >1100 -> 0 (frozen) guard; a haste stack or a
# heavy slow debuff must scale local prediction exactly like it already
# scales remote actors in EntityStore::motion_move_speed.
pc.set_server_speed(10)
_ck(is_equal_approx(pc.server_speed_scale, 0.1),
"set_server_speed is unclamped below the old 0.25 floor")
pc.set_server_speed(500)
_ck(is_equal_approx(pc.server_speed_scale, 5.0),
"set_server_speed is unclamped above the old 3.0 ceiling")
pc.set_server_speed(1200)
_ck(is_equal_approx(pc.server_speed_scale, 0.0),
"set_server_speed freezes to 0 past the 1100 SetMoveSpeed guard")
pc.set_server_speed(100)
_ck(is_equal_approx(pc.server_speed_scale, 1.0),
"set_server_speed keeps the 100 baseline at scale 1.0")
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)