Files
settled-reach/client/tests/unit/test_locomotion_input.gd
T
jpmschweitzerandClaude Fable 5 a7801a942a feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)
New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world
derived from server snapshots. Per-leg constant-velocity interpolation keyed
to the stance throttle, 'server feet / client eyes' facing (wire octant while
moving, client aim octant idle), cadence-synced gait state machine on
AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg
default, T-cycle presets), sim-space grid shader, camera-side wall cutaway,
accumulating never-evict tile store with four-state visibility tint.

Additive seams only: InputMapper.facing_angle_provider (2D path unchanged),
CharacterVisual.play_animation blend_time param + get_animation_player().
Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting.
210 new gdUnit assertions across five suites; verified live (230/230 total,
clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:23:16 +02:00

238 lines
9.4 KiB
GDScript

## T-1088 (design §9, §10.4): the InputMapper facing_angle_provider seam and the
## sandbox mouse-aim provider's pure angle math.
##
## The provider math is tested through the static
## SandboxMouseAimProvider.compute_facing_angle() with synthetic rays/transforms —
## no viewport or camera needed headless. The seam tests drive the InputMapper
## autoload directly; the legacy 2D canvas-transform path is made deterministic by
## positioning GameState.player_position relative to the CURRENT mouse position
## (no assumption about where the headless mouse sits).
class_name TestLocomotionInput
extends GdUnitTestSuite
const MouseAimProvider := preload("res://scripts/sandbox/mouse_aim_provider.gd")
const EPS := 0.000001
## Deadzone used by the synthetic-math tests (mirrors SandboxConstants.MOUSE_AIM_DEADZONE_M).
const DEADZONE := 0.1
## Ray pointing straight down at the ground plane.
const DOWN := Vector3(0.0, -1.0, 0.0)
func after_test() -> void:
InputMapper.facing_angle_provider = Callable()
InputMapper.reset_facing_state()
GameState.player_position = Vector2.ZERO
# -- compute_facing_angle: cardinal/diagonal directions (identity WorldRoot) --------
func test_math_hit_east_of_rig_is_zero() -> void:
# Straight-down ray 1 m east (+X) of the rig -> sim angle 0 (East).
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_hit_south_of_rig_is_plus_half_pi() -> void:
# Local +Z = sim South (Y-down radians): hit at +Z -> +PI/2.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI / 2.0, EPS)
func test_math_hit_west_of_rig_is_pi() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(-1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI, EPS)
func test_math_hit_north_of_rig_is_minus_half_pi() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 10.0, -1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(-PI / 2.0, EPS)
func test_math_hit_southeast_of_rig_is_quarter_pi() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI / 4.0, EPS)
# -- compute_facing_angle: WorldRoot transform is undone -----------------------------
func test_math_world_rotation_undone() -> void:
# The D-148 45° map rotation must NOT skew the sim angle. A world-space hit at
# the rotated image of local (1,0,0) must still read as East (0.0).
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3.ZERO)
var hit_world := xf * Vector3(1.0, 0.0, 0.0)
var a: float = MouseAimProvider.compute_facing_angle(
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_world_translation_undone() -> void:
# A translated WorldRoot: hit at the world image of local (0,0,1) -> South.
var xf := Transform3D(Basis.IDENTITY, Vector3(10.0, 0.0, -3.0))
var hit_world := xf * Vector3(0.0, 0.0, 1.0)
var a: float = MouseAimProvider.compute_facing_angle(
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI / 2.0, EPS)
func test_math_rig_offset_and_rotation_compose() -> void:
# Rotated WorldRoot + rig away from the origin: hit at the world image of the
# local point 1 m east of the rig -> East, regardless of either offset.
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3(5.0, 0.0, 7.0))
var rig_local := Vector3(2.0, 0.0, 3.0)
var hit_world := xf * (rig_local + Vector3(1.0, 0.0, 0.0))
var a: float = MouseAimProvider.compute_facing_angle(
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, rig_local, DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_oblique_ray_like_ortho_camera() -> void:
# A -30°-pitch-style oblique ray (not straight down) still lands on y=0
# correctly: origin (0, 5, 8.66), dir (0, -0.5, -0.866) -> hit (0, 0, 0);
# rig 1 m west of the hit -> East.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 5.0, 8.66),
Vector3(0.0, -0.5, -0.866),
Transform3D.IDENTITY,
Vector3(-1.0, 0.0, 0.0),
DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
# -- compute_facing_angle: NAN cases (deadzone + degenerate rays) --------------------
func test_math_inside_deadzone_is_nan() -> void:
# 0.05 m from the rig < 0.1 m deadzone -> NAN (no update; the 2D jitter-guard mirror).
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.05, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_bool(is_nan(a)).is_true()
func test_math_just_outside_deadzone_is_finite() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.2, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_bool(is_finite(a)).is_true()
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_ray_parallel_to_ground_is_nan() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 10.0, 0.0),
Vector3(1.0, 0.0, 0.0),
Transform3D.IDENTITY,
Vector3.ZERO,
DEADZONE
)
assert_bool(is_nan(a)).is_true()
func test_math_ground_plane_behind_ray_is_nan() -> void:
# Origin below the plane, ray pointing further down -> t < 0 -> NAN.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, -5.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_bool(is_nan(a)).is_true()
func test_math_angle_feeds_octant_snap() -> void:
# The provider's output is consumed by InputMapper._angle_to_octant — a
# southeast hit must snap to the "Southeast" wire octant.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_str(InputMapper._angle_to_octant(a)).is_equal("Southeast")
# -- provider instance guards (no viewport needed) ------------------------------------
func test_provider_with_null_nodes_returns_nan() -> void:
var provider := MouseAimProvider.new(null, null, null)
assert_bool(is_nan(provider.get_facing_angle())).is_true()
func test_provider_with_out_of_tree_nodes_returns_nan() -> void:
var camera: Camera3D = auto_free(Camera3D.new())
var world_root: Node3D = auto_free(Node3D.new())
var rig: Node3D = auto_free(Node3D.new())
var provider := MouseAimProvider.new(camera, world_root, rig)
assert_bool(is_nan(provider.get_facing_angle())).is_true()
# -- InputMapper seam ------------------------------------------------------------------
func test_seam_finite_provider_updates_facing_and_octant() -> void:
InputMapper.facing_angle_provider = func() -> float: return PI / 4.0
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, EPS)
assert_str(InputMapper.facing_octant).is_equal("Southeast")
func test_seam_nan_provider_leaves_facing_and_blocks_2d_path() -> void:
# Arrange the 2D path so it WOULD rewrite facing if it ran (player 100 px
# away from the mouse on screen), then install a NAN provider: the early
# return must both skip the update and block the 2D path entirely.
_place_player_at_screen_delta(Vector2(100.0, 100.0))
InputMapper.facing_angle = 0.42
InputMapper.facing_octant = "East"
InputMapper.facing_angle_provider = func() -> float: return NAN
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
assert_str(InputMapper.facing_octant).is_equal("East")
func test_seam_unset_provider_falls_through_to_2d_path() -> void:
# Provider unset (default Callable()): the new branch must not fire and the
# legacy 2D canvas-transform path must run unchanged — with the player placed
# 100 px up-left of the mouse, it computes atan2(100, 100) = PI/4 (Southeast).
_place_player_at_screen_delta(Vector2(100.0, 100.0))
InputMapper.facing_angle_provider = Callable()
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, 0.001)
assert_str(InputMapper.facing_octant).is_equal("Southeast")
func test_seam_unset_provider_leaves_facing_untouched_inside_2d_jitter_guard() -> void:
# Provider unset + player exactly under the mouse: neither the new branch nor
# the 2D path (its own <= 2 px jitter guard) may touch facing.
_place_player_at_screen_delta(Vector2.ZERO)
InputMapper.facing_angle = 0.42
InputMapper.facing_octant = "East"
InputMapper.facing_angle_provider = Callable()
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
assert_str(InputMapper.facing_octant).is_equal("East")
# Position GameState.player_position so that (mouse_screen - player_screen) equals
# delta_px EXACTLY, inverting the 2D path's own math (player_position * TILE_SIZE
# through the canvas transform). This pins the legacy path's outcome without any
# assumption about the headless mouse position or canvas transform.
func _place_player_at_screen_delta(delta_px: Vector2) -> void:
var vp := InputMapper.get_viewport()
var canvas_xf := vp.get_canvas_transform()
var C := load("res://scripts/constants.gd")
var player_screen := vp.get_mouse_position() - delta_px
var player_world_px := canvas_xf.affine_inverse() * player_screen
GameState.player_position = player_world_px / float(C.TILE_SIZE)