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_hitboxes · show_attacks · show_bones · show_raycasts (true / false) ## show_hitboxes — the combatants' hurt volumes: each HitArea + the player/enemy body ## capsules, drawn as calm translucent-blue volumes (what abilities/blades hit) ## show_attacks — the attack hitboxes (bear claw/smash/leap, matador blade, thrown ## spear), filled translucent red and shown ONLY while the swing is actually live ## 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_AREA := Color(0.0, 0.9, 1.0) # combatant hurt volumes (cyan, translucent) const COLOR_ATTACK := Color(1.0, 0.30, 0.15) # attack hitboxes (red, translucent, overlaid) 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 ATTACK_ALPHA := 0.32 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) _pulse_attacks() 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 hit := DP.b("show_hitboxes") var atk := DP.b("show_attacks") var bone := DP.b("show_bones") var anim := DP.b("show_animation_name") if not (hit or atk 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(hit), int(atk), 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, hit, atk, bone, anim, wanted) # Drop overlays whose shape is gone or no longer wanted. The overlay is a child of # the collision shape, so it's freed with it — hold it as Variant, since assigning a # freed instance to any *typed* var (even Object) throws before is_instance_valid() # can filter it out. for id: int in _overlays.keys(): var mi: Variant = _overlays[id] if not wanted.has(id) or not is_instance_valid(mi): if is_instance_valid(mi): (mi as Node).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 String) func _collect(node: Node, hit: bool, atk: bool, bone: bool, anim: bool, wanted: Dictionary) -> void: # The audience ("public") is ambient decoration, not a debug target — skip the whole # crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays. if node.is_in_group(&"public"): return 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 kind := _classify(cs.get_parent()) var show := (kind == "attack" and atk) or (kind == "hitbox" and hit) if show: wanted[cs.get_instance_id()] = [cs, kind] for child in node.get_children(): _collect(child, hit, atk, bone, anim, wanted) # Sort a shape's owning body/area into an overlay bucket, or "" to skip. # attack — a swing volume (a Hitbox area, or a thrown-spear projectile body) # hitbox — a combatant's hurt volume: its HitArea, or its own body capsule func _classify(parent: Node) -> String: if parent is Hitbox or parent is SpearProjectile: return "attack" if parent is Area3D: return "hitbox" # Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for the # skeleton; otherwise every corpse floods the view with capsules. if parent is PhysicsBody3D and not (parent is PhysicalBone3D): if parent.is_in_group(&"player") or parent.is_in_group(&"matador"): return "hitbox" return "" # 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, kind: String) -> MeshInstance3D: var mi := MeshInstance3D.new() var solid := _solid_mesh(cs.shape) mi.mesh = solid if solid != null else cs.shape.get_debug_mesh() if kind == "attack": # Overlaid so a brief swing volume reads clearly on top of the action; its # visibility is pulsed per-frame in _pulse_attacks so it only shows while live. mi.material_override = _translucent_material(COLOR_ATTACK, ATTACK_ALPHA, true) else: # The calm translucent-blue volume — depth-tested so it sits in the world. mi.material_override = _translucent_material(COLOR_AREA, AREA_ALPHA, false) mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF cs.add_child(mi) return mi # Attack overlays exist for as long as show_attacks is on, but a swing hitbox is only a # hazard while its Hitbox is monitoring — so show each attack volume only when it's live. # (A thrown spear has no monitoring gate; it stays visible for its whole flight.) func _pulse_attacks() -> void: for id: int in _overlays: var mi: Variant = _overlays[id] if not is_instance_valid(mi): continue var owner := (mi as Node).get_parent().get_parent() if owner is Hitbox: (mi as MeshInstance3D).visible = (owner as Area3D).monitoring func _clear_overlays() -> void: for id: int in _overlays: var mi: Variant = _overlays[id] if is_instance_valid(mi): (mi as Node).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 _translucent_material(color: Color, alpha: float, overlay: bool) -> 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, alpha) mat.cull_mode = BaseMaterial3D.CULL_DISABLED mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED mat.no_depth_test = overlay return mat