Hover marker + optimal path line over the KNOWN tile store only — the character plans through what they know; fog is unpathable (info boundary at the planning layer; the follower additionally revalidates every remaining tile per step). Pure static 8-dir A*: uniform cost 1 incl. diagonals (D-248 time-optimal, no sqrt2), no corner-cutting, terrain-cost provider seam for Phase-4 terrain. Execution streams ordinary Move* steps through the existing throttle — zero protocol change, server validates every step. RMB vocabulary (live-session spec): click = walk there at current stance; double-click = sprint there (ToggleStanceUp burst — server toggle handler verified cooldown-free so bursts climb deterministically — with net-zero restore on arrival; a double upgrades the active follow in place); long-press >=400ms = go there then Crouch on arrival, no restore (input- vocabulary prototype; real cover mechanics are future combat design). Cancellation: WASD override (stance kept), invalidation, teleport, suppression. Two additive InputMapper seams (queue_move_step, queue_stance_toggle); mouse unproject shared with the facing provider (ground_hit_local). 44 new gdUnit tests across finder + follower ladders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
4.9 KiB
GDScript
113 lines
4.9 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:
|
|
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
|