Bull roll ability, console screen, tests

This commit is contained in:
2026-07-28 23:10:38 +03:00
parent a0b248748c
commit ebb6ec6335
16 changed files with 1494 additions and 283 deletions
+502
View File
@@ -0,0 +1,502 @@
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