# 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)