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>
183 lines
7.0 KiB
GDScript
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
|