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