Files
settled-reach/client/scripts/sandbox/path_preview.gd
T
jpmschweitzerandClaude Fable 5 49c8994943 feat(client): move-here path preview + RMB gesture vocabulary (T-1088, Q-084)
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>
2026-07-06 19:52:03 +02:00

183 lines
7.0 KiB
GDScript

class_name PathPreview
extends Node3D
## T-1088 (Live-session feedback item 2) — mouse-over move-here marker + optimal
## path line. A Node3D placed UNDER WorldRoot by the sandbox root, so it inherits
## the single D-148 45° map rotation for free: its children live in the same
## WorldRoot-local (sim-aligned) metric space the greybox tiles do, and
## SandboxSpace.tile_to_world positions them directly.
##
## Two visuals, both children built in setup():
## - marker: a flat PlaneMesh quad on the hovered destination subtile, lifted a
## hair above the floor, in a distinct accent colour.
## - line: an ImmediateMesh line strip through the tile centers of the planned
## path, lifted slightly higher so it reads on top of the marker.
##
## The A* runs over the greybox KNOWN-tile store via the injected is_floor
## Callable (PathFinder honours the information boundary — fog is unpathable).
## Recompute is edge-triggered — on hovered-tile change, character-tile change, or
## knowledge growth — NEVER per frame (A* every frame over ~14k tiles is waste).
## Hidden whenever there is no valid path: hovering unknown/void/wall, or a
## known-floor tile with no known route.
##
## Note on knowledge_version: the root passes store.size() (monotonic — the store
## never evicts). A revealed wall REPLACING a known floor (a rekind, no size
## change) can leave the drawn line momentarily stale until the next hover/step,
## but the EXECUTED route is always safe — path_follower.gd revalidates every
## remaining tile per step and replans. Preview is advisory; the follower is
## authoritative.
## Hover-pick ray sources, injected in setup().
var _camera: Camera3D = null
var _world_root: Node3D = null
var _marker: MeshInstance3D = null
var _line: MeshInstance3D = null
var _line_mesh: ImmediateMesh = null
## Latest computed path (character tile -> hovered tile, inclusive). Empty when
## the hovered tile is unpathable. Read by the root's click handler.
var _current_path: Array[Vector3i] = []
## Edge-trigger memory — recompute only when one of these changes.
var _last_hover: Vector3i = Vector3i(2147483647, 0, 0) # sentinel: forces first pass
var _last_char_tile: Vector3i = Vector3i(2147483647, 0, 0)
var _last_version: int = -1
## Inject the ray-pick nodes and build the two child visuals. Called by the
## sandbox root right after the preview is added under WorldRoot.
func setup(camera: Camera3D, world_root: Node3D) -> void:
_camera = camera
_world_root = world_root
# Move-here marker — PlaneMesh faces +Y natively (like the floor tiles); a
# double-sided unshaded material keeps it readable from every camera preset.
var quad := PlaneMesh.new()
quad.size = Vector2(SandboxConstants.SUBTILE_M, SandboxConstants.SUBTILE_M)
_marker = MeshInstance3D.new()
_marker.name = "MoveHereMarker"
_marker.mesh = quad
_marker.material_override = _flat_material(SandboxConstants.PATH_MARKER_COLOR)
_marker.visible = false
add_child(_marker)
# Path line — an ImmediateMesh rebuilt on recompute.
_line_mesh = ImmediateMesh.new()
_line = MeshInstance3D.new()
_line.name = "PathLine"
_line.mesh = _line_mesh
_line.material_override = _flat_material(SandboxConstants.PATH_LINE_COLOR)
_line.visible = false
add_child(_line)
## One frame of preview maintenance (called by the root while gameplay is live).
## char_tile: the server-confirmed character tile (path origin).
## is_floor: greybox known-floor lookup (Vector3i) -> bool.
## knowledge_version: store.size() — bumps when new tiles are learned.
func update(char_tile: Vector3i, is_floor: Callable, knowledge_version: int) -> void:
var hover: Variant = _hovered_tile()
if hover == null:
# Mouse off the ground plane (pointing at sky / degenerate ray).
_clear_visuals()
_last_hover = Vector3i(2147483647, 0, 0)
return
var hover_tile: Vector3i = hover
if (
hover_tile == _last_hover
and char_tile == _last_char_tile
and knowledge_version == _last_version
):
return # nothing that affects the path changed — leave visuals as-is
_last_hover = hover_tile
_last_char_tile = char_tile
_last_version = knowledge_version
_current_path = PathFinder.find_path(char_tile, hover_tile, is_floor)
_redraw()
## The planned path (character -> hovered), inclusive of both ends; empty when the
## hovered tile is unpathable. The root hands this to the follower on click.
func get_current_path() -> Array[Vector3i]:
return _current_path
## Hide both visuals and forget the current path (root calls on teleport / when
## gameplay is suppressed).
func clear() -> void:
_current_path = []
_clear_visuals()
_last_hover = Vector3i(2147483647, 0, 0)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
## The tile currently under the mouse, or null if the ray misses the ground. Reuses
## the facing provider's ground-plane unproject (one unproject, one place), then
## SandboxSpace.world_to_tile — WorldRoot-local metres are sim-aligned space.
func _hovered_tile() -> Variant:
if not is_instance_valid(_camera) or not is_instance_valid(_world_root):
return null
if not _camera.is_inside_tree():
return null
var viewport := _camera.get_viewport()
if viewport == null:
return null
var mouse := viewport.get_mouse_position()
var hit_local: Variant = SandboxMouseAimProvider.ground_hit_local(
_camera.project_ray_origin(mouse),
_camera.project_ray_normal(mouse),
_world_root.global_transform
)
if hit_local == null:
return null
return SandboxSpace.world_to_tile(hit_local as Vector3)
## Repaint marker + line from _current_path. Marker on the destination when a path
## exists; line only for a real (>= 2 tile) move.
func _redraw() -> void:
if _current_path.is_empty():
_clear_visuals()
return
var dest: Vector3i = _current_path[_current_path.size() - 1]
var dest_world := SandboxSpace.tile_to_world(dest)
_marker.position = Vector3(dest_world.x, SandboxConstants.PATH_MARKER_Y, dest_world.z)
_marker.visible = true
_line_mesh.clear_surfaces()
if _current_path.size() >= 2:
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
for tile in _current_path:
var c := SandboxSpace.tile_to_world(tile)
_line_mesh.surface_add_vertex(Vector3(c.x, SandboxConstants.PATH_LINE_Y, c.z))
_line_mesh.surface_end()
_line.visible = true
else:
_line.visible = false
func _clear_visuals() -> void:
if _marker != null:
_marker.visible = false
if _line != null:
_line.visible = false
if _line_mesh != null:
_line_mesh.clear_surfaces()
## Unshaded, double-sided, alpha-blended material — a flat overlay swatch that
## ignores scene lighting so the accent colour stays constant at any camera pitch.
func _flat_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.albedo_color = color
mat.vertex_color_use_as_albedo = false
return mat