extends RefCounted
## Draws the nav graph over live terrain — design §8's "make it visible first".
##
## Run it through ScriptHost, which is the runtime-script mechanism already here,
## so this needs no scene change and inherits map containment for free:
##
## BotRegistry.register("navdraw", ["map"], "none",
## {"kind": "script_file", "path": "res://scenes/test/nav_debug_overlay.gd",
## "params": {"chunks": 3, "chunk_size": 8.0, "cells": 16}})
##
## WHAT YOU SHOULD SEE. A block of small cubes hugging the terrain surface,
## coloured by REGION — two colours either side of a wall, one colour when a
## passage joins them. Portal cells are drawn brighter and larger on the chunk
## faces. Carve through a wall and the two colours become one.
##
## ⛔ IT IS A VIEW, NOT THE NAV. Nothing paths over this, and drawing it does not
## build anything the game keeps. It exists so the graph is wrong VISIBLY rather
## than as bad routes later, which is the whole argument of §8.
var bot_id: String = ""
## How many chunks across, centred on the local player. 3 -> a 3x3x3 block.
var chunks: int = 3
var chunk_size: float = 8.0
var cells: int = 16
var agent_radius: float = 0.4
var agent_height: float = 1.8
## Redraw cadence. Terrain edits do not announce themselves to this script, so it
## re-samples rather than waiting for an event it cannot hear.
var refresh_seconds: float = 3.0
const CELL_SCALE := 0.35 ## fraction of a nav cell the marker fills
const PORTAL_SCALE := 0.7
var _t: float = 999.0
var _root: Node3D = null
var _mm: MultiMeshInstance3D = null
func _bot_init() -> void:
print("[navdraw] %s: %dx%d chunks of %.1fm, %d cells each" % [bot_id, chunks, chunks, chunk_size, cells])
func _bot_tick(delta: float) -> void:
_t += delta
if _t < refresh_seconds:
return
_t = 0.0
_redraw()
func _bot_exit() -> void:
if _root != null and is_instance_valid(_root):
_root.queue_free()
_root = null
## Whatever can describe the navigable set here.
##
## ⛔ NOT "the DCTerrain". The first version of this overlay looked for
## get_class() == "DCTerrain", which meant it could draw the one map that HAS a
## density field and not map_flat — the map the bots actually run in. A viewer that
## cannot see the testbed is not a viewer. Anything answering debug_cells (a
## DCNavVolume, built from collision boxes) or debug_nav_chunk (a DCTerrain,
## sampling its density) will do.
func _nav_source() -> Node:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return null
var game := tree.root.get_node_or_null(^"Game")
if game == null:
return null
var map: Variant = game.get("current_map")
if map == null or not is_instance_valid(map):
return null
for child in (map as Node).get_children():
if child.has_method("debug_cells") or child.has_method("debug_nav_chunk"):
return child
return null
func _anchor() -> Vector3:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return Vector3.ZERO
var pp := tree.root.get_node_or_null(^"PlayerPosition")
if pp != null and pp.has_method("get_local_player"):
var p: Variant = pp.get_local_player()
if p != null and is_instance_valid(p):
return (p as Node3D).global_position
return Vector3.ZERO
func _ensure_root(parent: Node) -> void:
if _root != null and is_instance_valid(_root):
return
_root = Node3D.new()
_root.name = "NavDebugOverlay"
parent.add_child(_root)
_mm = MultiMeshInstance3D.new()
_mm.name = "Cells"
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_colors = true
var box := BoxMesh.new()
box.size = Vector3.ONE
mm.mesh = box
_mm.multimesh = mm
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_mm.material_override = mat
_root.add_child(_mm)
## Draw straight from a DCNavVolume's cell dump.
func _draw_cells(d: Dictionary) -> void:
var pts: PackedVector3Array = d.get("centres", PackedVector3Array())
var regions: PackedInt32Array = d.get("regions", PackedInt32Array())
var cell_size: float = float(d.get("cell_size", 1.0))
var xforms: Array[Transform3D] = []
var colours: Array[Color] = []
for i in pts.size():
var s: float = cell_size * CELL_SCALE
xforms.append(Transform3D(Basis().scaled(Vector3(s, s * 0.35, s)), pts[i]))
colours.append(_region_colour(regions[i] if i < regions.size() else 0))
var mm: MultiMesh = _mm.multimesh
mm.instance_count = xforms.size()
for i in xforms.size():
mm.set_instance_transform(i, xforms[i])
mm.set_instance_color(i, colours[i])
print("[navdraw] %d standable cells at %.2f m" % [pts.size(), cell_size])
## Distinct, stable colour per region id. Golden-angle hue so adjacent ids never
## land on similar colours — the point is telling two regions apart at a glance.
func _region_colour(id: int) -> Color:
var h: float = fposmod(float(id) * 0.61803398875, 1.0)
return Color.from_hsv(h, 0.75, 1.0, 0.85)
func _redraw() -> void:
var src := _nav_source()
if src == null:
return
_ensure_root(src.get_parent())
# A volume already holds every standable cell — one call, no chunk walk.
if src.has_method("debug_cells"):
_draw_cells(src.debug_cells())
return
var terrain := src
var anchor := _anchor()
var half := float(chunks) * 0.5
var xforms: Array[Transform3D] = []
var colours: Array[Color] = []
var total_regions := 0
for cz in chunks:
for cy in chunks:
for cx in chunks:
var origin := Vector3(
floor(anchor.x / chunk_size + float(cx) - half) * chunk_size,
floor(anchor.y / chunk_size + float(cy) - half) * chunk_size,
floor(anchor.z / chunk_size + float(cz) - half) * chunk_size)
var d: Dictionary = terrain.debug_nav_chunk(
origin, chunk_size, cells, agent_radius, agent_height)
if d.is_empty():
continue
var cell_size: float = float(d.get("cell_size", 0.5))
var pts: PackedVector3Array = d.get("standable", PackedVector3Array())
var regions: PackedInt32Array = d.get("regions", PackedInt32Array())
total_regions += int(d.get("region_count", 0))
# Portal cells, so they can be drawn differently from plain floor.
var portal_at: Dictionary = {}
for p: Dictionary in (d.get("portals", []) as Array):
for pc: Vector3 in (p.get("cells", PackedVector3Array()) as PackedVector3Array):
portal_at[pc] = true
for i in pts.size():
var pos: Vector3 = pts[i]
var is_portal: bool = portal_at.has(pos)
var s: float = cell_size * (PORTAL_SCALE if is_portal else CELL_SCALE)
var t := Transform3D(Basis().scaled(Vector3(s, s * 0.35, s)), pos)
xforms.append(t)
var c := _region_colour(regions[i] if i < regions.size() else 0)
if is_portal:
c = c.lightened(0.45)
colours.append(c)
var mm: MultiMesh = _mm.multimesh
mm.instance_count = xforms.size()
for i in xforms.size():
mm.set_instance_transform(i, xforms[i])
mm.set_instance_color(i, colours[i])
print("[navdraw] %d cells across %d chunks, %d regions total"
% [xforms.size(), chunks * chunks * chunks, total_regions])