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>
225 lines
8.1 KiB
GDScript
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)
|