Files
settled-reach/client/scripts/sandbox/mouse_aim_provider.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

99 lines
4.2 KiB
GDScript

class_name SandboxMouseAimProvider
extends RefCounted
## T-1088 (design §9) — mouse-aim facing provider for the 3D locomotion sandbox.
## Installed by the sandbox root as InputMapper.facing_angle_provider:
##
## var provider := SandboxMouseAimProvider.new(camera, world_root, player_rig)
## InputMapper.facing_angle_provider = Callable(provider, "get_facing_angle")
##
## Returns sim-space radians (0 = East, +PI/2 = South — the InputMapper.facing_angle
## convention), or NAN for "no update" (deadzone / degenerate ray / freed nodes).
## Everything downstream in InputMapper — octant snap, SetFacing-on-change,
## mouse-relative WASD, dialogue suppression — runs unchanged; D-054 semantics
## preserved exactly (only the octant ever crosses the wire).
##
## RefCounted, not Node: InputMapper pulls the value through the Callable each
## frame, so the provider needs no tree presence or lifecycle of its own, and the
## pure static core stays headless-testable (design §10.4). The installed Callable
## keeps this RefCounted alive; the installer clears the seam back to Callable()
## on scene exit, and the is_instance_valid guards below return NAN if the scene
## nodes are freed first.
##
## Math (design §9): unproject the mouse to a ray, intersect the y=0 ground plane
## in world space, convert through WorldRoot.to_local() (undoing the single D-148
## 45° map rotation — WorldRoot-local space IS sim-aligned space, SandboxSpace §2),
## take the delta from the rig's WorldRoot-local position, atan2(delta.z, delta.x).
## A screen-space mouse angle is NOT a sim-space angle in the 3D scene (constant
## ~45° skew from the map rotation plus up to ~19° ortho-pitch warp) — the ray
## unprojection is the only correct D-054 path.
## Guard against a ray running (near-)parallel to the ground plane.
const _RAY_PARALLEL_EPS := 0.000001
var _camera: Camera3D = null
var _world_root: Node3D = null
var _rig: Node3D = null
func _init(camera: Camera3D, world_root: Node3D, rig: Node3D) -> void:
_camera = camera
_world_root = world_root
_rig = rig
## Callable target for InputMapper.facing_angle_provider. Gathers the live scene
## state (mouse, camera ray, transforms) and defers to the pure static core.
func get_facing_angle() -> float:
if (
not is_instance_valid(_camera)
or not is_instance_valid(_world_root)
or not is_instance_valid(_rig)
):
return NAN
if not _camera.is_inside_tree() or not _world_root.is_inside_tree() or not _rig.is_inside_tree():
return NAN
var viewport := _camera.get_viewport()
if viewport == null:
return NAN
var mouse_screen := viewport.get_mouse_position()
return compute_facing_angle(
_camera.project_ray_origin(mouse_screen),
_camera.project_ray_normal(mouse_screen),
_world_root.global_transform,
_world_root.to_local(_rig.global_position),
SandboxConstants.MOUSE_AIM_DEADZONE_M
)
## Pure math core (headless-testable, design §10.4): world-space mouse ray ->
## y=0 ground-plane hit -> WorldRoot-local delta from the rig -> sim radians.
## world_root_transform is WorldRoot's GLOBAL transform (local -> world);
## rig_local_pos is the rig's position in WorldRoot-local metres.
## Returns NAN when there is no meaningful aim point: ray parallel to the ground,
## ground plane behind the ray, or hit inside the deadzone (mirrors the 2D
## jitter guard in InputMapper._update_facing_from_mouse).
static func compute_facing_angle(
ray_origin: Vector3,
ray_dir: Vector3,
world_root_transform: Transform3D,
rig_local_pos: Vector3,
deadzone_m: float
) -> float:
# Ray -> y=0 ground plane, world space.
if absf(ray_dir.y) < _RAY_PARALLEL_EPS:
return NAN
var t := -ray_origin.y / ray_dir.y
if t < 0.0:
return NAN
var hit_world := ray_origin + ray_dir * t
# Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2:
# local +X = sim East, local +Z = sim South).
var hit_local := world_root_transform.affine_inverse() * hit_world
var delta := hit_local - rig_local_pos
var planar := Vector2(delta.x, delta.z)
if planar.length_squared() < deadzone_m * deadzone_m:
return NAN
# atan2(z, x) IS the sim convention (0 = East, +PI/2 = South) because local
# +Z = sim +y (South, Y-down radians — server vision_cone.rs).
return atan2(delta.z, delta.x)