503 lines
18 KiB
GDScript
503 lines
18 KiB
GDScript
extends Node
|
||
## Runtime debug overlays (autoload "DebugDraw").
|
||
##
|
||
## Two mechanisms, both gated by DP "Debug" bool params so they cost nothing when off:
|
||
## • Immediate-mode 3D lines — call DebugDraw.line()/ray() each frame; flushed once
|
||
## per frame into a single ImmediateMesh. Used for raycasts and the bone overlay.
|
||
## • Shape overlays — a MeshInstance3D attached to each CollisionShape3D, reconciled
|
||
## incrementally so static geometry is never re-instanced and freshly spawned
|
||
## bodies get picked up within one interval.
|
||
##
|
||
## Toggle from the console:
|
||
## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false)
|
||
## show_states — floating AI-state tags + velocity vectors over each matador
|
||
## show_animation_name — floating current-clip tag over every AnimationPlayer host
|
||
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
|
||
## show_grid — 1 m reference grid on the ground plane around the origin
|
||
## show_axes — RGB world-axis gizmo at the origin
|
||
## show_wireframe — render the whole viewport in wireframe
|
||
|
||
const REBUILD_INTERVAL := 0.5
|
||
const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame
|
||
|
||
const COLOR_BODY := Color(0.30, 1.0, 0.45) # collision wireframes (green)
|
||
const COLOR_AREA := Color(0.0, 0.9, 1.0) # hit-area volumes (cyan, translucent)
|
||
const COLOR_BONE := Color(1.0, 0.35, 0.9) # skeleton bones (magenta)
|
||
const COLOR_VEL := Color(1.0, 0.85, 0.2) # matador velocity vectors (amber)
|
||
const COLOR_ANIM := Color(0.55, 0.85, 1.0) # animation-name tags (sky blue)
|
||
const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5)
|
||
const AREA_ALPHA := 0.25
|
||
|
||
const GRID_HALF := 20 # grid extends ±GRID_HALF cells from the origin
|
||
const GRID_STEP := 1.0 # metres between grid lines
|
||
const AXIS_LEN := 3.0 # length of each world-axis gizmo arm
|
||
|
||
var _line_mesh: ImmediateMesh
|
||
var _lines: PackedVector3Array = PackedVector3Array()
|
||
var _colors: PackedColorArray = PackedColorArray()
|
||
|
||
var _overlays: Dictionary = {} # CollisionShape3D instance id -> MeshInstance3D
|
||
var _skeletons: Array[Skeleton3D] = []
|
||
var _anim_players: Array[AnimationPlayer] = []
|
||
var _reconcile_t: float = 0.0
|
||
var _prev_flags: String = ""
|
||
|
||
var _state_labels: Dictionary = {} # matador instance id -> Label3D
|
||
var _anim_labels: Dictionary = {} # AnimationPlayer instance id -> Label3D
|
||
|
||
var _stats_layer: CanvasLayer = null
|
||
var _stats_label: Label = null
|
||
var _stats_t: float = 0.0
|
||
|
||
|
||
func _ready() -> void:
|
||
process_mode = Node.PROCESS_MODE_ALWAYS # keep drawing while the console pauses the game
|
||
process_priority = 1000 # flush after everyone has queued their lines
|
||
# One-time: wireframe debug-draw needs the wireframe index buffers generated.
|
||
RenderingServer.set_debug_generate_wireframes(true)
|
||
_line_mesh = ImmediateMesh.new()
|
||
var inst := MeshInstance3D.new()
|
||
inst.mesh = _line_mesh
|
||
inst.material_override = _line_material()
|
||
inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||
add_child(inst)
|
||
|
||
|
||
# ── Public immediate-mode API ──────────────────────────────────────────────────
|
||
|
||
func line(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
|
||
_lines.append(from)
|
||
_lines.append(to)
|
||
_colors.append(color)
|
||
_colors.append(color)
|
||
|
||
|
||
func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
|
||
line(from, to, color)
|
||
|
||
|
||
# ── Frame flush ────────────────────────────────────────────────────────────────
|
||
|
||
func _process(delta: float) -> void:
|
||
_reconcile(delta)
|
||
if DP.b("show_bones"):
|
||
_draw_bones()
|
||
if DP.b("show_states"):
|
||
_update_state_labels()
|
||
elif not _state_labels.is_empty():
|
||
_clear_state_labels()
|
||
if DP.b("show_animation_name"):
|
||
_update_anim_labels()
|
||
elif not _anim_labels.is_empty():
|
||
_clear_anim_labels()
|
||
if DP.b("show_grid"):
|
||
_draw_grid()
|
||
if DP.b("show_axes"):
|
||
_draw_axes()
|
||
_update_wireframe()
|
||
_update_stats(delta)
|
||
_flush_lines()
|
||
|
||
|
||
func _flush_lines() -> void:
|
||
_line_mesh.clear_surfaces()
|
||
if not _lines.is_empty():
|
||
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
|
||
for i in _lines.size():
|
||
_line_mesh.surface_set_color(_colors[i])
|
||
_line_mesh.surface_add_vertex(_lines[i])
|
||
_line_mesh.surface_end()
|
||
_lines.clear()
|
||
_colors.clear()
|
||
|
||
|
||
# ── Bone overlay ───────────────────────────────────────────────────────────────
|
||
|
||
func _draw_bones() -> void:
|
||
for sk in _skeletons:
|
||
if not is_instance_valid(sk):
|
||
continue
|
||
var gx := sk.global_transform
|
||
for b in sk.get_bone_count():
|
||
var parent := sk.get_bone_parent(b)
|
||
if parent < 0:
|
||
continue
|
||
var a := gx * sk.get_bone_global_pose(parent).origin
|
||
var c := gx * sk.get_bone_global_pose(b).origin
|
||
line(a, c, COLOR_BONE)
|
||
|
||
|
||
# ── Matador AI-state overlay ───────────────────────────────────────────────────
|
||
# A billboarded state tag hovers over each matador, coloured by state, with an
|
||
# amber velocity vector — reads the whole arena's AI at a glance while tuning combat.
|
||
|
||
func _update_state_labels() -> void:
|
||
var seen: Dictionary = {}
|
||
for node: Node in get_tree().get_nodes_in_group(&"matador"):
|
||
var m := node as Node3D
|
||
if m == null:
|
||
continue
|
||
var id := m.get_instance_id()
|
||
seen[id] = true
|
||
var lbl: Label3D = _state_labels.get(id)
|
||
if lbl == null:
|
||
lbl = _new_state_label()
|
||
_state_labels[id] = lbl
|
||
var state_name := "?"
|
||
if m.has_method(&"ai_state_name"):
|
||
state_name = m.call(&"ai_state_name")
|
||
lbl.text = state_name
|
||
lbl.modulate = _state_color(state_name)
|
||
lbl.global_position = m.global_position + Vector3.UP * 2.2
|
||
if m is CharacterBody3D:
|
||
var v: Vector3 = (m as CharacterBody3D).velocity
|
||
v.y = 0.0
|
||
if v.length() > 0.5:
|
||
var base := m.global_position + Vector3.UP * 0.15
|
||
line(base, base + v * 0.18, COLOR_VEL)
|
||
for id: int in _state_labels.keys():
|
||
if not seen.has(id):
|
||
var lbl: Label3D = _state_labels[id]
|
||
if is_instance_valid(lbl):
|
||
lbl.queue_free()
|
||
_state_labels.erase(id)
|
||
|
||
|
||
func _new_state_label() -> Label3D:
|
||
var lbl := Label3D.new()
|
||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||
lbl.no_depth_test = true
|
||
lbl.fixed_size = true
|
||
lbl.pixel_size = 0.0006
|
||
lbl.font_size = 64
|
||
lbl.outline_size = 12
|
||
lbl.outline_modulate = Color(0.0, 0.0, 0.0, 0.9)
|
||
add_child(lbl)
|
||
return lbl
|
||
|
||
|
||
func _clear_state_labels() -> void:
|
||
for id: int in _state_labels:
|
||
var lbl: Label3D = _state_labels[id]
|
||
if is_instance_valid(lbl):
|
||
lbl.queue_free()
|
||
_state_labels.clear()
|
||
|
||
|
||
const _STATE_COLORS := {
|
||
"WANDER": Color(0.70, 0.70, 0.70),
|
||
"FLEE": Color(0.40, 0.80, 1.00),
|
||
"BRACE": Color(1.00, 0.90, 0.30),
|
||
"SIDESTEP": Color(1.00, 0.60, 0.10),
|
||
"ATTACK": Color(1.00, 0.30, 0.30),
|
||
"ROLL": Color(0.60, 1.00, 0.60),
|
||
"THROW": Color(1.00, 0.40, 0.90),
|
||
"RAGDOLL": Color(0.40, 0.40, 0.40),
|
||
}
|
||
|
||
|
||
func _state_color(state_name: String) -> Color:
|
||
return _STATE_COLORS.get(state_name, Color.WHITE)
|
||
|
||
|
||
# ── Animation-name overlay ─────────────────────────────────────────────────────
|
||
# A billboarded tag showing the clip each AnimationPlayer is currently playing,
|
||
# hovering above its nearest Node3D host — catches wrong / stuck animation states
|
||
# at a glance. Players are re-collected on the reconcile tick (see _collect).
|
||
|
||
func _update_anim_labels() -> void:
|
||
var seen: Dictionary = {}
|
||
for ap in _anim_players:
|
||
if not is_instance_valid(ap):
|
||
continue
|
||
var clip := ap.current_animation
|
||
# Skip finished / dormant sub-players so a host with several AnimationPlayers
|
||
# (e.g. a matador's main + olé + death players) doesn't stack empty tags.
|
||
if clip.is_empty() and not ap.is_playing():
|
||
continue
|
||
var host := _node3d_host(ap)
|
||
if host == null:
|
||
continue
|
||
var id := ap.get_instance_id()
|
||
seen[id] = true
|
||
var lbl: Label3D = _anim_labels.get(id)
|
||
if lbl == null:
|
||
lbl = _new_state_label()
|
||
lbl.modulate = COLOR_ANIM
|
||
_anim_labels[id] = lbl
|
||
lbl.text = clip if not clip.is_empty() else "—"
|
||
lbl.global_position = host.global_position + Vector3.UP * 2.6
|
||
for id: int in _anim_labels.keys():
|
||
if not seen.has(id):
|
||
var lbl: Label3D = _anim_labels[id]
|
||
if is_instance_valid(lbl):
|
||
lbl.queue_free()
|
||
_anim_labels.erase(id)
|
||
|
||
|
||
func _clear_anim_labels() -> void:
|
||
for id: int in _anim_labels:
|
||
var lbl: Label3D = _anim_labels[id]
|
||
if is_instance_valid(lbl):
|
||
lbl.queue_free()
|
||
_anim_labels.clear()
|
||
|
||
|
||
# Nearest Node3D at or above `node` — the anchor an AnimationPlayer's tag hovers over.
|
||
func _node3d_host(node: Node) -> Node3D:
|
||
var n := node
|
||
while n != null:
|
||
if n is Node3D:
|
||
return n as Node3D
|
||
n = n.get_parent()
|
||
return null
|
||
|
||
|
||
# ── World reference overlays ───────────────────────────────────────────────────
|
||
|
||
func _draw_grid() -> void:
|
||
var extent := GRID_HALF * GRID_STEP
|
||
for i in range(-GRID_HALF, GRID_HALF + 1):
|
||
var o := i * GRID_STEP
|
||
line(Vector3(o, 0.01, -extent), Vector3(o, 0.01, extent), COLOR_GRID)
|
||
line(Vector3(-extent, 0.01, o), Vector3(extent, 0.01, o), COLOR_GRID)
|
||
|
||
|
||
func _draw_axes() -> void:
|
||
line(Vector3.ZERO, Vector3.RIGHT * AXIS_LEN, Color.RED) # +X
|
||
line(Vector3.ZERO, Vector3.UP * AXIS_LEN, Color.GREEN) # +Y
|
||
line(Vector3.ZERO, Vector3(0.0, 0.0, 1.0) * AXIS_LEN, Color.DODGER_BLUE) # +Z
|
||
|
||
|
||
func _update_wireframe() -> void:
|
||
var vp := get_viewport()
|
||
var want := Viewport.DEBUG_DRAW_WIREFRAME if DP.b("show_wireframe") else Viewport.DEBUG_DRAW_DISABLED
|
||
if vp.debug_draw != want:
|
||
vp.debug_draw = want
|
||
|
||
|
||
# ── Stats overlay ──────────────────────────────────────────────────────────────
|
||
# A 2D corner readout of frame cost + scene load, plus a per-state matador tally so
|
||
# a combat slowdown or a stuck AI swarm shows up immediately.
|
||
|
||
func _update_stats(delta: float) -> void:
|
||
if not DP.b("show_stats"):
|
||
if _stats_layer != null:
|
||
_stats_layer.visible = false
|
||
return
|
||
_ensure_stats_ui()
|
||
_stats_layer.visible = true
|
||
_stats_t -= delta
|
||
if _stats_t > 0.0:
|
||
return
|
||
_stats_t = STATS_INTERVAL
|
||
_stats_label.text = _compose_stats()
|
||
|
||
|
||
func _ensure_stats_ui() -> void:
|
||
if _stats_layer != null:
|
||
return
|
||
var mono := SystemFont.new()
|
||
mono.font_names = PackedStringArray(["JetBrains Mono", "DejaVu Sans Mono", "monospace"])
|
||
|
||
_stats_layer = CanvasLayer.new()
|
||
_stats_layer.layer = 64
|
||
add_child(_stats_layer)
|
||
|
||
var panel := PanelContainer.new()
|
||
panel.position = Vector2(8.0, 8.0)
|
||
var bg := StyleBoxFlat.new()
|
||
bg.bg_color = Color(0.0, 0.0, 0.0, 0.55)
|
||
bg.set_content_margin_all(6.0)
|
||
panel.add_theme_stylebox_override("panel", bg)
|
||
_stats_layer.add_child(panel)
|
||
|
||
_stats_label = Label.new()
|
||
_stats_label.add_theme_font_override("font", mono)
|
||
_stats_label.add_theme_font_size_override("font_size", 13)
|
||
_stats_label.add_theme_color_override("font_color", Color(0.60, 1.0, 0.60))
|
||
panel.add_child(_stats_label)
|
||
|
||
|
||
func _compose_stats() -> String:
|
||
var fps := int(Performance.get_monitor(Performance.TIME_FPS))
|
||
var proc := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||
var phys := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||
var draws := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||
var prims := int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
|
||
var vmem := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0)
|
||
var nodes := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||
var phys3d := int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
|
||
return "fps %d proc %.1fms phys %.1fms\ndraws %d prims %s vmem %dMB\nnodes %d phys3d %d\n%s" % [
|
||
fps, proc, phys, draws, _si(prims), vmem, nodes, phys3d, _matador_tally()]
|
||
|
||
|
||
func _matador_tally() -> String:
|
||
var mats := get_tree().get_nodes_in_group(&"matador")
|
||
if mats.is_empty():
|
||
return "matadors 0"
|
||
var counts: Dictionary = {}
|
||
for node: Node in mats:
|
||
var s := "?"
|
||
if node.has_method(&"ai_state_name"):
|
||
s = node.call(&"ai_state_name")
|
||
counts[s] = int(counts.get(s, 0)) + 1
|
||
var parts: PackedStringArray = PackedStringArray()
|
||
for s: String in counts:
|
||
parts.append("%s:%d" % [s.substr(0, 2), counts[s]])
|
||
return "matadors %d %s" % [mats.size(), " ".join(parts)]
|
||
|
||
|
||
func _si(n: int) -> String:
|
||
if n >= 1000000:
|
||
return "%.1fM" % (n / 1000000.0)
|
||
if n >= 1000:
|
||
return "%.0fk" % (n / 1000.0)
|
||
return str(n)
|
||
|
||
|
||
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
|
||
|
||
func _reconcile(delta: float) -> void:
|
||
var col := DP.b("show_collisions")
|
||
var hit := DP.b("show_hitboxes")
|
||
var bone := DP.b("show_bones")
|
||
var anim := DP.b("show_animation_name")
|
||
if not (col or hit or bone or anim):
|
||
if not _overlays.is_empty():
|
||
_clear_overlays()
|
||
_skeletons.clear()
|
||
_anim_players.clear()
|
||
_prev_flags = ""
|
||
return
|
||
|
||
var flags := "%d%d%d%d" % [int(col), int(hit), int(bone), int(anim)]
|
||
_reconcile_t -= delta
|
||
if flags != _prev_flags: # a toggle just changed — refresh immediately
|
||
_prev_flags = flags
|
||
_reconcile_t = 0.0
|
||
if _reconcile_t > 0.0:
|
||
return
|
||
_reconcile_t = REBUILD_INTERVAL
|
||
|
||
_skeletons.clear()
|
||
_anim_players.clear()
|
||
var wanted: Dictionary = {}
|
||
var scene := get_tree().current_scene
|
||
if scene != null:
|
||
_collect(scene, col, hit, bone, anim, wanted)
|
||
|
||
# Drop overlays whose shape is gone or no longer wanted.
|
||
for id: int in _overlays.keys():
|
||
var mi: MeshInstance3D = _overlays[id]
|
||
if not wanted.has(id) or not is_instance_valid(mi):
|
||
if is_instance_valid(mi):
|
||
mi.queue_free()
|
||
_overlays.erase(id)
|
||
# Add overlays for newly seen shapes.
|
||
for id: int in wanted:
|
||
if not _overlays.has(id):
|
||
var entry: Array = wanted[id]
|
||
_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as bool)
|
||
|
||
|
||
func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
|
||
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
|
||
_skeletons.append(node as Skeleton3D)
|
||
if anim and node is AnimationPlayer:
|
||
_anim_players.append(node as AnimationPlayer)
|
||
if node is CollisionShape3D:
|
||
var cs := node as CollisionShape3D
|
||
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
|
||
var parent := cs.get_parent()
|
||
var is_area := parent is Area3D
|
||
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for
|
||
# the skeleton; otherwise every corpse floods the view with capsules.
|
||
var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D)
|
||
if (is_area and hit) or (is_body and col):
|
||
wanted[cs.get_instance_id()] = [cs, is_area]
|
||
for child in node.get_children():
|
||
_collect(child, col, hit, bone, anim, wanted)
|
||
|
||
|
||
# Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena
|
||
# geometry is fine — Godot caches each shape's debug mesh, so it's built at most once.
|
||
func _drawable(shape: Shape3D) -> bool:
|
||
return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D)
|
||
|
||
|
||
func _attach(cs: CollisionShape3D, is_area: bool) -> MeshInstance3D:
|
||
var mi := MeshInstance3D.new()
|
||
if is_area:
|
||
var solid := _solid_mesh(cs.shape)
|
||
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
|
||
mi.material_override = _translucent_material(COLOR_AREA)
|
||
else:
|
||
mi.mesh = cs.shape.get_debug_mesh()
|
||
mi.material_override = _wire_material(COLOR_BODY)
|
||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||
cs.add_child(mi)
|
||
return mi
|
||
|
||
|
||
func _clear_overlays() -> void:
|
||
for id: int in _overlays:
|
||
var mi: MeshInstance3D = _overlays[id]
|
||
if is_instance_valid(mi):
|
||
mi.queue_free()
|
||
_overlays.clear()
|
||
|
||
|
||
# ── Meshes & materials ─────────────────────────────────────────────────────────
|
||
|
||
# A filled mesh matching a primitive shape, for translucent volume overlays. Returns
|
||
# null for shapes without a clean solid equivalent (caller falls back to wireframe).
|
||
func _solid_mesh(shape: Shape3D) -> Mesh:
|
||
if shape is BoxShape3D:
|
||
var m := BoxMesh.new()
|
||
m.size = (shape as BoxShape3D).size
|
||
return m
|
||
if shape is SphereShape3D:
|
||
var m := SphereMesh.new()
|
||
m.radius = (shape as SphereShape3D).radius
|
||
m.height = m.radius * 2.0
|
||
return m
|
||
if shape is CapsuleShape3D:
|
||
var m := CapsuleMesh.new()
|
||
m.radius = (shape as CapsuleShape3D).radius
|
||
m.height = (shape as CapsuleShape3D).height
|
||
return m
|
||
if shape is CylinderShape3D:
|
||
var m := CylinderMesh.new()
|
||
m.top_radius = (shape as CylinderShape3D).radius
|
||
m.bottom_radius = (shape as CylinderShape3D).radius
|
||
m.height = (shape as CylinderShape3D).height
|
||
return m
|
||
return null
|
||
|
||
|
||
func _line_material() -> StandardMaterial3D:
|
||
var mat := StandardMaterial3D.new()
|
||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
mat.vertex_color_use_as_albedo = true
|
||
mat.no_depth_test = true
|
||
return mat
|
||
|
||
|
||
func _wire_material(color: Color) -> StandardMaterial3D:
|
||
var mat := StandardMaterial3D.new()
|
||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
mat.albedo_color = color
|
||
mat.no_depth_test = true
|
||
return mat
|
||
|
||
|
||
func _translucent_material(color: Color) -> StandardMaterial3D:
|
||
var mat := StandardMaterial3D.new()
|
||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||
mat.albedo_color = Color(color.r, color.g, color.b, AREA_ALPHA)
|
||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||
return mat
|