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>
238 lines
9.4 KiB
GDScript
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)
|