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
+2 -17
View File
@@ -94,18 +94,14 @@ func _setup_tail() -> void:
_tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0]) _tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0])
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_QUOTELEFT:
_dbg_toggle()
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _skeleton: if not _skeleton:
return return
_wobble_body(delta) _wobble_body(delta)
_update_tail(delta) _update_tail(delta)
_apply_tail() _apply_tail()
if DP.b("show_tail") != _dbg_on:
_dbg_toggle()
if _dbg_on: if _dbg_on:
_dbg_update() _dbg_update()
@@ -243,12 +239,9 @@ func _dbg_toggle() -> void:
n.queue_free() n.queue_free()
_dbg_nodes.clear() _dbg_nodes.clear()
_dbg_on = false _dbg_on = false
print("TailDebug OFF")
else: else:
_dbg_build() _dbg_build()
_dbg_on = true _dbg_on = true
var pname := _skeleton.get_bone_name(_tail_parent_idx) if _tail_parent_idx >= 0 else "none"
print("TailDebug ON parent_bone=", pname, " bull_pos=", _bull.global_position)
func _dbg_build() -> void: func _dbg_build() -> void:
@@ -275,14 +268,6 @@ func _dbg_build() -> void:
func _dbg_update() -> void: func _dbg_update() -> void:
for i: int in _dbg_nodes.size(): for i: int in _dbg_nodes.size():
_dbg_nodes[i].global_position = _tail_world[i] _dbg_nodes[i].global_position = _tail_world[i]
# Print positions every ~60 frames
if Engine.get_process_frames() % 60 == 0:
var parent_y := 0.0
if _tail_parent_idx >= 0:
parent_y = _skeleton.to_global(_skeleton.get_bone_global_pose(_tail_parent_idx).origin).y
print("anchor_Y=%.2f parent_bone_Y=%.2f player_Y=%.2f on_floor=%s" % [
_tail_world[0].y, parent_y, _player.global_position.y,
str(_player.is_on_floor())])
# ── Utility ─────────────────────────────────────────────────────────────────── # ── Utility ───────────────────────────────────────────────────────────────────
+267 -115
View File
@@ -1,13 +1,27 @@
extends Node extends Node
## Quake-style drop-down debug console. Toggle with ~ (Shift+`). ## Quake-style drop-down developer console (autoload "Console"). Toggle with the
## backtick/tilde key (`/~) — matched by physical position so it works on any
## keyboard layout and with or without Shift.
## Query a param: type its name. Set it: "name value". ## Query a param: type its name. Set it: "name value".
## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close. ## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close.
const CONSOLE_HEIGHT_RATIO := 0.48 const CONSOLE_HEIGHT_RATIO := 0.25
const SLIDE_DURATION := 0.18 const SLIDE_DURATION := 0.18
const MAX_SUGGESTIONS := 10 const MAX_SUGGESTIONS := 10
const LOG_MAX_LINES := 300 const LOG_MAX_LINES := 300
# Built-in commands (name → one-line help). Surfaced in autocomplete alongside
# param keys and printed by `help`. None collide with param key names.
const _COMMANDS := {
"save": "persist values to disk",
"reset": "reset all, or reset <name>",
"diff": "list params changed from default",
"toggle": "flip a bool param",
"list": "print params (optional section)",
"clear": "clear the log",
"help": "show this help",
}
var _canvas: CanvasLayer var _canvas: CanvasLayer
var _panel: PanelContainer var _panel: PanelContainer
var _log: RichTextLabel var _log: RichTextLabel
@@ -22,14 +36,22 @@ var _prev_mouse: Input.MouseMode = Input.MOUSE_MODE_CAPTURED
var _suggestions: Array[String] = [] var _suggestions: Array[String] = []
var _selected_idx: int = -1 var _selected_idx: int = -1
var _history: Array[String] = [] var _history: Array[String] = []
var _history_idx: int = -1 var _history_idx: int = -1 # -1 = editing the live line
var _pending_line: String = "" # live line stashed while browsing history
func _ready() -> void: func _ready() -> void:
# Keep the console (and its slide tween/input) alive while the tree is paused.
process_mode = Node.PROCESS_MODE_ALWAYS
_build_ui() _build_ui()
func _build_ui() -> void: func _build_ui() -> void:
var mono := SystemFont.new()
mono.font_names = PackedStringArray([
"JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", "monospace",
])
_canvas = CanvasLayer.new() _canvas = CanvasLayer.new()
_canvas.layer = 128 _canvas.layer = 128
add_child(_canvas) add_child(_canvas)
@@ -55,20 +77,6 @@ func _build_ui() -> void:
root_vbox.size_flags_vertical = Control.SIZE_FILL root_vbox.size_flags_vertical = Control.SIZE_FILL
_panel.add_child(root_vbox) _panel.add_child(root_vbox)
# ── Header ────────────────────────────────────────────────────────────────
var header_margin := MarginContainer.new()
header_margin.add_theme_constant_override("margin_left", 10)
header_margin.add_theme_constant_override("margin_top", 4)
header_margin.add_theme_constant_override("margin_bottom", 2)
root_vbox.add_child(header_margin)
var header_lbl := Label.new()
header_lbl.text = "BULLOSSEUM CONSOLE [~ close · Tab complete · ↑↓ browse]"
header_lbl.modulate = Color(0.35, 0.9, 0.35, 0.60)
header_margin.add_child(header_lbl)
root_vbox.add_child(HSeparator.new())
# ── Log ─────────────────────────────────────────────────────────────────── # ── Log ───────────────────────────────────────────────────────────────────
_log = RichTextLabel.new() _log = RichTextLabel.new()
_log.bbcode_enabled = true _log.bbcode_enabled = true
@@ -77,6 +85,10 @@ func _build_ui() -> void:
_log.size_flags_horizontal = Control.SIZE_FILL _log.size_flags_horizontal = Control.SIZE_FILL
_log.add_theme_font_size_override("normal_font_size", 13) _log.add_theme_font_size_override("normal_font_size", 13)
_log.add_theme_constant_override("line_separation", 1) _log.add_theme_constant_override("line_separation", 1)
# Monospace so the rpad-aligned suggestion/list columns actually line up.
_log.add_theme_font_override("normal_font", mono)
_log.add_theme_font_override("bold_font", mono)
_log.add_theme_font_override("mono_font", mono)
root_vbox.add_child(_log) root_vbox.add_child(_log)
# ── Suggestion panel ────────────────────────────────────────────────────── # ── Suggestion panel ──────────────────────────────────────────────────────
@@ -99,6 +111,7 @@ func _build_ui() -> void:
var lbl := Label.new() var lbl := Label.new()
lbl.visible = false lbl.visible = false
lbl.add_theme_font_size_override("font_size", 13) lbl.add_theme_font_size_override("font_size", 13)
lbl.add_theme_font_override("font", mono)
_suggest_box.add_child(lbl) _suggest_box.add_child(lbl)
_suggest_labels.append(lbl) _suggest_labels.append(lbl)
@@ -119,22 +132,27 @@ func _build_ui() -> void:
var prompt_lbl := Label.new() var prompt_lbl := Label.new()
prompt_lbl.text = ">" prompt_lbl.text = ">"
prompt_lbl.modulate = Color(0.35, 1.0, 0.35) prompt_lbl.modulate = Color(0.35, 1.0, 0.35)
prompt_lbl.add_theme_font_override("font", mono)
in_row.add_child(prompt_lbl) in_row.add_child(prompt_lbl)
_input_field = LineEdit.new() _input_field = LineEdit.new()
_input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL _input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_input_field.placeholder_text = "param_name | param_name value | save / reset / list / help" _input_field.placeholder_text = "param · param value · toggle <bool> · save reset diff list clear help"
_input_field.clear_button_enabled = true _input_field.clear_button_enabled = true
_input_field.add_theme_font_size_override("font_size", 14) _input_field.add_theme_font_size_override("font_size", 14)
_input_field.add_theme_font_override("font", mono)
# Drop the default LineEdit chrome (the white box border) — the panel is the frame.
var flat := StyleBoxFlat.new()
flat.bg_color = Color(0, 0, 0, 0)
flat.set_border_width_all(0)
flat.set_content_margin_all(2)
_input_field.add_theme_stylebox_override("normal", flat)
_input_field.add_theme_stylebox_override("focus", flat)
in_row.add_child(_input_field) in_row.add_child(_input_field)
_input_field.text_changed.connect(_on_text_changed) _input_field.text_changed.connect(_on_text_changed)
_input_field.text_submitted.connect(_on_submitted) _input_field.text_submitted.connect(_on_submitted)
_log_raw("[color=#55bb55]Bullosseum Debug Console[/color] [color=#444444]─ Tab=complete ↑↓=browse ~=close[/color]")
_log_raw("[color=#444444]Commands:[/color] [b]save reset list [section] help[/b]")
_log_raw("")
# ── Input handling ──────────────────────────────────────────────────────────── # ── Input handling ────────────────────────────────────────────────────────────
@@ -144,8 +162,9 @@ func _input(event: InputEvent) -> void:
return return
var ke := event as InputEventKey var ke := event as InputEventKey
# ~ (Shift+backtick) — toggle regardless of focus # Backtick/tilde — toggle regardless of focus. Match by physical position so a
if ke.keycode == KEY_QUOTELEFT and ke.shift_pressed: # non-US layout or a Shift-produced ~ both open the console.
if ke.physical_keycode == KEY_QUOTELEFT:
_toggle() _toggle()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
@@ -155,13 +174,13 @@ func _input(event: InputEvent) -> void:
match ke.keycode: match ke.keycode:
KEY_TAB: KEY_TAB:
_do_autocomplete() _accept_completion()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_UP: KEY_UP:
_navigate(-1) _arrow(-1)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_DOWN: KEY_DOWN:
_navigate(1) _arrow(1)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_ESCAPE: KEY_ESCAPE:
_toggle() _toggle()
@@ -170,6 +189,7 @@ func _input(event: InputEvent) -> void:
func _toggle() -> void: func _toggle() -> void:
_is_open = not _is_open _is_open = not _is_open
get_tree().paused = _is_open
var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO
_panel.offset_bottom = h _panel.offset_bottom = h
@@ -206,6 +226,12 @@ func _on_text_changed(text: String) -> void:
func _refresh_suggestions(query: String) -> void: func _refresh_suggestions(query: String) -> void:
var ql := query.to_lower() var ql := query.to_lower()
var scored : Array = [] var scored : Array = []
# Commands rank above params on an equal score so a leading "re" surfaces the
# `reset` verb, not just params that happen to contain those letters.
for name: String in _COMMANDS:
var cs := _fuzzy_score(ql, name)
if cs > 0:
scored.append([cs + 1, name])
for key: String in DP.get_all(): for key: String in DP.get_all():
var s := _fuzzy_score(ql, key.to_lower()) var s := _fuzzy_score(ql, key.to_lower())
if s > 0: if s > 0:
@@ -218,11 +244,7 @@ func _refresh_suggestions(query: String) -> void:
for i in MAX_SUGGESTIONS: for i in MAX_SUGGESTIONS:
if i < _suggestions.size(): if i < _suggestions.size():
var key : String = _suggestions[i] _suggest_labels[i].text = _suggestion_text(_suggestions[i])
var meta: Dictionary = DP.get_all()[key]
_suggest_labels[i].text = " %s %s [%s]" % [
key.rpad(28), _fmt_val(key, meta).rpad(8), meta["section"]
]
_suggest_labels[i].modulate = _suggestion_color(i) _suggest_labels[i].modulate = _suggestion_color(i)
_suggest_labels[i].visible = true _suggest_labels[i].visible = true
else: else:
@@ -230,6 +252,17 @@ func _refresh_suggestions(query: String) -> void:
_suggest_panel.visible = not _suggestions.is_empty() _suggest_panel.visible = not _suggestions.is_empty()
func _is_command(name: String) -> bool:
return _COMMANDS.has(name)
func _suggestion_text(name: String) -> String:
if _is_command(name):
return " %s [cmd] %s" % [name.rpad(10), _COMMANDS[name]]
var meta: Dictionary = DP.get_all()[name]
return " %s %s [%s]" % [name.rpad(28), _fmt_val(name, meta).rpad(8), meta["section"]]
func _suggestion_color(idx: int) -> Color: func _suggestion_color(idx: int) -> Color:
if idx == _selected_idx: if idx == _selected_idx:
return Color(1.0, 1.0, 0.4) # selected — yellow return Color(1.0, 1.0, 0.4) # selected — yellow
@@ -260,13 +293,22 @@ func _fuzzy_score(query: String, target: String) -> int:
func _fmt_val(_key: String, meta: Dictionary) -> String: func _fmt_val(_key: String, meta: Dictionary) -> String:
if meta["type"] == TYPE_FLOAT: if meta["type"] == TYPE_FLOAT:
return "%.4g" % (meta["value"] as float) return _fmt_num(meta["value"] as float)
if meta["type"] == TYPE_BOOL: if meta["type"] == TYPE_BOOL:
var bval: bool = meta["value"] var bval: bool = meta["value"]
return "true" if bval else "false" return "true" if bval else "false"
return str(meta["value"]) return str(meta["value"])
# Render a float as a plain decimal — trailing zeros trimmed but always at least one
# place, so it always reads as a float (4.0, 0.4, 0.005) never an int (4).
func _fmt_num(v: float) -> String:
var s := ("%.4f" % v).rstrip("0")
if s.ends_with("."):
s += "0"
return s
func _clear_suggestions() -> void: func _clear_suggestions() -> void:
_suggestions.clear() _suggestions.clear()
_selected_idx = -1 _selected_idx = -1
@@ -275,50 +317,74 @@ func _clear_suggestions() -> void:
_suggest_panel.visible = false _suggest_panel.visible = false
func _do_autocomplete() -> void: # ↑/↓ move through the suggestion list while it's open; once it's gone they recall
# command history instead. Same keys, context decides — no separate binding to learn.
func _arrow(dir: int) -> void:
if _suggest_panel.visible and not _suggestions.is_empty():
_move_selection(dir)
else:
_history_step(-dir)
# Highlight the next/prev suggestion and drop "<name> <value>" into the field with the
# value pre-selected, so you just type your number over it.
func _move_selection(dir: int) -> void:
if _selected_idx < 0:
_selected_idx = 0 if dir > 0 else _suggestions.size() - 1
else:
_selected_idx = wrapi(_selected_idx + dir, 0, _suggestions.size())
_recolor_suggestions()
_fill_selected()
# Tab commits the highlighted suggestion (top match if none yet) and closes the list.
func _accept_completion() -> void:
if _suggestions.is_empty(): if _suggestions.is_empty():
return return
var idx := maxi(0, _selected_idx) _selected_idx = maxi(_selected_idx, 0)
var key := _suggestions[idx] _fill_selected()
var meta: Dictionary = DP.get_all()[key]
# Fill "key current_value" so the user just edits the number
_input_field.text = "%s %s" % [key, _fmt_val(key, meta)]
_input_field.caret_column = _input_field.text.length()
_clear_suggestions() _clear_suggestions()
func _navigate(dir: int) -> void: func _fill_selected() -> void:
if _suggestions.is_empty(): var key := _suggestions[_selected_idx]
# History navigation when no suggestion list is visible # A command drops in as "verb " ready for its argument (or a bare Enter); a
if dir == -1 and _history_idx + 1 < _history.size(): # param drops in as "key value" with the value pre-selected to type over.
_history_idx += 1 # Suggestions stay open either way so ↑↓ keeps cycling.
elif dir == 1 and _history_idx > 0: if _is_command(key):
_history_idx -= 1 _input_field.text = key + " "
elif dir == 1 and _history_idx == 0:
_history_idx = -1
_input_field.clear()
return
else:
return
if _history_idx >= 0:
_input_field.text = _history[_history_idx]
_input_field.caret_column = _input_field.text.length() _input_field.caret_column = _input_field.text.length()
return return
var meta: Dictionary = DP.get_all()[key]
_input_field.text = "%s %s" % [key, _fmt_val(key, meta)]
_input_field.caret_column = _input_field.text.length()
_input_field.select(key.length() + 1)
# Cycle through suggestion list
if _selected_idx < 0:
_selected_idx = 0 if dir == 1 else _suggestions.size() - 1
else:
_selected_idx = wrapi(_selected_idx + dir, 0, _suggestions.size())
func _recolor_suggestions() -> void:
for i in MAX_SUGGESTIONS: for i in MAX_SUGGESTIONS:
if _suggest_labels[i].visible: if _suggest_labels[i].visible:
_suggest_labels[i].modulate = _suggestion_color(i) _suggest_labels[i].modulate = _suggestion_color(i)
var key := _suggestions[_selected_idx]
var meta: Dictionary = DP.get_all()[key] # ↑ recalls older commands, ↓ newer ones; index -1 is the live (in-progress) line,
_input_field.text = "%s %s" % [key, _fmt_val(key, meta)] # which is stashed on the first ↑ and restored when you step back down to it.
_input_field.caret_column = _input_field.text.length() func _history_step(dir: int) -> void:
if _history.is_empty():
return
var target := _history_idx + dir
if target < -1 or target >= _history.size():
return
if _history_idx == -1 and dir > 0:
_pending_line = _input_field.text
_history_idx = target
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
func _set_input(text: String) -> void:
_input_field.text = text
_input_field.caret_column = text.length()
_clear_suggestions()
# ── Command execution ───────────────────────────────────────────────────────── # ── Command execution ─────────────────────────────────────────────────────────
@@ -327,87 +393,167 @@ func _on_submitted(raw: String) -> void:
raw = raw.strip_edges() raw = raw.strip_edges()
if raw.is_empty(): if raw.is_empty():
return return
if _history.is_empty() or _history[0] != raw:
_history.push_front(raw) _history.push_front(raw)
_history_idx = -1 _history_idx = -1
_pending_line = ""
_input_field.clear() _input_field.clear()
_clear_suggestions() _clear_suggestions()
_execute(raw) _execute(raw)
# Defer: the LineEdit finishes handling the Enter keypress after this signal
# returns, so a synchronous grab_focus() gets clobbered. Re-grab next idle.
_input_field.grab_focus.call_deferred()
func _execute(cmd: String) -> void: func _execute(cmd: String) -> void:
_log_raw("[color=#3a5a3a]> %s[/color]" % cmd) _log_raw("[color=#3a5a3a]> %s[/color]" % _esc(cmd))
var parts := cmd.split(" ", false) var parts := cmd.split(" ", false)
if parts.is_empty(): if parts.is_empty():
return return
match parts[0].to_lower(): if _run_command(parts):
"save":
DP.save()
_log_ok("Saved.")
return
"reset":
DP.reset_all()
_log_ok("All params reset to defaults.")
return
"list":
_cmd_list(parts)
return
"help":
_log_raw("[color=#888888] save[/color] — persist values to disk")
_log_raw("[color=#888888] reset[/color] — restore all defaults")
_log_raw("[color=#888888] list [section][/color] — print params (optional section filter)")
_log_raw("[color=#888888] <name>[/color] — read current value + range")
_log_raw("[color=#888888] <name> <value>[/color] — set param (bool: true/false/1/0)")
return return
# Treat as param key, with fuzzy fall-back for partial names # Not a command — treat as a param key, with fuzzy fall-back for partial names.
var key := parts[0] var key := _resolve_key(parts[0])
var all := DP.get_all() if key.is_empty():
if not all.has(key):
var matches := _fuzzy_find_all(key)
if matches.size() == 1:
key = matches[0]
_log_raw("[color=#8888ff]→ %s[/color]" % key)
else:
if matches.size() > 1:
_log_warn("Ambiguous '%s': %s" % [parts[0], ", ".join(matches.slice(0, 6))])
else:
_log_warn("Unknown: %s" % parts[0])
return return
var meta: Dictionary = DP.get_all()[key]
var meta: Dictionary = all[key]
if parts.size() == 1: if parts.size() == 1:
_read_param(key, meta) _read_param(key, meta)
else: else:
_set_param(key, meta, parts[1]) _set_param(key, meta, parts[1])
# Run a built-in command; returns false if parts[0] isn't one (caller falls through
# to param handling). Kept separate from _execute to keep each dispatch shallow.
func _run_command(parts: PackedStringArray) -> bool:
match parts[0].to_lower():
"save":
DP.save()
_log_ok("Saved.")
"reset":
_cmd_reset(parts)
"toggle":
_cmd_toggle(parts)
"diff":
_cmd_diff()
"list":
_cmd_list(parts)
"clear":
_log.clear()
"help":
_cmd_help()
_:
return false
return true
# Resolve a user-typed token to a param key: exact match, else unique fuzzy match.
# Logs an ambiguity / unknown warning (and an arrow on a fuzzy hit) and returns
# "" when it can't narrow to a single key.
func _resolve_key(raw: String) -> String:
if DP.get_all().has(raw):
return raw
var matches := _fuzzy_find_all(raw)
if matches.size() == 1:
_log_raw("[color=#8888ff]→ %s[/color]" % matches[0])
return matches[0]
if matches.size() > 1:
_log_warn("Ambiguous '%s': %s" % [_esc(raw), ", ".join(matches.slice(0, 6))])
else:
_log_warn("Unknown: %s" % _esc(raw))
return ""
func _cmd_reset(parts: PackedStringArray) -> void:
if parts.size() == 1:
DP.reset_all()
_log_ok("All params reset to defaults.")
return
var key := _resolve_key(parts[1])
if key.is_empty():
return
DP.reset(key)
_log_ok("[b]%s[/b] reset to [color=#ffe080]%s[/color]" % [key, _fmt_val(key, DP.get_all()[key])])
func _cmd_toggle(parts: PackedStringArray) -> void:
if parts.size() < 2:
_log_warn("Usage: toggle <bool param>")
return
var key := _resolve_key(parts[1])
if key.is_empty():
return
var meta: Dictionary = DP.get_all()[key]
if meta["type"] != TYPE_BOOL:
_log_warn("%s is not a bool — use '%s <value>'" % [key, key])
return
var v := not (meta["value"] as bool)
DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, "true" if v else "false"])
# List only params that currently differ from their registered default, grouped by
# section — the "what have I actually changed?" view before a save.
func _cmd_diff() -> void:
var all := DP.get_all()
var sections: Dictionary = {}
for key: String in all:
if not DP.is_modified(key):
continue
var sec: String = all[key]["section"]
if not sections.has(sec):
sections[sec] = []
sections[sec].append(key)
if sections.is_empty():
_log_ok("All params at defaults.")
return
for sec: String in sections:
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
for key: String in sections[sec]:
var meta: Dictionary = all[key]
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color] [color=#484848](def %s)[/color]" % [
key, _fmt_val(key, meta), _fmt_default(meta)
])
func _cmd_help() -> void:
_log_raw("[color=#888888] <name>[/color] — read current value + range")
_log_raw("[color=#888888] <name> <value>[/color] — set it (number, or true/false)")
for name: String in _COMMANDS:
_log_raw("[color=#888888] %s[/color] — %s" % [name.rpad(20), _COMMANDS[name]])
_log_raw(" [color=#555555]↑↓ pick suggestion · Tab complete · ↑↓ history when empty · ` close[/color]")
func _fmt_default(meta: Dictionary) -> String:
if meta["type"] == TYPE_FLOAT:
return _fmt_num(meta["default"] as float)
return "true" if (meta["default"] as bool) else "false"
func _read_param(key: String, meta: Dictionary) -> void: func _read_param(key: String, meta: Dictionary) -> void:
if meta["type"] == TYPE_FLOAT: if meta["type"] == TYPE_FLOAT:
_log_raw( _log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](%s%s)[/color]" % [
"[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](min %s max %s step %s default %s)[/color]" % [
key, _fmt_val(key, meta), key, _fmt_val(key, meta),
"%.4g" % (meta["min"] as float), _fmt_num(meta["min"] as float), _fmt_num(meta["max"] as float),
"%.4g" % (meta["max"] as float), ])
"%.4g" % (meta["step"] as float),
"%.4g" % (meta["default"] as float),
]
)
else: else:
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color]" % [key, _fmt_val(key, meta)]) _log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](true / false)[/color]" % [
key, _fmt_val(key, meta),
])
func _set_param(key: String, meta: Dictionary, raw: String) -> void: func _set_param(key: String, meta: Dictionary, raw: String) -> void:
if meta["type"] == TYPE_FLOAT: if meta["type"] == TYPE_FLOAT:
if not raw.is_valid_float(): var lo := meta["min"] as float
_log_warn("Expected number, got: %s" % raw) var hi := meta["max"] as float
if not raw.is_valid_float() or not is_finite(raw.to_float()):
_log_warn("%s wants a number %s%s" % [key, _fmt_num(lo), _fmt_num(hi)])
return return
var v := snappedf( var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float)
clampf(raw.to_float(), meta["min"] as float, meta["max"] as float),
meta["step"] as float
)
DP.set_value(key, v) DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%.4g[/color]" % [key, v]) _log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
elif meta["type"] == TYPE_BOOL: elif meta["type"] == TYPE_BOOL:
match raw.to_lower(): match raw.to_lower():
"1", "true", "yes", "on": "1", "true", "yes", "on":
@@ -417,7 +563,7 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void:
DP.set_value(key, false) DP.set_value(key, false)
_log_ok("[b]%s[/b] ← [color=#ffe080]false[/color]" % key) _log_ok("[b]%s[/b] ← [color=#ffe080]false[/color]" % key)
_: _:
_log_warn("Expected true/false, got: %s" % raw) _log_warn("%s wants true or false" % key)
else: else:
_log_warn("Cannot set param of type %d" % meta["type"]) _log_warn("Cannot set param of type %d" % meta["type"])
@@ -453,6 +599,12 @@ func _fuzzy_find_all(query: String) -> Array[String]:
# ── Logging ─────────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────────
## Neutralise BBCode in user-supplied text before it reaches the RichTextLabel,
## so malformed or hostile tags (e.g. [img]) render literally instead of running.
func _esc(text: String) -> String:
return text.replace("[", "[lb]")
func _log_raw(text: String) -> void: func _log_raw(text: String) -> void:
if _log.get_line_count() > LOG_MAX_LINES: if _log.get_line_count() > LOG_MAX_LINES:
_log.clear() _log.clear()
+1
View File
@@ -17,6 +17,7 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
&"ability_kick", &"ability_kick",
&"ability_slam", &"ability_slam",
&"ability_dash", &"ability_dash",
&"ability_roll",
] ]
+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
+1
View File
@@ -0,0 +1 @@
uid://cgty4al6aqey3
+68 -8
View File
@@ -7,7 +7,12 @@ signal any_changed(key: String, value: Variant)
const SAVE_PATH := "user://debug_params.cfg" const SAVE_PATH := "user://debug_params.cfg"
# _params holds the full metadata (min/max/step/default/section/type) and is the
# authority the debug menu reads. _values is a flat key→value mirror so the hot
# f()/b() reads — called dozens of times per matador per physics frame — cost a
# single hash lookup instead of a nested dict access. Every write updates both.
var _params: Dictionary = {} var _params: Dictionary = {}
var _values: Dictionary = {}
func _ready() -> void: func _ready() -> void:
@@ -112,12 +117,19 @@ func _register_all() -> void:
_reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1) _reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1)
_reg_f("Matador", "mat_step_speed", 3.5, 2.0, 20.0) _reg_f("Matador", "mat_step_speed", 3.5, 2.0, 20.0)
_reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05) _reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05)
_reg_f("Matador", "mat_dodge_cooldown", 5.0, 0.5, 12.0, 0.25) # How strongly the sidestep biases toward the bull so the blade sweeps through
_reg_f("Matador", "mat_dodge_chance", 0.25, 0.0, 1.0, 0.05) # its path during the pass (0 = pure lateral dodge, 1 = straight at the bull).
_reg_f("Matador", "mat_pass_lunge", 0.35, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_dodge_cooldown", 2.5, 0.5, 12.0, 0.25)
_reg_f("Matador", "mat_dodge_chance", 0.55, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0) _reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0)
_reg_f("Matador", "mat_attack_speed", 5.5, 1.0, 12.0) _reg_f("Matador", "mat_attack_speed", 5.5, 1.0, 12.0)
_reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1) _reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1)
_reg_f("Matador", "mat_attack_min_dist", 2.5, 0.5, 6.0, 0.1) _reg_f("Matador", "mat_attack_min_dist", 3.0, 0.5, 6.0, 0.1)
# Forward drive during the opening of a melee swing — turns the stationary stab
# into an estocada lunge so the blade covers ground toward the bull.
_reg_f("Matador", "mat_lunge_speed", 9.0, 0.0, 20.0)
_reg_f("Matador", "mat_lunge_time", 0.18, 0.0, 0.6, 0.01)
_reg_f("Matador", "mat_roll_chance", 0.35, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_roll_chance", 0.35, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05) _reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05)
# Playback rate of the Draw_weapon clip used for both drawing and sheathing — # Playback rate of the Draw_weapon clip used for both drawing and sheathing —
@@ -174,8 +186,35 @@ func _register_all() -> void:
_reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0) _reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0)
_reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1) _reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1)
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0) _reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
# ── Debug ───────────────────────────────────────────────────────────────── # ── Roll (boulder charge) ─────────────────────────────────────────────────
# A held-momentum roll: inherits your current charge, ramps toward roll_max_speed,
# and RICOCHETS off walls with a speed boost (bank shots build ludicrous speed).
_reg_f("Roll", "roll_cooldown", 3.0, 0.5, 15.0, 0.1)
_reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1)
_reg_f("Roll", "roll_min_speed", 30.0, 5.0, 120.0) # launch floor (combines charge)
_reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # natural top from acceleration
_reg_f("Roll", "roll_accel", 55.0, 0.0, 300.0)
# Per-second speed retained above roll_max_speed — low friction so wall-boosted
# momentum bleeds off slowly rather than snapping back to the cruise speed.
_reg_f("Roll", "roll_momentum", 0.9, 0.1, 0.99, 0.01)
_reg_f("Roll", "roll_turn", 3.5, 0.2, 20.0) # steer rate (sluggish = momentum feel)
_reg_f("Roll", "roll_wall_boost", 1.25, 1.0, 2.0, 0.05) # speed × on each ricochet
_reg_f("Roll", "roll_wall_cap", 150.0, 10.0, 300.0) # ceiling reachable via boosts
_reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1)
_reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0)
_reg_f("Roll", "roll_hit_up", 4.5, 0.0, 15.0)
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
_reg_b("Debug", "show_collisions", false) _reg_b("Debug", "show_collisions", false)
_reg_b("Debug", "show_hitboxes", false)
_reg_b("Debug", "show_bones", false)
_reg_b("Debug", "show_raycasts", false)
_reg_b("Debug", "show_tail", false)
_reg_b("Debug", "show_stats", false)
_reg_b("Debug", "show_states", false)
_reg_b("Debug", "show_animation_name", false)
_reg_b("Debug", "show_wireframe", false)
_reg_b("Debug", "show_grid", false)
_reg_b("Debug", "show_axes", false)
func _reg_f(section: String, key: String, default: float, func _reg_f(section: String, key: String, default: float,
@@ -189,6 +228,7 @@ func _reg_f(section: String, key: String, default: float,
"max": max_val, "max": max_val,
"step": step, "step": step,
} }
_values[key] = default
func _reg_b(section: String, key: String, default: bool) -> void: func _reg_b(section: String, key: String, default: bool) -> void:
@@ -198,26 +238,28 @@ func _reg_b(section: String, key: String, default: bool) -> void:
"section": section, "section": section,
"type": TYPE_BOOL, "type": TYPE_BOOL,
} }
_values[key] = default
## Read a float param. Panics on unknown key — intentional: typos should be loud. ## Read a float param. Panics on unknown key — intentional: typos should be loud.
func f(key: String) -> float: func f(key: String) -> float:
return _params[key]["value"] as float return _values[key] as float
## Read a bool param. ## Read a bool param.
func b(key: String) -> bool: func b(key: String) -> bool:
return _params[key]["value"] as bool return _values[key] as bool
func set_value(key: String, value: Variant) -> void: func set_value(key: String, value: Variant) -> void:
if not _params.has(key): if not _params.has(key):
return return
_params[key]["value"] = value _params[key]["value"] = value
_values[key] = value
any_changed.emit(key, value) any_changed.emit(key, value)
## Returns the full metadata dict — used by DebugMenu to build UI. ## Returns the full metadata dict — used by Console to build UI.
func get_all() -> Dictionary: func get_all() -> Dictionary:
return _params return _params
@@ -243,9 +285,27 @@ func load_saved() -> void:
if p["type"] == TYPE_FLOAT: if p["type"] == TYPE_FLOAT:
val = clampf(val as float, p["min"] as float, p["max"] as float) val = clampf(val as float, p["min"] as float, p["max"] as float)
_params[key]["value"] = val _params[key]["value"] = val
_values[key] = val
func reset_all() -> void: func reset_all() -> void:
for key: String in _params: for key: String in _params:
_params[key]["value"] = _params[key]["default"] var default: Variant = _params[key]["default"]
_params[key]["value"] = default
_values[key] = default
any_changed.emit("__all__", null) any_changed.emit("__all__", null)
## Reset a single param to its default. No-op on unknown key.
func reset(key: String) -> void:
if not _params.has(key):
return
var default: Variant = _params[key]["default"]
_params[key]["value"] = default
_values[key] = default
any_changed.emit(key, default)
## True when the param currently differs from its registered default.
func is_modified(key: String) -> bool:
return _params.has(key) and _params[key]["value"] != _params[key]["default"]
+201 -98
View File
@@ -7,12 +7,22 @@ const _BG := Color(0.06, 0.04, 0.02, 0.78)
const _BADGE := Color(0.16, 0.10, 0.03, 0.90) const _BADGE := Color(0.16, 0.10, 0.03, 0.90)
const _BORDER := Color(0.65, 0.48, 0.15, 0.55) const _BORDER := Color(0.65, 0.48, 0.15, 0.55)
# Scoring: each kill scores _KILL_BASE × the live combo count; the combo grows with
# every kill inside _COMBO_WINDOW seconds of the last and resets when that lapses.
const _KILL_BASE: int = 100
const _COMBO_WINDOW: float = 3.0
const _ABILITY_ICONS: Array[String] = [ const _ABILITY_ICONS: Array[String] = [
"res://HUD/HUD_0000_ability_JALG.png", "res://HUD/HUD_0000_ability_JALG.png",
"res://HUD/HUD_0001_ability_SLAM.png", "res://HUD/HUD_0001_ability_SLAM.png",
"res://HUD/HUD_0002_ability_DASH.png", "res://HUD/HUD_0002_ability_DASH.png",
"res://HUD/HUD_0003_ability_ROLL.png",
] ]
const _ABILITY_ACTIONS: Array[StringName] = [&"ability_kick", &"ability_slam", &"ability_dash"] const _ABILITY_ACTIONS: Array[StringName] = [
&"ability_kick", &"ability_slam", &"ability_dash", &"ability_roll",
]
# Fallback label shown in a slot whose icon file doesn't exist yet (e.g. ROLL).
const _ABILITY_NAMES: Array[String] = ["Kick", "Slam", "Dash", "Roll"]
var _controls_visible: bool = false var _controls_visible: bool = false
var _controls_panel: PanelContainer var _controls_panel: PanelContainer
@@ -25,6 +35,16 @@ var _cd_labels: Array[Label] = []
var _health_segments: Array[ColorRect] = [] var _health_segments: Array[ColorRect] = []
var _health_connected: bool = false var _health_connected: bool = false
var _score: int = 0
var _combo: int = 0
var _combo_timer: float = 0.0
var _score_label: Label
var _wave_label: Label
var _combo_label: Label
var _combo_track: ColorRect
var _combo_fill: ColorRect
var _banner_label: Label
func _ready() -> void: func _ready() -> void:
layer = 11 layer = 11
@@ -33,10 +53,12 @@ func _ready() -> void:
_build_tab_hint() _build_tab_hint()
_build_ability_bar() _build_ability_bar()
_build_health_bar() _build_health_bar()
_build_scoreboard()
_find_spawner.call_deferred() _find_spawner.call_deferred()
func _process(_delta: float) -> void: func _process(delta: float) -> void:
_update_combo(delta)
if _player == null: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
@@ -62,8 +84,12 @@ func _process(_delta: float) -> void:
func _find_spawner() -> void: func _find_spawner() -> void:
var spawners := get_tree().get_nodes_in_group(&"matador_spawn") var spawners := get_tree().get_nodes_in_group(&"matador_spawn")
if not spawners.is_empty(): if spawners.is_empty():
return
_spawner = spawners[0] _spawner = spawners[0]
_spawner.matador_killed.connect(_on_matador_killed)
_spawner.wave_changed.connect(_on_wave_changed)
_on_wave_changed(_spawner.current_wave())
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
@@ -92,6 +118,26 @@ func _on_reset_pressed() -> void:
get_tree().paused = false get_tree().paused = false
if _spawner: if _spawner:
_spawner.reset_spawn() _spawner.reset_spawn()
_score = 0
_score_label.text = "SCORE 0"
_reset_combo()
# A bordered, rounded, gold-trimmed panel background — the HUD's one repeated look.
# Margins are symmetric (L=R, T=B); pass a transparent border to draw none.
func _panel_style(bg: Color, corner: int, h_margin: float, v_margin: float,
border: Color = _BORDER) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = bg
s.set_corner_radius_all(corner)
if border.a > 0.0:
s.set_border_width_all(1)
s.border_color = border
s.content_margin_left = h_margin
s.content_margin_right = h_margin
s.content_margin_top = v_margin
s.content_margin_bottom = v_margin
return s
func _build_health_bar() -> void: func _build_health_bar() -> void:
@@ -107,24 +153,8 @@ func _build_health_bar() -> void:
bar.offset_bottom = 16.0 bar.offset_bottom = 16.0
add_child(bar) add_child(bar)
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = _BG
bg_style.corner_radius_top_left = 4
bg_style.corner_radius_top_right = 4
bg_style.corner_radius_bottom_left = 4
bg_style.corner_radius_bottom_right = 4
bg_style.border_width_left = 1
bg_style.border_width_right = 1
bg_style.border_width_top = 1
bg_style.border_width_bottom = 1
bg_style.border_color = _BORDER
bg_style.content_margin_left = 6.0
bg_style.content_margin_right = 6.0
bg_style.content_margin_top = 6.0
bg_style.content_margin_bottom = 6.0
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", bg_style) panel.add_theme_stylebox_override("panel", _panel_style(_BG, 4, 6.0, 6.0))
bar.add_child(panel) bar.add_child(panel)
var inner := HBoxContainer.new() var inner := HBoxContainer.new()
@@ -145,6 +175,139 @@ func _on_health_changed(new_health: int) -> void:
else Color(0.20, 0.10, 0.10) else Color(0.20, 0.10, 0.10)
# ── Scoreboard: score · wave · kill-combo ──────────────────────────────────────
func _build_scoreboard() -> void:
var panel := PanelContainer.new()
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
panel.anchor_left = 1.0
panel.anchor_right = 1.0
panel.anchor_top = 0.0
panel.anchor_bottom = 0.0
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
panel.offset_left = -172.0
panel.offset_right = -14.0
panel.offset_top = 14.0
panel.add_theme_stylebox_override("panel", _panel_style(_BG, 6, 12.0, 8.0))
add_child(panel)
var vbox := VBoxContainer.new()
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_theme_constant_override("separation", 2)
panel.add_child(vbox)
_score_label = _score_line(vbox, "SCORE 0", _GOLD, 20)
_wave_label = _score_line(vbox, "WAVE 1", _CREAM, 14)
_combo_label = _score_line(vbox, "", _CREAM, 16)
_combo_track = ColorRect.new()
_combo_track.color = Color(0.0, 0.0, 0.0, 0.4)
_combo_track.custom_minimum_size = Vector2(0.0, 4.0)
_combo_track.mouse_filter = Control.MOUSE_FILTER_IGNORE
_combo_track.visible = false
vbox.add_child(_combo_track)
_combo_fill = ColorRect.new()
_combo_fill.color = _GOLD
_combo_fill.set_anchors_preset(Control.PRESET_FULL_RECT)
_combo_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_combo_track.add_child(_combo_fill)
# Centre banner that flashes on each new wave.
_banner_label = Label.new()
_banner_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_banner_label.anchor_left = 0.5
_banner_label.anchor_right = 0.5
_banner_label.anchor_top = 0.26
_banner_label.anchor_bottom = 0.26
_banner_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
_banner_label.grow_vertical = Control.GROW_DIRECTION_BOTH
_banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_banner_label.add_theme_color_override("font_color", _GOLD)
_banner_label.add_theme_font_size_override("font_size", 44)
_banner_label.modulate.a = 0.0
add_child(_banner_label)
func _score_line(parent: VBoxContainer, text: String, color: Color, size: int) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
lbl.add_theme_color_override("font_color", color)
lbl.add_theme_font_size_override("font_size", size)
parent.add_child(lbl)
return lbl
# Every kill lengthens the combo; scoring is base × combo so a fast chain is worth
# far more than the same kills spread out — rewarding aggressive, uninterrupted play.
func _on_matador_killed() -> void:
_combo += 1
_combo_timer = _COMBO_WINDOW
_score += _KILL_BASE * _combo
_score_label.text = "SCORE %d" % _score
_pulse(_score_label, 1.15)
if _combo >= 2:
_combo_label.text = "%d× COMBO" % _combo
_combo_label.add_theme_color_override("font_color", _combo_color())
_combo_fill.color = _combo_color()
_combo_track.visible = true
_pulse(_combo_label, 1.25)
else:
_combo_label.text = ""
_combo_track.visible = false
func _on_wave_changed(wave: int) -> void:
_wave_label.text = "WAVE %d" % wave
if wave > 1:
_flash_banner("WAVE %d" % wave)
func _update_combo(delta: float) -> void:
if _combo <= 0:
return
_combo_timer -= delta
if _combo_timer <= 0.0:
_reset_combo()
elif _combo_track.visible:
_combo_fill.anchor_right = clampf(_combo_timer / _COMBO_WINDOW, 0.0, 1.0)
func _reset_combo() -> void:
_combo = 0
_combo_timer = 0.0
_combo_label.text = ""
_combo_track.visible = false
func _combo_color() -> Color:
if _combo >= 8:
return Color(1.0, 0.25, 0.10)
if _combo >= 5:
return Color(1.0, 0.50, 0.10)
if _combo >= 3:
return Color(1.0, 0.85, 0.20)
return _CREAM
func _pulse(ctrl: Control, amount: float) -> void:
ctrl.pivot_offset = ctrl.size * 0.5
ctrl.scale = Vector2.ONE * amount
create_tween().tween_property(ctrl, "scale", Vector2.ONE, 0.25) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
func _flash_banner(text: String) -> void:
_banner_label.text = text
_banner_label.modulate.a = 0.0
var tw := create_tween()
tw.tween_property(_banner_label, "modulate:a", 1.0, 0.2)
tw.tween_interval(0.7)
tw.tween_property(_banner_label, "modulate:a", 0.0, 0.6)
func _build_ability_bar() -> void: func _build_ability_bar() -> void:
var bar := HBoxContainer.new() var bar := HBoxContainer.new()
bar.add_theme_constant_override("separation", 10) bar.add_theme_constant_override("separation", 10)
@@ -154,10 +317,10 @@ func _build_ability_bar() -> void:
bar.anchor_bottom = 1.0 bar.anchor_bottom = 1.0
bar.grow_horizontal = Control.GROW_DIRECTION_BOTH bar.grow_horizontal = Control.GROW_DIRECTION_BOTH
bar.grow_vertical = Control.GROW_DIRECTION_BEGIN bar.grow_vertical = Control.GROW_DIRECTION_BEGIN
bar.offset_left = -120.0 bar.offset_left = -160.0
bar.offset_bottom = -20.0 bar.offset_bottom = -20.0
add_child(bar) add_child(bar)
for i: int in 3: for i: int in 4:
bar.add_child(_build_ability_slot(i)) bar.add_child(_build_ability_slot(i))
@@ -165,25 +328,9 @@ func _build_ability_slot(idx: int) -> Control:
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4) vbox.add_theme_constant_override("separation", 4)
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = _BG
bg_style.corner_radius_top_left = 6
bg_style.corner_radius_top_right = 6
bg_style.corner_radius_bottom_left = 6
bg_style.corner_radius_bottom_right = 6
bg_style.border_width_left = 1
bg_style.border_width_right = 1
bg_style.border_width_top = 1
bg_style.border_width_bottom = 1
bg_style.border_color = _BORDER
bg_style.content_margin_left = 4.0
bg_style.content_margin_right = 4.0
bg_style.content_margin_top = 4.0
bg_style.content_margin_bottom = 4.0
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(72.0, 72.0) panel.custom_minimum_size = Vector2(72.0, 72.0)
panel.add_theme_stylebox_override("panel", bg_style) panel.add_theme_stylebox_override("panel", _panel_style(_BG, 6, 4.0, 4.0))
vbox.add_child(panel) vbox.add_child(panel)
var stack := Control.new() var stack := Control.new()
@@ -196,6 +343,16 @@ func _build_ability_slot(idx: int) -> Control:
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.set_anchors_preset(Control.PRESET_FULL_RECT) icon.set_anchors_preset(Control.PRESET_FULL_RECT)
stack.add_child(icon) stack.add_child(icon)
else:
var name_lbl := Label.new()
name_lbl.text = _ABILITY_NAMES[idx]
name_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
name_lbl.add_theme_color_override("font_color", _GOLD)
name_lbl.add_theme_font_size_override("font_size", 16)
stack.add_child(name_lbl)
var cd_overlay := ColorRect.new() var cd_overlay := ColorRect.new()
cd_overlay.color = Color(0.0, 0.0, 0.0, 0.70) cd_overlay.color = Color(0.0, 0.0, 0.0, 0.70)
@@ -245,23 +402,7 @@ func _build_tab_hint() -> void:
func _build_controls_panel() -> void: func _build_controls_panel() -> void:
_controls_panel = PanelContainer.new() _controls_panel = PanelContainer.new()
_controls_panel.add_theme_stylebox_override("panel", _panel_style(_BG, 7, 14.0, 10.0))
var bg := StyleBoxFlat.new()
bg.bg_color = _BG
bg.corner_radius_top_left = 7
bg.corner_radius_top_right = 7
bg.corner_radius_bottom_left = 7
bg.corner_radius_bottom_right = 7
bg.border_width_left = 1
bg.border_width_right = 1
bg.border_width_top = 1
bg.border_width_bottom = 1
bg.border_color = _BORDER
bg.content_margin_left = 14.0
bg.content_margin_right = 14.0
bg.content_margin_top = 10.0
bg.content_margin_bottom = 10.0
_controls_panel.add_theme_stylebox_override("panel", bg)
_controls_panel.anchor_left = 0.0 _controls_panel.anchor_left = 0.0
_controls_panel.anchor_top = 1.0 _controls_panel.anchor_top = 1.0
@@ -289,6 +430,7 @@ func _build_controls_panel() -> void:
_row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM) _row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM) _row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM) _row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (bank off walls!)", _CREAM)
_row(vbox, "Scroll", "Zoom", _CREAM) _row(vbox, "Scroll", "Zoom", _CREAM)
_row(vbox, "R", "Respawn matadors (cooldown)", _MUTED) _row(vbox, "R", "Respawn matadors (cooldown)", _MUTED)
_row(vbox, "Tab", "Toggle controls", _MUTED) _row(vbox, "Tab", "Toggle controls", _MUTED)
@@ -320,37 +462,8 @@ func _make_button(label_text: String) -> Button:
btn.text = label_text btn.text = label_text
btn.process_mode = Node.PROCESS_MODE_ALWAYS btn.process_mode = Node.PROCESS_MODE_ALWAYS
var normal := StyleBoxFlat.new() var normal := _panel_style(Color(0.18, 0.12, 0.04, 0.90), 5, 12.0, 6.0)
normal.bg_color = Color(0.18, 0.12, 0.04, 0.90) var hover := _panel_style(Color(0.30, 0.20, 0.06, 0.95), 5, 12.0, 6.0, _GOLD)
normal.corner_radius_top_left = 5
normal.corner_radius_top_right = 5
normal.corner_radius_bottom_left = 5
normal.corner_radius_bottom_right = 5
normal.border_width_left = 1
normal.border_width_right = 1
normal.border_width_top = 1
normal.border_width_bottom = 1
normal.border_color = _BORDER
normal.content_margin_left = 12.0
normal.content_margin_right = 12.0
normal.content_margin_top = 6.0
normal.content_margin_bottom = 6.0
var hover := StyleBoxFlat.new()
hover.bg_color = Color(0.30, 0.20, 0.06, 0.95)
hover.corner_radius_top_left = 5
hover.corner_radius_top_right = 5
hover.corner_radius_bottom_left = 5
hover.corner_radius_bottom_right = 5
hover.border_width_left = 1
hover.border_width_right = 1
hover.border_width_top = 1
hover.border_width_bottom = 1
hover.border_color = _GOLD
hover.content_margin_left = 12.0
hover.content_margin_right = 12.0
hover.content_margin_top = 6.0
hover.content_margin_bottom = 6.0
btn.add_theme_stylebox_override("normal", normal) btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_stylebox_override("hover", hover) btn.add_theme_stylebox_override("hover", hover)
@@ -368,17 +481,7 @@ func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -
parent.add_child(row) parent.add_child(row)
var badge := PanelContainer.new() var badge := PanelContainer.new()
var bs := StyleBoxFlat.new() badge.add_theme_stylebox_override("panel", _panel_style(_BADGE, 3, 6.0, 2.0, Color(0, 0, 0, 0)))
bs.bg_color = _BADGE
bs.corner_radius_top_left = 3
bs.corner_radius_top_right = 3
bs.corner_radius_bottom_left = 3
bs.corner_radius_bottom_right = 3
bs.content_margin_left = 6.0
bs.content_margin_right = 6.0
bs.content_margin_top = 2.0
bs.content_margin_bottom = 2.0
badge.add_theme_stylebox_override("panel", bs)
badge.custom_minimum_size = Vector2(100.0, 0.0) badge.custom_minimum_size = Vector2(100.0, 0.0)
row.add_child(badge) row.add_child(badge)
+1
View File
@@ -55,6 +55,7 @@ const ACTION_LABELS: Dictionary = {
&"ability_kick": "Kick", &"ability_kick": "Kick",
&"ability_slam": "Slam", &"ability_slam": "Slam",
&"ability_dash": "Dash", &"ability_dash": "Dash",
&"ability_roll": "Roll",
} }
@onready var main_panel: Control = $MainPanel @onready var main_panel: Control = $MainPanel
+59 -10
View File
@@ -18,6 +18,7 @@ var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0 var _idle_timer: float = 0.0
var _idle_anim: StringName = _ANIM_IDLE
var _bull: CharacterBody3D = null var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO var _step_dir: Vector3 = Vector3.ZERO
var _brace_timer: float = 0.0 var _brace_timer: float = 0.0
@@ -26,6 +27,7 @@ var _dodge_cd: float = 0.0
var _will_dodge: bool = true var _will_dodge: bool = true
var _attack_timer: float = 0.0 var _attack_timer: float = 0.0
var _swing_timer: float = 0.0 var _swing_timer: float = 0.0
var _lunge_timer: float = 0.0
var _roll_timer: float = 0.0 var _roll_timer: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO var _roll_dir: Vector3 = Vector3.ZERO
var _throw_timer: float = 0.0 var _throw_timer: float = 0.0
@@ -57,6 +59,10 @@ const _ANIM_ATTACK: StringName = &"Attack"
const _ANIM_ROLL: StringName = &"Roll" const _ANIM_ROLL: StringName = &"Roll"
const _ANIM_DRAW: StringName = &"Draw_weapon" const _ANIM_DRAW: StringName = &"Draw_weapon"
# Idle taunt clips — one is picked at random each time the matador pauses to
# taunt while wandering, so the crowd doesn't see the same gesture every time.
const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
# Fraction of the draw clip at which the hand grabs the sword — the sword # Fraction of the draw clip at which the hand grabs the sword — the sword
# reparents from holster to hand at this point in the animation. # reparents from holster to hand at this point in the animation.
const _DRAW_GRAB_AT: float = 0.55 const _DRAW_GRAB_AT: float = 0.55
@@ -82,7 +88,7 @@ func _ready() -> void:
if _skeleton: if _skeleton:
_sim = MatadorRagdoll.build(_skeleton) _sim = MatadorRagdoll.build(_skeleton)
if _anim_player: if _anim_player:
for anim in [_ANIM_RUN, _ANIM_IDLE, _ANIM_ATTACK, _ANIM_ROLL]: for anim in [_ANIM_RUN, _ANIM_ATTACK, _ANIM_ROLL] + _ANIM_TAUNTS:
_ensure_loop(anim) _ensure_loop(anim)
if not _anim_player.has_animation(_ANIM_RUN): if not _anim_player.has_animation(_ANIM_RUN):
push_warning("Matador: expected animations not found. Available: %s" % push_warning("Matador: expected animations not found. Available: %s" %
@@ -125,6 +131,10 @@ func _acquire_bull() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_acquire_bull() _acquire_bull()
if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
var o := global_position + Vector3.UP * 0.6
DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))
if not is_on_floor(): if not is_on_floor():
velocity += get_gravity() * delta velocity += get_gravity() * delta
@@ -266,7 +276,7 @@ func _tick_wander(delta: float) -> void:
if _drawing or _sheathing or _idle_timer > 0.0: if _drawing or _sheathing or _idle_timer > 0.0:
_idle_timer = maxf(0.0, _idle_timer - delta) _idle_timer = maxf(0.0, _idle_timer - delta)
_decelerate(10.0, delta) _decelerate(10.0, delta)
_play_anim(_ANIM_IDLE) _play_anim(_idle_anim)
move_and_slide() move_and_slide()
return return
@@ -274,6 +284,7 @@ func _tick_wander(delta: float) -> void:
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
if to_target.length() < 0.8: if to_target.length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) _idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt()
_pick_wander_target() _pick_wander_target()
else: else:
var dir := _steer_clear(to_target.normalized(), delta) var dir := _steer_clear(to_target.normalized(), delta)
@@ -340,17 +351,23 @@ func _tick_brace(delta: float) -> void:
_dodge_cd = DP.f("mat_dodge_cooldown") _dodge_cd = DP.f("mat_dodge_cooldown")
# Pase phase: sharp lateral step, then immediately exploit with an attack # Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps
# across its path as it charges by; face the bull so the sword points at it.
func _tick_sidestep(delta: float) -> void: func _tick_sidestep(delta: float) -> void:
_step_timer -= delta _step_timer -= delta
if _step_timer <= 0.0: if _step_timer <= 0.0:
_start_attack() _start_attack()
return return
var dir := _step_dir
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
if to_bull.length() > 0.1:
dir = (_step_dir + to_bull.normalized() * DP.f("mat_pass_lunge")).normalized()
var spd := DP.f("mat_step_speed") var spd := DP.f("mat_step_speed")
velocity.x = _step_dir.x * spd velocity.x = dir.x * spd
velocity.z = _step_dir.z * spd velocity.z = dir.z * spd
_face_velocity(delta, _TURN_SHARP) _face_point(_bull.global_position, delta, _TURN_SHARP)
_play_anim(_ANIM_ROLL) _play_anim(_ANIM_ATTACK)
move_and_slide() move_and_slide()
@@ -380,8 +397,13 @@ func _tick_attack(delta: float) -> void:
# ends the matador re-closes if the bull slipped away, or stabs again if not. # ends the matador re-closes if the bull slipped away, or stabs again if not.
if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand: if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand:
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9) _swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
_lunge_timer = DP.f("mat_lunge_time")
if _swing_timer > 0.0: if _swing_timer > 0.0:
_lunge_timer = maxf(0.0, _lunge_timer - delta)
if _lunge_timer > 0.0 and dist > 0.1:
_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
else:
_decelerate(22.0, delta) _decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK) _play_anim(_ANIM_ATTACK)
else: else:
@@ -533,7 +555,18 @@ func _release_sword() -> void:
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs) rb.add_child(cs)
var dir := (_throw_dir + Vector3.UP * 0.12).normalized() # Lead the target: aim where the bull will be when the sword arrives, so a
# moving bull isn't simply behind the throw by the time it lands.
var aim := _throw_dir
if is_instance_valid(_bull):
var speed := maxf(DP.f("mat_throw_speed"), 0.1)
var flat := _bull.global_position - gx.origin
flat.y = 0.0
var lead := (_bull.global_position + _bull.velocity * (flat.length() / speed)) - gx.origin
lead.y = 0.0
if lead.length() > 0.1:
aim = lead.normalized()
var dir := (aim + Vector3.UP * 0.12).normalized()
rb.linear_velocity = dir * DP.f("mat_throw_speed") rb.linear_velocity = dir * DP.f("mat_throw_speed")
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin") rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin")
@@ -559,6 +592,11 @@ func _tick_ragdoll(_delta: float) -> void:
move_and_slide() move_and_slide()
# Current state as its enum name (WANDER / FLEE / …), for the debug state overlay.
func ai_state_name() -> String:
return State.keys()[_state]
func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void: func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void:
if _state == State.RAGDOLL or _state == State.ROLL: if _state == State.RAGDOLL or _state == State.ROLL:
return return
@@ -585,7 +623,7 @@ func _on_body_entered(body: Node3D) -> void:
func _on_blade_hit(body: Node3D) -> void: func _on_blade_hit(body: Node3D) -> void:
if _state == State.RAGDOLL or _state != State.ATTACK: if _state != State.ATTACK and _state != State.SIDESTEP:
return return
if not body.is_in_group(&"player"): if not body.is_in_group(&"player"):
return return
@@ -709,6 +747,16 @@ func _ensure_loop(anim_name: StringName) -> void:
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
func _pick_taunt() -> StringName:
var choices: Array[StringName] = []
for anim in _ANIM_TAUNTS:
if _anim_player != null and _anim_player.has_animation(anim):
choices.append(anim)
if choices.is_empty():
return _ANIM_IDLE
return choices[randi() % choices.size()]
func _anim_length(anim_name: StringName, fallback: float) -> float: func _anim_length(anim_name: StringName, fallback: float) -> float:
if _anim_player and _anim_player.has_animation(anim_name): if _anim_player and _anim_player.has_animation(anim_name):
return _anim_player.get_animation(anim_name).length return _anim_player.get_animation(anim_name).length
@@ -807,7 +855,8 @@ func _spawn_sword() -> void:
# THROW snaps the blade out (its arm cock-back covers the motion); everything # THROW snaps the blade out (its arm cock-back covers the motion); everything
# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe. # else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
func _wants_sword_drawn() -> bool: func _wants_sword_drawn() -> bool:
return _state == State.ATTACK or _state == State.THROW return _state == State.ATTACK or _state == State.THROW \
or _state == State.BRACE or _state == State.SIDESTEP
func _update_sword_carry() -> void: func _update_sword_carry() -> void:
+23 -8
View File
@@ -1,19 +1,26 @@
extends Node3D extends Node3D
signal matador_killed signal matador_killed
signal wave_changed(wave: int)
const MATADOR := preload("res://Matador.tscn") const MATADOR := preload("res://Matador.tscn")
const RESPAWN_COOLDOWN := 8.0 const RESPAWN_COOLDOWN := 8.0
var _respawn_cooldown: float = 0.0 var _respawn_cooldown: float = 0.0
var _wave_count: int = 1 var _spawn_count: int = 1 # matadors in the current wave — doubles on each clear
var _wave: int = 1 # wave number, surfaced on the HUD
var _alive: int = 0 var _alive: int = 0
func _ready() -> void: func _ready() -> void:
add_to_group(&"matador_spawn") add_to_group(&"matador_spawn")
_wave_count = maxi(1, int(DP.f("mat_spawn_count"))) _spawn_count = maxi(1, int(DP.f("mat_spawn_count")))
_spawn.call_deferred() _start_wave(1)
# Latest wave number, for a HUD that connects after the first wave has begun.
func current_wave() -> int:
return _wave
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -27,8 +34,16 @@ func _unhandled_input(event: InputEvent) -> void:
reset_spawn() reset_spawn()
# Announce a wave and spawn its matadors. Deferred spawn keeps it safe to call
# from _ready and from inside a kill handler mid-signal.
func _start_wave(number: int) -> void:
_wave = number
wave_changed.emit(_wave)
_spawn.call_deferred()
func _spawn() -> void: func _spawn() -> void:
var positions := _spread_positions(_wave_count) var positions := _spread_positions(_spawn_count)
for pos: Vector3 in positions: for pos: Vector3 in positions:
_spawn_at(pos) _spawn_at(pos)
@@ -57,14 +72,14 @@ func _on_matador_killed() -> void:
matador_killed.emit() matador_killed.emit()
_alive -= 1 _alive -= 1
if _alive <= 0: if _alive <= 0:
_wave_count *= 2 _spawn_count *= 2
_spawn.call_deferred() _start_wave(_wave + 1)
func reset_spawn() -> void: func reset_spawn() -> void:
for m: Node in get_tree().get_nodes_in_group(&"matador"): for m: Node in get_tree().get_nodes_in_group(&"matador"):
m.queue_free() m.queue_free()
await get_tree().process_frame await get_tree().process_frame
_wave_count = maxi(1, int(DP.f("mat_spawn_count"))) _spawn_count = maxi(1, int(DP.f("mat_spawn_count")))
_alive = 0 _alive = 0
_spawn() _start_wave(1)
+148 -16
View File
@@ -8,6 +8,14 @@ const HOOF_OFFSETS: Array = [
Vector3( 0.30, -0.25, 0.60), Vector3( 0.30, -0.25, 0.60),
] ]
# Bull animation clips (Armature|* action names in Assets/bull.fbx). IDLE is the
# looping base pose; the others are one-shot ability clips that return to it.
const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK"
const _ANIM_SLAM: StringName = &"Armature|SLAM"
const _ANIM_DASH: StringName = &"Armature|DASH"
const _ANIM_ROLL: StringName = &"Armature|ROLL"
@onready var camera_pivot: Node3D = $Camera3D @onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull @onready var cube_guy: Node3D = $bull
@@ -23,12 +31,13 @@ var _charge_was_full: bool = false
var _slam_ring_mesh: ArrayMesh = null var _slam_ring_mesh: ArrayMesh = null
# Ability system — kick=0, slam=1, dash=2 # Ability system — kick=0, slam=1, dash=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0] var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
var _ability_active: bool = false var _ability_active: bool = false
var _ability_timer: float = 0.0 var _ability_timer: float = 0.0
var _active_ability: int = -1 var _active_ability: int = -1
var _dash_dir: Vector3 = Vector3.ZERO var _dash_dir: Vector3 = Vector3.ZERO
var _roll_dir: Vector3 = Vector3.ZERO
var _bull_anim: AnimationPlayer = null var _bull_anim: AnimationPlayer = null
var _charge_ready_emitter: CPUParticles3D = null var _charge_ready_emitter: CPUParticles3D = null
@@ -68,6 +77,7 @@ func _ready() -> void:
_setup_charge_bar() _setup_charge_bar()
_setup_audio() _setup_audio()
_bull_anim = _find_anim_player(cube_guy) _bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle()
DP.any_changed.connect(_on_dp_changed) DP.any_changed.connect(_on_dp_changed)
_roll_damping = DP.f("roll_damping") _roll_damping = DP.f("roll_damping")
@@ -339,6 +349,28 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
return null return null
# IDLE ships one-shot; make it loop and start it so the bull breathes at rest
# instead of freezing on a bind pose. It's the base every ability blends back to.
func _setup_bull_idle() -> void:
if _bull_anim == null:
return
# IDLE and ROLL both loop as continuous states; the ability clips are one-shot.
for clip: StringName in [_ANIM_IDLE, _ANIM_ROLL]:
if _bull_anim.has_animation(clip):
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
_play_idle()
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
# finishing ability clip eases back to idle instead of snapping.
func _play_idle() -> void:
if _bull_anim == null:
return
_bull_anim.speed_scale = 1.0
if _bull_anim.has_animation(_ANIM_IDLE):
_bull_anim.play(_ANIM_IDLE, 0.2)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
# Abilities can be chained: a new one interrupts whatever is currently # Abilities can be chained: a new one interrupts whatever is currently
# active, gated only by each ability's own cooldown. # active, gated only by each ability's own cooldown.
@@ -348,6 +380,8 @@ func _unhandled_input(event: InputEvent) -> void:
_activate_slam() _activate_slam()
elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0: elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0:
_activate_dash() _activate_dash()
elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0:
_activate_roll()
func ability_cooldown_fraction(slot: int) -> float: func ability_cooldown_fraction(slot: int) -> float:
@@ -356,6 +390,7 @@ func ability_cooldown_fraction(slot: int) -> float:
0: maxcd = DP.f("kick_cooldown") 0: maxcd = DP.f("kick_cooldown")
1: maxcd = DP.f("slam_cooldown") 1: maxcd = DP.f("slam_cooldown")
2: maxcd = DP.f("dash_cooldown") 2: maxcd = DP.f("dash_cooldown")
3: maxcd = DP.f("roll_cooldown")
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0 return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
@@ -370,9 +405,9 @@ func _activate_kick() -> void:
_ability_timer = 0.6 _ability_timer = 0.6
if _bull_anim: if _bull_anim:
_bull_anim.speed_scale = 1.0 _bull_anim.speed_scale = 1.0
if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"): if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
_bull_anim.play(&"Armature|FOOTKICK") _bull_anim.play(_ANIM_KICK)
_ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").length _ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
var back := -Vector3(raw.x, 0.0, raw.z).normalized() var back := -Vector3(raw.x, 0.0, raw.z).normalized()
_spawn_kick_fx(back) _spawn_kick_fx(back)
@@ -393,10 +428,10 @@ func _activate_slam() -> void:
var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01) var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01)
var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult)) var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult))
_ability_timer = air_time + 0.3 _ability_timer = air_time + 0.3
if _bull_anim and _bull_anim.has_animation(&"Armature|SLAM"): if _bull_anim and _bull_anim.has_animation(_ANIM_SLAM):
var anim_len := _bull_anim.get_animation(&"Armature|SLAM").length var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length
_bull_anim.speed_scale = anim_len / maxf(air_time, 0.01) _bull_anim.speed_scale = anim_len / maxf(air_time, 0.01)
_bull_anim.play(&"Armature|SLAM") _bull_anim.play(_ANIM_SLAM)
func _activate_dash() -> void: func _activate_dash() -> void:
@@ -406,9 +441,9 @@ func _activate_dash() -> void:
_ability_timer = 0.45 _ability_timer = 0.45
if _bull_anim: if _bull_anim:
_bull_anim.speed_scale = 1.0 _bull_anim.speed_scale = 1.0
if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"): if _bull_anim and _bull_anim.has_animation(_ANIM_DASH):
_bull_anim.play(&"Armature|DASH") _bull_anim.play(_ANIM_DASH)
_ability_timer = _bull_anim.get_animation(&"Armature|DASH").length _ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized() _dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
velocity.x = _dash_dir.x * DP.f("dash_speed") velocity.x = _dash_dir.x * DP.f("dash_speed")
@@ -420,6 +455,98 @@ func _activate_dash() -> void:
_huff_player.play() _huff_player.play()
func _activate_roll() -> void:
ability_cd[3] = DP.f("roll_cooldown")
_ability_active = true
_active_ability = 3
_ability_timer = DP.f("roll_duration")
# Combine with any charge already underway: keep the current heading + speed,
# floored to a launch minimum so even a standing roll shoves off like a boulder.
var flat := Vector3(velocity.x, 0.0, velocity.z)
if flat.length() > 1.0:
_roll_dir = flat.normalized()
else:
var f := cube_guy.global_transform.basis.z
_roll_dir = Vector3(f.x, 0.0, f.z).normalized()
var launch := maxf(flat.length(), DP.f("roll_min_speed"))
velocity.x = _roll_dir.x * launch
velocity.z = _roll_dir.z * launch
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = 1.0
_bull_anim.play(_ANIM_ROLL)
_legs.set(&"tail_ragdoll", true) # tail streams out behind
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) # horns down, plowing
_spawn_dash_burst()
camera_pivot.call(&"trigger_hit", 0.35)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.8, 1.0)
_huff_player.play()
# One rolling frame: steer, ramp/retain speed, move, then ricochet off walls and
# bowl through matadors. Fully replaces the normal locomotion path while active.
func _tick_roll(delta: float, steer: Vector3) -> void:
var steer_flat := Vector3(steer.x, 0.0, steer.z)
if steer_flat.length() > 0.1:
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
_roll_dir = _roll_dir.slerp(steer_flat.normalized(), t).normalized()
# Ramp toward the cruise top speed; anything above it (from wall boosts) bleeds
# off slowly via roll_momentum instead of snapping back — that's the fun carry.
var speed := Vector2(velocity.x, velocity.z).length()
var top := DP.f("roll_max_speed")
if speed < top:
speed = move_toward(speed, top, DP.f("roll_accel") * delta)
else:
speed = maxf(top, speed * pow(DP.f("roll_momentum"), delta))
speed = minf(speed, DP.f("roll_wall_cap"))
velocity.x = _roll_dir.x * speed
velocity.z = _roll_dir.z * speed
cube_guy.rotation.y = lerp_angle(cube_guy.rotation.y, atan2(_roll_dir.x, _roll_dir.z), delta * 12.0)
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = clampf(speed / maxf(top, 1.0), 0.6, 2.4)
_set_dust_state(DustState.CHARGE)
if _charge_trail_emitter:
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
var pre_wall := Vector3(velocity.x, 0.0, velocity.z)
move_and_slide()
_was_on_floor = is_on_floor()
_roll_wall_ricochet(pre_wall)
# Bowling strike: fling matadors we plow into up and outward like pins (and the
# resulting kills feed the HUD combo meter).
_hit_matadors_radius(DP.f("roll_hit_radius"), DP.f("roll_hit_strength"), DP.f("roll_hit_up"))
# On hitting a wall mid-roll, mirror the heading and BOOST speed (capped) instead of
# bleeding it — banking off walls is how you build ludicrous momentum.
func _roll_wall_ricochet(pre_wall: Vector3) -> void:
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var normal := col.get_normal()
if absf(normal.y) >= 0.5:
continue
var flat_n := Vector3(normal.x, 0.0, normal.z).normalized()
var refl := pre_wall - 2.0 * pre_wall.dot(flat_n) * flat_n
refl.y = 0.0
if refl.length() < 0.01:
return
_roll_dir = refl.normalized()
var boosted := minf(pre_wall.length() * DP.f("roll_wall_boost"), DP.f("roll_wall_cap"))
velocity.x = _roll_dir.x * boosted
velocity.z = _roll_dir.z * boosted
var hit := col.get_position()
_spawn_kick_sparks(hit, flat_n)
_spawn_kick_flash(hit)
camera_pivot.call(&"trigger_hit", clampf(boosted / 90.0, 0.25, 1.0))
if _huff_player:
_huff_player.pitch_scale = randf_range(1.05, 1.35)
_huff_player.play()
return
func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float, func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
dir: Vector3 = Vector3.ZERO) -> void: dir: Vector3 = Vector3.ZERO) -> void:
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
@@ -807,7 +934,7 @@ func _physics_process(delta: float) -> void:
var on_floor := is_on_floor() var on_floor := is_on_floor()
# ── Ability cooldowns ──────────────────────────────────────────────────────── # ── Ability cooldowns ────────────────────────────────────────────────────────
for i: int in 3: for i: int in 4:
ability_cd[i] = maxf(0.0, ability_cd[i] - delta) ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
if _ability_active: if _ability_active:
_ability_timer -= delta _ability_timer -= delta
@@ -817,8 +944,14 @@ func _physics_process(delta: float) -> void:
_legs.set(&"charge_pitch_target", 0.0) _legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false) _legs.set(&"tail_ragdoll", false)
_slam_pending = false _slam_pending = false
if _bull_anim: _play_idle()
_bull_anim.speed_scale = 1.0
# ── Boulder roll ──────────────────────────────────────────────────────────
# While rolling, the ability owns movement entirely (its own accel, low friction
# and wall ricochet) — bypass the normal input/kick/damping path below.
if _active_ability == 3 and _ability_active:
_tick_roll(delta, direction)
return
# ── Mouse button single-thrust actions ─────────────────────────────────────── # ── Mouse button single-thrust actions ───────────────────────────────────────
# Left only → thrust NE (forward-right diagonal) # Left only → thrust NE (forward-right diagonal)
@@ -911,8 +1044,7 @@ func _physics_process(delta: float) -> void:
_ability_timer = 0.0 _ability_timer = 0.0
_legs.set(&"charge_pitch_target", 0.0) _legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false) _legs.set(&"tail_ragdoll", false)
if _bull_anim: _play_idle()
_bull_anim.speed_scale = 1.0
_spawn_slam_fx() _spawn_slam_fx()
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up")) _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
+8 -1
View File
@@ -19,8 +19,9 @@ config/run/main_scene="res://MainMenu.tscn"
[autoload] [autoload]
DP="*res://debug_params.gd" DP="*res://debug_params.gd"
DebugMenu="*res://debug_menu.gd" Console="*res://console.gd"
Controls="*res://controls_manager.gd" Controls="*res://controls_manager.gd"
DebugDraw="*res://debug_draw.gd"
[editor_plugins] [editor_plugins]
@@ -105,6 +106,12 @@ ability_dash={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null) , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null)
] ]
} }
ability_roll={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null)
]
}
[physics] [physics]
+148
View File
@@ -39,10 +39,14 @@ func _run() -> void:
var scene: Node = scene_res.instantiate() var scene: Node = scene_res.instantiate()
root.add_child(scene) root.add_child(scene)
current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene
# Let physics, AI, and IK warm up. # Let physics, AI, and IK warm up.
await create_timer(0.5).timeout await create_timer(0.5).timeout
_check_bull_animation()
await _check_overlays()
# Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle). # Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle).
for i in range(5): for i in range(5):
_capture_frame("motion_%02d.png" % i) _capture_frame("motion_%02d.png" % i)
@@ -61,12 +65,156 @@ func _run() -> void:
await create_timer(0.8).timeout await create_timer(0.8).timeout
_capture_frame("ragdoll_result.png") _capture_frame("ragdoll_result.png")
_check_ragdoll_sanity(mat, start_pos) _check_ragdoll_sanity(mat, start_pos)
_check_score_wiring(scene)
else: else:
_note("ragdoll check skipped — no matadors found in scene") _note("ragdoll check skipped — no matadors found in scene")
# Last, so any matadors the roll bowls over don't perturb the score check above.
await _check_roll_ability(scene)
_finish() _finish()
# ── Roll ability ──────────────────────────────────────────────────────────────
# Roll into a synthetic wall and confirm the bank shot: the heading reflects and the
# speed boosts past roll_max_speed (the natural ramp caps at max, so any excess is a
# wall boost). A low roll_duration lets it end within the sample window.
func _check_roll_ability(scene: Node) -> void:
print("\n-- check_roll_ability --")
var dp: Node = root.get_node_or_null("/root/DP")
var players := get_nodes_in_group(&"player")
if dp == null or players.is_empty():
_note("roll check: DP / player missing")
return
var player: Node = players[0]
var ap: AnimationPlayer = _find_anim_player(player)
_assert_true(player.ability_cd.size() == 4, "bull has 4 ability slots (roll added)")
var wall := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(10, 5, 1)
cs.shape = box
wall.add_child(cs)
scene.add_child(wall)
wall.global_position = Vector3(0, 1, 8)
var restore_dur: float = dp.f("roll_duration")
dp.set_value("roll_duration", 0.6)
player.global_position = Vector3(0, 1, 0)
player.cube_guy.rotation.y = 0.0 # face +Z, into the wall
player.velocity = Vector3(0, 0, 40)
player.ability_cd[3] = 0.0
player._activate_roll()
_assert_true(player._active_ability == 3, "roll activates in slot 3")
if ap != null:
_assert_true(ap.current_animation == &"Armature|ROLL", "roll plays the ROLL clip")
var peak := 0.0
var reflected := false
for _n in 25:
await create_timer(0.03).timeout
peak = maxf(peak, Vector2(player.velocity.x, player.velocity.z).length())
if player._roll_dir.z < -0.2:
reflected = true
_assert_true(reflected, "roll ricochets its heading off the wall")
_assert_true(peak > dp.f("roll_max_speed") + 1.0, "wall ricochet boosts speed past roll_max_speed")
_assert_true(peak <= dp.f("roll_wall_cap") + 1.0, "ricochet boost stays under the cap")
if ap != null:
_assert_true(ap.current_animation == &"Armature|IDLE", "roll returns to IDLE when it ends")
dp.set_value("roll_duration", restore_dur)
wall.queue_free()
# ── Bull animation ────────────────────────────────────────────────────────────
# The bull sits idle in this test (no input), so it must be playing its looping
# IDLE clip — guards the idle wiring and the KICK clip rename in player.gd.
func _check_bull_animation() -> void:
print("\n-- check_bull_animation --")
var players := get_nodes_in_group(&"player")
if players.is_empty():
_note("bull anim check: no player in scene")
return
var ap: AnimationPlayer = _find_anim_player(players[0])
if ap == null:
_note("bull anim check: no AnimationPlayer under player")
return
_assert_true(ap.has_animation(&"Armature|IDLE"), "bull has IDLE clip")
_assert_true(ap.has_animation(&"Armature|KICK"), "bull has KICK clip (not stale FOOTKICK)")
_assert_true(ap.current_animation == &"Armature|IDLE", "bull plays IDLE at rest")
if ap.has_animation(&"Armature|IDLE"):
_assert_true(ap.get_animation(&"Armature|IDLE").loop_mode == Animation.LOOP_LINEAR,
"bull IDLE clip loops")
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node
for child in node.get_children():
var r := _find_anim_player(child)
if r != null:
return r
return null
# ── Scoreboard wiring ─────────────────────────────────────────────────────────
# The ragdoll above is one matador kill; confirm it flowed matador → spawner →
# HUD and scored base × combo-1 = 100. Guards the whole kill→score signal chain.
func _check_score_wiring(scene: Node) -> void:
print("\n-- check_score_wiring --")
var hud: Node = _find_hud(scene)
if hud == null:
_note("score check: HUD not found in scene")
return
_assert_true(hud._score == 100, "one kill scores 100 (base × combo 1) — got %d" % hud._score)
_assert_true(hud._combo == 1, "one kill sets combo to 1 — got %d" % hud._combo)
func _find_hud(node: Node) -> Node:
if node is CanvasLayer and node.has_method(&"_on_matador_killed"):
return node
for child in node.get_children():
var r := _find_hud(child)
if r != null:
return r
return null
# ── Debug overlays ────────────────────────────────────────────────────────────
# Turn on every DebugDraw overlay against the live scene, confirm it builds, then
# turn them off and confirm it tears down — guards the reconcile bookkeeping.
func _check_overlays() -> void:
print("\n-- check_overlays --")
var dp: Node = root.get_node_or_null("/root/DP")
var draw: Node = root.get_node_or_null("/root/DebugDraw")
if dp == null or draw == null:
_note("overlay check: DP / DebugDraw autoload missing")
return
var flags := [
"show_states", "show_stats", "show_collisions", "show_hitboxes", "show_bones",
"show_animation_name", "show_wireframe", "show_grid", "show_axes",
]
for flag: String in flags:
dp.set_value(flag, true)
await create_timer(0.3).timeout
_assert_true(draw._state_labels.size() > 0, "overlay builds matador state labels")
_assert_true(draw._overlays.size() > 0, "overlay builds collision meshes")
_assert_true(draw._anim_labels.size() > 0, "overlay builds animation-name labels")
_assert_true(draw._stats_layer != null and draw._stats_layer.visible, "stats overlay visible")
_assert_true(not draw._stats_label.text.is_empty(), "stats overlay composes text")
for flag: String in flags:
dp.set_value(flag, false)
await create_timer(0.3).timeout
_assert_true(draw._state_labels.is_empty(), "overlay clears state labels when off")
_assert_true(draw._overlays.is_empty(), "overlay clears collision meshes when off")
_assert_true(draw._anim_labels.is_empty(), "overlay clears animation-name labels when off")
# ── Tail integrity ──────────────────────────────────────────────────────────── # ── Tail integrity ────────────────────────────────────────────────────────────
func _check_tail_integrity() -> void: func _check_tail_integrity() -> void:
+53
View File
@@ -20,6 +20,8 @@ func _run() -> void:
test_debug_params_defaults() test_debug_params_defaults()
test_debug_params_clamp_values() test_debug_params_clamp_values()
test_debug_params_save_load_cycle() test_debug_params_save_load_cycle()
test_debug_params_flat_cache()
test_debug_console_commands()
test_matador_spawn_positions() test_matador_spawn_positions()
test_matador_wander_target_in_bounds() test_matador_wander_target_in_bounds()
test_matador_state_transitions() test_matador_state_transitions()
@@ -230,6 +232,57 @@ func test_debug_params_save_load_cycle() -> void:
_assert_eq(dp.f("mat_walk_speed"), original, "restore original value") _assert_eq(dp.f("mat_walk_speed"), original, "restore original value")
# The hot-read cache (f/b) and the metadata mirror (get_all()[k].value) must never
# diverge across set / reset / reset_all.
func test_debug_params_flat_cache() -> void:
print("\n-- test_debug_params_flat_cache --")
var dp: Node = root.get_node_or_null("/root/DP")
if dp == null:
_assert_true(false, "DP autoload should exist")
return
dp.set_value("mat_walk_speed", 6.25)
_assert_eq(dp.f("mat_walk_speed"), 6.25, "flat cache reflects set_value")
_assert_eq(dp.get_all()["mat_walk_speed"]["value"], 6.25, "metadata mirror agrees with cache")
_assert_true(dp.is_modified("mat_walk_speed"), "is_modified true after set")
dp.reset("mat_walk_speed")
_assert_eq(dp.f("mat_walk_speed"), dp.get_all()["mat_walk_speed"]["default"],
"reset(key) restores default in cache")
_assert_true(not dp.is_modified("mat_walk_speed"), "is_modified false after reset")
# The debug console verbs (toggle / reset <name> / set / diff / clear / help) must
# mutate DP correctly and never crash on the read-only reporting commands.
func test_debug_console_commands() -> void:
print("\n-- test_debug_console_commands --")
var dp: Node = root.get_node_or_null("/root/DP")
var menu: Node = root.get_node_or_null("/root/Console")
if dp == null or menu == null:
_assert_true(false, "DP + Console autoloads should exist")
return
dp.set_value("show_bones", false)
menu._execute("toggle show_bones")
_assert_true(dp.b("show_bones"), "toggle flips bool on")
menu._execute("toggle show_bones")
_assert_true(not dp.b("show_bones"), "toggle flips bool off")
dp.set_value("mat_walk_speed", 4.0)
menu._execute("toggle mat_walk_speed")
_assert_eq(dp.f("mat_walk_speed"), 4.0, "toggle refuses a float param")
menu._execute("mat_walk_speed 6")
_assert_eq(dp.f("mat_walk_speed"), 6.0, "console 'key value' sets the param")
menu._execute("reset mat_walk_speed")
_assert_eq(dp.f("mat_walk_speed"), dp.get_all()["mat_walk_speed"]["default"],
"console 'reset <name>' restores default")
# Read-only reporting verbs must not raise.
menu._execute("diff")
menu._execute("clear")
menu._execute("help")
menu._execute("nonsense_key_xyz")
_assert_true(true, "diff / clear / help / unknown key survive")
func test_matador_spawn_positions() -> void: func test_matador_spawn_positions() -> void:
print("\n-- test_matador_spawn_positions --") print("\n-- test_matador_spawn_positions --")
var dp: Node = root.get_node_or_null("/root/DP") var dp: Node = root.get_node_or_null("/root/DP")
+3 -1
View File
@@ -43,7 +43,9 @@ func _run() -> void:
push_error("performance_test: failed to load scene.tscn") push_error("performance_test: failed to load scene.tscn")
quit(1) quit(1)
return return
root.add_child(scene_res.instantiate()) var scene_inst: Node = scene_res.instantiate()
root.add_child(scene_inst)
current_scene = scene_inst # match runtime: game code (sword throw, overlays) uses current_scene
await create_timer(WARMUP_SEC).timeout await create_timer(WARMUP_SEC).timeout