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: var hit_local: Variant = ground_hit_local(ray_origin, ray_dir, world_root_transform) if hit_local == null: return NAN var delta: Vector3 = (hit_local as Vector3) - 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) ## Pure ground-plane pick (headless-testable): a world-space mouse ray -> its y=0 ## ground-plane intersection expressed in WorldRoot-LOCAL metres (undoing the 45° ## map rotation, so the result is sim-aligned space per SandboxSpace §2). Shared by ## the facing provider above and the T-1088 hover-tile picker (path_preview.gd), ## which feeds the returned point straight into SandboxSpace.world_to_tile — one ## unproject, one place. Returns null (not a Vector3) when the ray is parallel to ## the ground or the plane sits behind the ray origin. static func ground_hit_local( ray_origin: Vector3, ray_dir: Vector3, world_root_transform: Transform3D ) -> Variant: if absf(ray_dir.y) < _RAY_PARALLEL_EPS: return null var t := -ray_origin.y / ray_dir.y if t < 0.0: return null 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). return world_root_transform.affine_inverse() * hit_world