gmp(M02): extract local player movement
Work-Package: M02-GMP-MOVEMENT-001 Agent: sindo-main-codex Tests: local movement and player input regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates Fidelity: preserves current sandbox speed, acceleration, sprint and camera-relative flight behavior; build-12340 parity remains unverified
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
class_name LocalPlayerMovementController
|
||||
extends RefCounted
|
||||
|
||||
## Scene-free state and velocity integrator for the current local sandbox player.
|
||||
## Direction inputs use a Godot-world movement basis selected by the scene
|
||||
## adapter. This class does not discover Nodes, query terrain or mutate transforms.
|
||||
|
||||
var godot_world_velocity_units_per_second: Vector3:
|
||||
get:
|
||||
return _godot_world_velocity_units_per_second
|
||||
|
||||
var is_flight_enabled: bool:
|
||||
get:
|
||||
return _is_flight_enabled
|
||||
|
||||
var _run_speed_units_per_second: float
|
||||
var _backward_speed_units_per_second: float
|
||||
var _strafe_speed_units_per_second: float
|
||||
var _flight_vertical_speed_units_per_second: float
|
||||
var _acceleration_units_per_second_squared: float
|
||||
var _sandbox_sprint_multiplier: float
|
||||
var _godot_world_velocity_units_per_second := Vector3.ZERO
|
||||
var _is_flight_enabled := false
|
||||
|
||||
|
||||
## Creates a movement state with explicit Godot-unit speeds and acceleration.
|
||||
## The current compatibility renderer treats one Godot unit as one WoW yard.
|
||||
func _init(
|
||||
run_speed_units_per_second: float,
|
||||
backward_speed_units_per_second: float,
|
||||
strafe_speed_units_per_second: float,
|
||||
flight_vertical_speed_units_per_second: float,
|
||||
acceleration_units_per_second_squared: float,
|
||||
sandbox_sprint_multiplier: float
|
||||
) -> void:
|
||||
_run_speed_units_per_second = run_speed_units_per_second
|
||||
_backward_speed_units_per_second = backward_speed_units_per_second
|
||||
_strafe_speed_units_per_second = strafe_speed_units_per_second
|
||||
_flight_vertical_speed_units_per_second = flight_vertical_speed_units_per_second
|
||||
_acceleration_units_per_second_squared = acceleration_units_per_second_squared
|
||||
_sandbox_sprint_multiplier = sandbox_sprint_multiplier
|
||||
|
||||
|
||||
## Toggles sandbox free flight and clears velocity exactly like the pre-M02 controller.
|
||||
## Returns the new flight state.
|
||||
func toggle_sandbox_flight() -> bool:
|
||||
_is_flight_enabled = not _is_flight_enabled
|
||||
_godot_world_velocity_units_per_second = Vector3.ZERO
|
||||
return _is_flight_enabled
|
||||
|
||||
|
||||
## Advances velocity by one physics step and returns local-world displacement.
|
||||
## `godot_world_movement_basis` is the player basis on ground and camera-pivot basis in flight.
|
||||
## Negative delta values are treated as zero and do not mutate velocity.
|
||||
func advance(
|
||||
move_intent: MoveIntent,
|
||||
godot_world_movement_basis: Basis,
|
||||
delta_seconds: float
|
||||
) -> Vector3:
|
||||
var safe_delta_seconds := maxf(delta_seconds, 0.0)
|
||||
if is_zero_approx(safe_delta_seconds):
|
||||
return Vector3.ZERO
|
||||
|
||||
var target_velocity := _calculate_target_velocity(move_intent, godot_world_movement_basis)
|
||||
_godot_world_velocity_units_per_second = _godot_world_velocity_units_per_second.move_toward(
|
||||
target_velocity,
|
||||
_acceleration_units_per_second_squared * safe_delta_seconds
|
||||
)
|
||||
return _godot_world_velocity_units_per_second * safe_delta_seconds
|
||||
|
||||
|
||||
func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_basis: Basis) -> Vector3:
|
||||
var forward_direction := -godot_world_movement_basis.z
|
||||
var right_direction := godot_world_movement_basis.x
|
||||
if not _is_flight_enabled:
|
||||
forward_direction.y = 0.0
|
||||
right_direction.y = 0.0
|
||||
forward_direction = forward_direction.normalized()
|
||||
right_direction = right_direction.normalized()
|
||||
|
||||
var forward_speed := (
|
||||
_run_speed_units_per_second
|
||||
if move_intent.forward_axis > 0.0
|
||||
else _backward_speed_units_per_second
|
||||
)
|
||||
var speed_multiplier := _sandbox_sprint_multiplier if move_intent.is_sprint_requested else 1.0
|
||||
var target_velocity := (
|
||||
forward_direction * move_intent.forward_axis * forward_speed
|
||||
+ right_direction * move_intent.strafe_axis * _strafe_speed_units_per_second
|
||||
) * speed_multiplier
|
||||
if _is_flight_enabled:
|
||||
target_velocity.y += (
|
||||
move_intent.vertical_axis
|
||||
* _flight_vertical_speed_units_per_second
|
||||
* speed_multiplier
|
||||
)
|
||||
return target_velocity
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxlx5kloh7p5c
|
||||
@@ -9,6 +9,7 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti
|
||||
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
||||
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
|
||||
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
|
||||
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
|
||||
|
||||
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
||||
@@ -54,14 +55,21 @@ var _active_animation := ""
|
||||
var _captured := false
|
||||
var _yaw := 0.0
|
||||
var _pitch := deg_to_rad(-18.0)
|
||||
var _horizontal_velocity := Vector3.ZERO
|
||||
var _flight_enabled := false
|
||||
var _adt_cache: Dictionary = {}
|
||||
var _player_input_source: PlayerInputSource
|
||||
var _local_movement_controller: LocalPlayerMovementController
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
|
||||
_local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new(
|
||||
run_speed,
|
||||
backward_speed,
|
||||
strafe_speed,
|
||||
flight_vertical_speed,
|
||||
acceleration,
|
||||
sprint_multiplier
|
||||
)
|
||||
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
|
||||
_camera = get_node_or_null(camera_path) as Camera3D
|
||||
_visual = get_node_or_null(visual_path) as Node3D
|
||||
@@ -99,8 +107,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
||||
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||
_flight_enabled = not _flight_enabled
|
||||
_horizontal_velocity = Vector3.ZERO
|
||||
_local_movement_controller.toggle_sandbox_flight()
|
||||
|
||||
if _captured and event is InputEventMouseMotion:
|
||||
_yaw -= event.relative.x * mouse_sensitivity
|
||||
@@ -111,54 +118,26 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
var move_intent := _player_input_source.sample_move_intent()
|
||||
var target_velocity := Vector3.ZERO
|
||||
if move_intent.has_translation():
|
||||
target_velocity = _movement_vector(move_intent)
|
||||
if _flight_enabled:
|
||||
target_velocity.y += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent)
|
||||
var godot_world_movement_basis := global_basis
|
||||
if _local_movement_controller.is_flight_enabled and _camera_pivot:
|
||||
godot_world_movement_basis = _camera_pivot.global_basis
|
||||
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
||||
|
||||
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
|
||||
global_position += _horizontal_velocity * delta
|
||||
|
||||
if not _flight_enabled:
|
||||
if not _local_movement_controller.is_flight_enabled:
|
||||
var ground := _sample_ground_height(global_position)
|
||||
if is_finite(ground):
|
||||
var target_y := ground + ground_offset
|
||||
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
|
||||
|
||||
var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z)
|
||||
var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
|
||||
var horizontal_motion := Vector2(movement_velocity.x, movement_velocity.z)
|
||||
if _visual and horizontal_motion.length_squared() > 0.01:
|
||||
_visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z)
|
||||
_visual.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
|
||||
|
||||
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
||||
|
||||
_apply_camera_transform()
|
||||
|
||||
|
||||
func _movement_vector(move_intent: MoveIntent) -> Vector3:
|
||||
var forward := -global_basis.z
|
||||
var right := global_basis.x
|
||||
if _flight_enabled and _camera_pivot:
|
||||
forward = -_camera_pivot.global_basis.z
|
||||
right = _camera_pivot.global_basis.x
|
||||
else:
|
||||
forward.y = 0.0
|
||||
right.y = 0.0
|
||||
forward = forward.normalized()
|
||||
right = right.normalized()
|
||||
|
||||
var speed_z := run_speed if move_intent.forward_axis > 0.0 else backward_speed
|
||||
var speed_x := strafe_speed
|
||||
return (
|
||||
forward * move_intent.forward_axis * speed_z
|
||||
+ right * move_intent.strafe_axis * speed_x
|
||||
) * _debug_speed_multiplier(move_intent)
|
||||
|
||||
|
||||
func _debug_speed_multiplier(move_intent: MoveIntent) -> float:
|
||||
return sprint_multiplier if move_intent.is_sprint_requested else 1.0
|
||||
|
||||
|
||||
func _load_character_visual() -> void:
|
||||
if character_model_path.is_empty() or _visual == null:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless M02 unit and boundary regression for local player movement state.
|
||||
|
||||
const MOVEMENT_CONTROLLER_PATH := "res://src/gameplay/movement/local_player_movement_controller.gd"
|
||||
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_directional_speeds(failures)
|
||||
_verify_acceleration_and_deceleration(failures)
|
||||
_verify_sandbox_sprint(failures)
|
||||
_verify_flight_state_and_basis(failures)
|
||||
_verify_invalid_delta(failures)
|
||||
_verify_scene_boundary(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("LOCAL_PLAYER_MOVEMENT: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2 scene_boundary=1")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_directional_speeds(failures: Array[String]) -> void:
|
||||
var forward_controller := _new_controller()
|
||||
var forward_displacement := forward_controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
|
||||
_expect_vector_near(forward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "forward velocity", failures)
|
||||
_expect_vector_near(forward_displacement, Vector3(0.0, 0.0, -7.0), "forward displacement", failures)
|
||||
|
||||
var backward_controller := _new_controller()
|
||||
backward_controller.advance(_intent(-1.0, 0.0), Basis.IDENTITY, 1.0)
|
||||
_expect_vector_near(backward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, 4.5), "backward velocity", failures)
|
||||
|
||||
var strafe_controller := _new_controller()
|
||||
strafe_controller.advance(_intent(0.0, 1.0), Basis.IDENTITY, 1.0)
|
||||
_expect_vector_near(strafe_controller.godot_world_velocity_units_per_second, Vector3(4.5, 0.0, 0.0), "strafe velocity", failures)
|
||||
|
||||
var rotated_controller := _new_controller()
|
||||
var rotated_basis := Basis(Vector3.UP, deg_to_rad(90.0))
|
||||
rotated_controller.advance(_intent(1.0, 0.0), rotated_basis, 1.0)
|
||||
_expect_vector_near(
|
||||
rotated_controller.godot_world_velocity_units_per_second,
|
||||
(-rotated_basis.z).normalized() * 7.0,
|
||||
"rotated character basis",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_acceleration_and_deceleration(failures: Array[String]) -> void:
|
||||
var controller := _new_controller()
|
||||
var forward_intent := _intent(1.0, 0.0)
|
||||
_expect_vector_near(controller.advance(forward_intent, Basis.IDENTITY, 0.1), Vector3(0.0, 0.0, -0.28), "first acceleration displacement", failures)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "first acceleration velocity", failures)
|
||||
controller.advance(forward_intent, Basis.IDENTITY, 0.1)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -5.6), "second acceleration velocity", failures)
|
||||
controller.advance(_intent(0.0, 0.0), Basis.IDENTITY, 0.1)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "deceleration velocity", failures)
|
||||
|
||||
|
||||
func _verify_sandbox_sprint(failures: Array[String]) -> void:
|
||||
var controller := _new_controller()
|
||||
controller.advance(_intent(1.0, 0.0, 0.0, true), Basis.IDENTITY, 2.0)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint velocity", failures)
|
||||
|
||||
|
||||
func _verify_flight_state_and_basis(failures: Array[String]) -> void:
|
||||
var controller := _new_controller()
|
||||
controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
|
||||
_expect_true(controller.toggle_sandbox_flight(), "flight toggles on", failures)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "flight toggle clears velocity", failures)
|
||||
|
||||
var pitched_basis := Basis(Vector3.RIGHT, deg_to_rad(30.0))
|
||||
controller.advance(_intent(1.0, 0.0, 1.0), pitched_basis, 1.0)
|
||||
var expected_flight_velocity := (-pitched_basis.z).normalized() * 7.0 + Vector3.UP * 7.0
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, expected_flight_velocity, "camera-relative flight velocity", failures)
|
||||
_expect_true(not controller.toggle_sandbox_flight(), "flight toggles off", failures)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "landing toggle clears velocity", failures)
|
||||
|
||||
|
||||
func _verify_invalid_delta(failures: Array[String]) -> void:
|
||||
var controller := _new_controller()
|
||||
var displacement := controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, -1.0)
|
||||
_expect_vector_near(displacement, Vector3.ZERO, "negative delta displacement", failures)
|
||||
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "negative delta state", failures)
|
||||
|
||||
|
||||
func _verify_scene_boundary(failures: Array[String]) -> void:
|
||||
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
|
||||
for forbidden_text in ["extends Node", "extends Resource", "Input.", "Camera3D", "ADTLoader", "global_position"]:
|
||||
_expect_true(not movement_source.contains(forbidden_text), "movement controller omits %s" % forbidden_text, failures)
|
||||
|
||||
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||
_expect_true(player_source.contains("_local_movement_controller.advance"), "scene delegates movement update", failures)
|
||||
_expect_true(not player_source.contains("move_toward(target_velocity"), "scene omits velocity integration", failures)
|
||||
_expect_true(not player_source.contains("func _movement_vector"), "scene omits movement-vector calculation", failures)
|
||||
_expect_true(not player_source.contains("_horizontal_velocity"), "scene omits movement velocity state", failures)
|
||||
_expect_true(not player_source.contains("var _flight_enabled"), "scene omits flight state", failures)
|
||||
|
||||
|
||||
func _new_controller() -> LocalPlayerMovementController:
|
||||
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
|
||||
|
||||
|
||||
func _intent(
|
||||
forward_axis: float,
|
||||
strafe_axis: float,
|
||||
vertical_axis: float = 0.0,
|
||||
sprint_requested: bool = false
|
||||
) -> MoveIntent:
|
||||
return MoveIntent.new(forward_axis, strafe_axis, vertical_axis, sprint_requested)
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _expect_vector_near(
|
||||
actual_value: Vector3,
|
||||
expected_value: Vector3,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if not actual_value.is_equal_approx(expected_value):
|
||||
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
@@ -113,7 +113,8 @@ func _verify_regression_scene(failures: Array[String]) -> void:
|
||||
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
||||
flight_toggle_event.pressed = true
|
||||
player.call("_unhandled_input", flight_toggle_event)
|
||||
_expect_true(player.get("_flight_enabled"), "flight action preserves immediate sandbox toggle", failures)
|
||||
var movement_controller: LocalPlayerMovementController = player.get("_local_movement_controller")
|
||||
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
|
||||
player.free()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user