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