OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / nav_debug_overlay.gd

202 lines · gd ref:
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])