From ebb6ec63351cd0b81843410dea0e4fd7aa39cf99 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Tue, 28 Jul 2026 23:10:38 +0300 Subject: [PATCH] Bull roll ability, console screen, tests --- bull_legs.gd | 19 +- debug_menu.gd => console.gd | 394 +++++++++++++++------- debug_menu.gd.uid => console.gd.uid | 0 controls_manager.gd | 1 + debug_draw.gd | 502 ++++++++++++++++++++++++++++ debug_draw.gd.uid | 1 + debug_params.gd | 78 ++++- hud.gd | 301 +++++++++++------ main_menu.gd | 1 + matador.gd | 71 +++- matador_spawn.gd | 31 +- player.gd | 164 ++++++++- project.godot | 9 +- tests/gameplay_test.gd | 148 ++++++++ tests/logic_test.gd | 53 +++ tests/performance_test.gd | 4 +- 16 files changed, 1494 insertions(+), 283 deletions(-) rename debug_menu.gd => console.gd (53%) rename debug_menu.gd.uid => console.gd.uid (100%) create mode 100644 debug_draw.gd create mode 100644 debug_draw.gd.uid diff --git a/bull_legs.gd b/bull_legs.gd index df25d0c..72f7717 100644 --- a/bull_legs.gd +++ b/bull_legs.gd @@ -94,18 +94,14 @@ func _setup_tail() -> void: _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: if not _skeleton: return _wobble_body(delta) _update_tail(delta) _apply_tail() + if DP.b("show_tail") != _dbg_on: + _dbg_toggle() if _dbg_on: _dbg_update() @@ -243,12 +239,9 @@ func _dbg_toggle() -> void: n.queue_free() _dbg_nodes.clear() _dbg_on = false - print("TailDebug OFF") else: _dbg_build() _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: @@ -275,14 +268,6 @@ func _dbg_build() -> void: func _dbg_update() -> void: for i: int in _dbg_nodes.size(): _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 ─────────────────────────────────────────────────────────────────── diff --git a/debug_menu.gd b/console.gd similarity index 53% rename from debug_menu.gd rename to console.gd index 0399629..fed766a 100644 --- a/debug_menu.gd +++ b/console.gd @@ -1,13 +1,27 @@ 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". ## 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 MAX_SUGGESTIONS := 10 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 ", + "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 _panel: PanelContainer var _log: RichTextLabel @@ -22,14 +36,22 @@ var _prev_mouse: Input.MouseMode = Input.MOUSE_MODE_CAPTURED var _suggestions: Array[String] = [] var _selected_idx: int = -1 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: + # Keep the console (and its slide tween/input) alive while the tree is paused. + process_mode = Node.PROCESS_MODE_ALWAYS _build_ui() func _build_ui() -> void: + var mono := SystemFont.new() + mono.font_names = PackedStringArray([ + "JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", "monospace", + ]) + _canvas = CanvasLayer.new() _canvas.layer = 128 add_child(_canvas) @@ -55,20 +77,6 @@ func _build_ui() -> void: root_vbox.size_flags_vertical = Control.SIZE_FILL _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 = RichTextLabel.new() _log.bbcode_enabled = true @@ -77,6 +85,10 @@ func _build_ui() -> void: _log.size_flags_horizontal = Control.SIZE_FILL _log.add_theme_font_size_override("normal_font_size", 13) _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) # ── Suggestion panel ────────────────────────────────────────────────────── @@ -99,6 +111,7 @@ func _build_ui() -> void: var lbl := Label.new() lbl.visible = false lbl.add_theme_font_size_override("font_size", 13) + lbl.add_theme_font_override("font", mono) _suggest_box.add_child(lbl) _suggest_labels.append(lbl) @@ -119,22 +132,27 @@ func _build_ui() -> void: var prompt_lbl := Label.new() prompt_lbl.text = ">" prompt_lbl.modulate = Color(0.35, 1.0, 0.35) + prompt_lbl.add_theme_font_override("font", mono) in_row.add_child(prompt_lbl) _input_field = LineEdit.new() _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 · save reset diff list clear help" _input_field.clear_button_enabled = true _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) _input_field.text_changed.connect(_on_text_changed) _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 ──────────────────────────────────────────────────────────── @@ -144,8 +162,9 @@ func _input(event: InputEvent) -> void: return var ke := event as InputEventKey - # ~ (Shift+backtick) — toggle regardless of focus - if ke.keycode == KEY_QUOTELEFT and ke.shift_pressed: + # Backtick/tilde — toggle regardless of focus. Match by physical position so a + # non-US layout or a Shift-produced ~ both open the console. + if ke.physical_keycode == KEY_QUOTELEFT: _toggle() get_viewport().set_input_as_handled() return @@ -155,13 +174,13 @@ func _input(event: InputEvent) -> void: match ke.keycode: KEY_TAB: - _do_autocomplete() + _accept_completion() get_viewport().set_input_as_handled() KEY_UP: - _navigate(-1) + _arrow(-1) get_viewport().set_input_as_handled() KEY_DOWN: - _navigate(1) + _arrow(1) get_viewport().set_input_as_handled() KEY_ESCAPE: _toggle() @@ -170,6 +189,7 @@ func _input(event: InputEvent) -> void: func _toggle() -> void: _is_open = not _is_open + get_tree().paused = _is_open var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO _panel.offset_bottom = h @@ -206,6 +226,12 @@ func _on_text_changed(text: String) -> void: func _refresh_suggestions(query: String) -> void: var ql := query.to_lower() 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(): var s := _fuzzy_score(ql, key.to_lower()) if s > 0: @@ -218,11 +244,7 @@ func _refresh_suggestions(query: String) -> void: for i in MAX_SUGGESTIONS: if i < _suggestions.size(): - var key : String = _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].text = _suggestion_text(_suggestions[i]) _suggest_labels[i].modulate = _suggestion_color(i) _suggest_labels[i].visible = true else: @@ -230,6 +252,17 @@ func _refresh_suggestions(query: String) -> void: _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: if idx == _selected_idx: 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: if meta["type"] == TYPE_FLOAT: - return "%.4g" % (meta["value"] as float) + return _fmt_num(meta["value"] as float) if meta["type"] == TYPE_BOOL: var bval: bool = meta["value"] return "true" if bval else "false" 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: _suggestions.clear() _selected_idx = -1 @@ -275,50 +317,74 @@ func _clear_suggestions() -> void: _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 " " 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(): return - var idx := maxi(0, _selected_idx) - var key := _suggestions[idx] - 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() + _selected_idx = maxi(_selected_idx, 0) + _fill_selected() _clear_suggestions() -func _navigate(dir: int) -> void: - if _suggestions.is_empty(): - # History navigation when no suggestion list is visible - if dir == -1 and _history_idx + 1 < _history.size(): - _history_idx += 1 - elif dir == 1 and _history_idx > 0: - _history_idx -= 1 - 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() +func _fill_selected() -> void: + var key := _suggestions[_selected_idx] + # A command drops in as "verb " ready for its argument (or a bare Enter); a + # param drops in as "key value" with the value pre-selected to type over. + # Suggestions stay open either way so ↑↓ keeps cycling. + if _is_command(key): + _input_field.text = key + " " + _input_field.caret_column = _input_field.text.length() 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: if _suggest_labels[i].visible: _suggest_labels[i].modulate = _suggestion_color(i) - var key := _suggestions[_selected_idx] - 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() + +# ↑ recalls older commands, ↓ newer ones; index -1 is the live (in-progress) line, +# which is stashed on the first ↑ and restored when you step back down to it. +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 ───────────────────────────────────────────────────────── @@ -327,87 +393,167 @@ func _on_submitted(raw: String) -> void: raw = raw.strip_edges() if raw.is_empty(): return - _history.push_front(raw) - _history_idx = -1 + if _history.is_empty() or _history[0] != raw: + _history.push_front(raw) + _history_idx = -1 + _pending_line = "" _input_field.clear() _clear_suggestions() _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: - _log_raw("[color=#3a5a3a]> %s[/color]" % cmd) + _log_raw("[color=#3a5a3a]> %s[/color]" % _esc(cmd)) var parts := cmd.split(" ", false) if parts.is_empty(): return - match parts[0].to_lower(): - "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] [/color] — read current value + range") - _log_raw("[color=#888888] [/color] — set param (bool: true/false/1/0)") - return + if _run_command(parts): + return - # Treat as param key, with fuzzy fall-back for partial names - var key := parts[0] - var all := DP.get_all() - 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 - - var meta: Dictionary = all[key] + # Not a command — treat as a param key, with fuzzy fall-back for partial names. + var key := _resolve_key(parts[0]) + if key.is_empty(): + return + var meta: Dictionary = DP.get_all()[key] if parts.size() == 1: _read_param(key, meta) else: _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 ") + 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 '" % [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] [/color] — read current value + range") + _log_raw("[color=#888888] [/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: if meta["type"] == TYPE_FLOAT: - _log_raw( - "[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](min %s max %s step %s default %s)[/color]" % [ - key, _fmt_val(key, meta), - "%.4g" % (meta["min"] as float), - "%.4g" % (meta["max"] as float), - "%.4g" % (meta["step"] as float), - "%.4g" % (meta["default"] as float), - ] - ) + _log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](%s … %s)[/color]" % [ + key, _fmt_val(key, meta), + _fmt_num(meta["min"] as float), _fmt_num(meta["max"] as float), + ]) 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: if meta["type"] == TYPE_FLOAT: - if not raw.is_valid_float(): - _log_warn("Expected number, got: %s" % raw) + var lo := meta["min"] as float + 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 - var v := snappedf( - clampf(raw.to_float(), meta["min"] as float, meta["max"] as float), - meta["step"] as float - ) + var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float) 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: match raw.to_lower(): "1", "true", "yes", "on": @@ -417,7 +563,7 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void: DP.set_value(key, false) _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: _log_warn("Cannot set param of type %d" % meta["type"]) @@ -453,6 +599,12 @@ func _fuzzy_find_all(query: String) -> Array[String]: # ── 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: if _log.get_line_count() > LOG_MAX_LINES: _log.clear() diff --git a/debug_menu.gd.uid b/console.gd.uid similarity index 100% rename from debug_menu.gd.uid rename to console.gd.uid diff --git a/controls_manager.gd b/controls_manager.gd index f17b1fc..ccdb190 100644 --- a/controls_manager.gd +++ b/controls_manager.gd @@ -17,6 +17,7 @@ const REBINDABLE_ACTIONS: PackedStringArray = [ &"ability_kick", &"ability_slam", &"ability_dash", + &"ability_roll", ] diff --git a/debug_draw.gd b/debug_draw.gd new file mode 100644 index 0000000..7c1ac6a --- /dev/null +++ b/debug_draw.gd @@ -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 diff --git a/debug_draw.gd.uid b/debug_draw.gd.uid new file mode 100644 index 0000000..3bcfbfb --- /dev/null +++ b/debug_draw.gd.uid @@ -0,0 +1 @@ +uid://cgty4al6aqey3 diff --git a/debug_params.gd b/debug_params.gd index 4eb4ede..bac4fc1 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -7,7 +7,12 @@ signal any_changed(key: String, value: Variant) 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 _values: Dictionary = {} 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_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_dodge_cooldown", 5.0, 0.5, 12.0, 0.25) - _reg_f("Matador", "mat_dodge_chance", 0.25, 0.0, 1.0, 0.05) + # How strongly the sidestep biases toward the bull so the blade sweeps through + # 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_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_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_duration", 0.45, 0.1, 1.5, 0.05) # 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", "dash_cooldown", 2.0, 0.5, 10.0, 0.1) _reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0) - # ── Debug ───────────────────────────────────────────────────────────────── - _reg_b("Debug", "show_collisions", false) + # ── 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_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, @@ -189,6 +228,7 @@ func _reg_f(section: String, key: String, default: float, "max": max_val, "step": step, } + _values[key] = default 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, "type": TYPE_BOOL, } + _values[key] = default ## Read a float param. Panics on unknown key — intentional: typos should be loud. func f(key: String) -> float: - return _params[key]["value"] as float + return _values[key] as float ## Read a bool param. func b(key: String) -> bool: - return _params[key]["value"] as bool + return _values[key] as bool func set_value(key: String, value: Variant) -> void: if not _params.has(key): return _params[key]["value"] = value + _values[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: return _params @@ -243,9 +285,27 @@ func load_saved() -> void: if p["type"] == TYPE_FLOAT: val = clampf(val as float, p["min"] as float, p["max"] as float) _params[key]["value"] = val + _values[key] = val func reset_all() -> void: 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) + + +## 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"] diff --git a/hud.gd b/hud.gd index d30db83..af4363c 100644 --- a/hud.gd +++ b/hud.gd @@ -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 _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] = [ "res://HUD/HUD_0000_ability_JALG.png", "res://HUD/HUD_0001_ability_SLAM.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_panel: PanelContainer @@ -25,6 +35,16 @@ var _cd_labels: Array[Label] = [] var _health_segments: Array[ColorRect] = [] 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: layer = 11 @@ -33,10 +53,12 @@ func _ready() -> void: _build_tab_hint() _build_ability_bar() _build_health_bar() + _build_scoreboard() _find_spawner.call_deferred() -func _process(_delta: float) -> void: +func _process(delta: float) -> void: + _update_combo(delta) if _player == null: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): @@ -62,8 +84,12 @@ func _process(_delta: float) -> void: func _find_spawner() -> void: var spawners := get_tree().get_nodes_in_group(&"matador_spawn") - if not spawners.is_empty(): - _spawner = spawners[0] + if spawners.is_empty(): + return + _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: @@ -92,6 +118,26 @@ func _on_reset_pressed() -> void: get_tree().paused = false if _spawner: _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: @@ -107,24 +153,8 @@ func _build_health_bar() -> void: bar.offset_bottom = 16.0 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() - 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) var inner := HBoxContainer.new() @@ -145,6 +175,139 @@ func _on_health_changed(new_health: int) -> void: 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: var bar := HBoxContainer.new() bar.add_theme_constant_override("separation", 10) @@ -154,10 +317,10 @@ func _build_ability_bar() -> void: bar.anchor_bottom = 1.0 bar.grow_horizontal = Control.GROW_DIRECTION_BOTH bar.grow_vertical = Control.GROW_DIRECTION_BEGIN - bar.offset_left = -120.0 + bar.offset_left = -160.0 bar.offset_bottom = -20.0 add_child(bar) - for i: int in 3: + for i: int in 4: bar.add_child(_build_ability_slot(i)) @@ -165,25 +328,9 @@ func _build_ability_slot(idx: int) -> Control: var vbox := VBoxContainer.new() 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() 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) var stack := Control.new() @@ -196,6 +343,16 @@ func _build_ability_slot(idx: int) -> Control: icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.set_anchors_preset(Control.PRESET_FULL_RECT) 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() 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: _controls_panel = PanelContainer.new() - - 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.add_theme_stylebox_override("panel", _panel_style(_BG, 7, 14.0, 10.0)) _controls_panel.anchor_left = 0.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_slam"), "Slam", _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, "R", "Respawn matadors (cooldown)", _MUTED) _row(vbox, "Tab", "Toggle controls", _MUTED) @@ -320,37 +462,8 @@ func _make_button(label_text: String) -> Button: btn.text = label_text btn.process_mode = Node.PROCESS_MODE_ALWAYS - var normal := StyleBoxFlat.new() - normal.bg_color = Color(0.18, 0.12, 0.04, 0.90) - 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 + var normal := _panel_style(Color(0.18, 0.12, 0.04, 0.90), 5, 12.0, 6.0) + var hover := _panel_style(Color(0.30, 0.20, 0.06, 0.95), 5, 12.0, 6.0, _GOLD) btn.add_theme_stylebox_override("normal", normal) 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) var badge := PanelContainer.new() - var bs := StyleBoxFlat.new() - 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.add_theme_stylebox_override("panel", _panel_style(_BADGE, 3, 6.0, 2.0, Color(0, 0, 0, 0))) badge.custom_minimum_size = Vector2(100.0, 0.0) row.add_child(badge) diff --git a/main_menu.gd b/main_menu.gd index 84e159b..97bd2ef 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -55,6 +55,7 @@ const ACTION_LABELS: Dictionary = { &"ability_kick": "Kick", &"ability_slam": "Slam", &"ability_dash": "Dash", + &"ability_roll": "Roll", } @onready var main_panel: Control = $MainPanel diff --git a/matador.gd b/matador.gd index 389310a..fd072eb 100644 --- a/matador.gd +++ b/matador.gd @@ -18,6 +18,7 @@ var _sim: PhysicalBoneSimulator3D = null var _anim_player: AnimationPlayer = null var _wander_target: Vector3 = Vector3.ZERO var _idle_timer: float = 0.0 +var _idle_anim: StringName = _ANIM_IDLE var _bull: CharacterBody3D = null var _step_dir: Vector3 = Vector3.ZERO var _brace_timer: float = 0.0 @@ -26,6 +27,7 @@ var _dodge_cd: float = 0.0 var _will_dodge: bool = true var _attack_timer: float = 0.0 var _swing_timer: float = 0.0 +var _lunge_timer: float = 0.0 var _roll_timer: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO var _throw_timer: float = 0.0 @@ -57,6 +59,10 @@ const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ROLL: StringName = &"Roll" 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 # reparents from holster to hand at this point in the animation. const _DRAW_GRAB_AT: float = 0.55 @@ -82,7 +88,7 @@ func _ready() -> void: if _skeleton: _sim = MatadorRagdoll.build(_skeleton) 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) if not _anim_player.has_animation(_ANIM_RUN): push_warning("Matador: expected animations not found. Available: %s" % @@ -125,6 +131,10 @@ func _acquire_bull() -> void: func _physics_process(delta: float) -> void: _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(): velocity += get_gravity() * delta @@ -266,7 +276,7 @@ func _tick_wander(delta: float) -> void: if _drawing or _sheathing or _idle_timer > 0.0: _idle_timer = maxf(0.0, _idle_timer - delta) _decelerate(10.0, delta) - _play_anim(_ANIM_IDLE) + _play_anim(_idle_anim) move_and_slide() 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) if to_target.length() < 0.8: _idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) + _idle_anim = _pick_taunt() _pick_wander_target() else: 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") -# 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: _step_timer -= delta if _step_timer <= 0.0: _start_attack() 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") - velocity.x = _step_dir.x * spd - velocity.z = _step_dir.z * spd - _face_velocity(delta, _TURN_SHARP) - _play_anim(_ANIM_ROLL) + velocity.x = dir.x * spd + velocity.z = dir.z * spd + _face_point(_bull.global_position, delta, _TURN_SHARP) + _play_anim(_ANIM_ATTACK) move_and_slide() @@ -380,9 +397,14 @@ func _tick_attack(delta: float) -> void: # 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: _swing_timer = _anim_length(_ANIM_ATTACK, 0.9) + _lunge_timer = DP.f("mat_lunge_time") if _swing_timer > 0.0: - _decelerate(22.0, delta) + _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) _play_anim(_ANIM_ATTACK) else: var dir := _steer_clear(to_bull.normalized(), delta) @@ -533,7 +555,18 @@ func _release_sword() -> void: cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) 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.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() +# 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: if _state == State.RAGDOLL or _state == State.ROLL: return @@ -585,7 +623,7 @@ func _on_body_entered(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 if not body.is_in_group(&"player"): return @@ -709,6 +747,16 @@ func _ensure_loop(anim_name: StringName) -> void: 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: if _anim_player and _anim_player.has_animation(anim_name): 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 # else animates with the Draw_weapon clip — forward to draw, reversed to sheathe. 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: diff --git a/matador_spawn.gd b/matador_spawn.gd index 5304954..665ea82 100644 --- a/matador_spawn.gd +++ b/matador_spawn.gd @@ -1,19 +1,26 @@ extends Node3D signal matador_killed +signal wave_changed(wave: int) const MATADOR := preload("res://Matador.tscn") const RESPAWN_COOLDOWN := 8.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 func _ready() -> void: add_to_group(&"matador_spawn") - _wave_count = maxi(1, int(DP.f("mat_spawn_count"))) - _spawn.call_deferred() + _spawn_count = maxi(1, int(DP.f("mat_spawn_count"))) + _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: @@ -27,8 +34,16 @@ func _unhandled_input(event: InputEvent) -> void: 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: - var positions := _spread_positions(_wave_count) + var positions := _spread_positions(_spawn_count) for pos: Vector3 in positions: _spawn_at(pos) @@ -57,14 +72,14 @@ func _on_matador_killed() -> void: matador_killed.emit() _alive -= 1 if _alive <= 0: - _wave_count *= 2 - _spawn.call_deferred() + _spawn_count *= 2 + _start_wave(_wave + 1) func reset_spawn() -> void: for m: Node in get_tree().get_nodes_in_group(&"matador"): m.queue_free() 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 - _spawn() + _start_wave(1) diff --git a/player.gd b/player.gd index 69a7461..da4a6b4 100644 --- a/player.gd +++ b/player.gd @@ -8,6 +8,14 @@ const HOOF_OFFSETS: Array = [ 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 cube_guy: Node3D = $bull @@ -23,12 +31,13 @@ var _charge_was_full: bool = false var _slam_ring_mesh: ArrayMesh = null -# Ability system — kick=0, slam=1, dash=2 -var ability_cd: Array[float] = [0.0, 0.0, 0.0] +# Ability system — kick=0, slam=1, dash=2, roll=3 +var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0] var _ability_active: bool = false var _ability_timer: float = 0.0 var _active_ability: int = -1 var _dash_dir: Vector3 = Vector3.ZERO +var _roll_dir: Vector3 = Vector3.ZERO var _bull_anim: AnimationPlayer = null var _charge_ready_emitter: CPUParticles3D = null @@ -68,6 +77,7 @@ func _ready() -> void: _setup_charge_bar() _setup_audio() _bull_anim = _find_anim_player(cube_guy) + _setup_bull_idle() DP.any_changed.connect(_on_dp_changed) _roll_damping = DP.f("roll_damping") @@ -339,6 +349,28 @@ func _find_anim_player(node: Node) -> AnimationPlayer: 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: # Abilities can be chained: a new one interrupts whatever is currently # active, gated only by each ability's own cooldown. @@ -348,6 +380,8 @@ func _unhandled_input(event: InputEvent) -> void: _activate_slam() elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0: _activate_dash() + elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0: + _activate_roll() func ability_cooldown_fraction(slot: int) -> float: @@ -356,6 +390,7 @@ func ability_cooldown_fraction(slot: int) -> float: 0: maxcd = DP.f("kick_cooldown") 1: maxcd = DP.f("slam_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 @@ -370,9 +405,9 @@ func _activate_kick() -> void: _ability_timer = 0.6 if _bull_anim: _bull_anim.speed_scale = 1.0 - if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"): - _bull_anim.play(&"Armature|FOOTKICK") - _ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").length + if _bull_anim and _bull_anim.has_animation(_ANIM_KICK): + _bull_anim.play(_ANIM_KICK) + _ability_timer = _bull_anim.get_animation(_ANIM_KICK).length var raw := cube_guy.global_transform.basis.z var back := -Vector3(raw.x, 0.0, raw.z).normalized() _spawn_kick_fx(back) @@ -393,10 +428,10 @@ func _activate_slam() -> void: 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)) _ability_timer = air_time + 0.3 - if _bull_anim and _bull_anim.has_animation(&"Armature|SLAM"): - var anim_len := _bull_anim.get_animation(&"Armature|SLAM").length + if _bull_anim and _bull_anim.has_animation(_ANIM_SLAM): + var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length _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: @@ -406,9 +441,9 @@ func _activate_dash() -> void: _ability_timer = 0.45 if _bull_anim: _bull_anim.speed_scale = 1.0 - if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"): - _bull_anim.play(&"Armature|DASH") - _ability_timer = _bull_anim.get_animation(&"Armature|DASH").length + if _bull_anim and _bull_anim.has_animation(_ANIM_DASH): + _bull_anim.play(_ANIM_DASH) + _ability_timer = _bull_anim.get_animation(_ANIM_DASH).length var raw := cube_guy.global_transform.basis.z _dash_dir = Vector3(raw.x, 0.0, raw.z).normalized() velocity.x = _dash_dir.x * DP.f("dash_speed") @@ -420,6 +455,98 @@ func _activate_dash() -> void: _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, dir: Vector3 = Vector3.ZERO) -> void: var raw := cube_guy.global_transform.basis.z @@ -807,7 +934,7 @@ func _physics_process(delta: float) -> void: var on_floor := is_on_floor() # ── Ability cooldowns ──────────────────────────────────────────────────────── - for i: int in 3: + for i: int in 4: ability_cd[i] = maxf(0.0, ability_cd[i] - delta) if _ability_active: _ability_timer -= delta @@ -817,8 +944,14 @@ func _physics_process(delta: float) -> void: _legs.set(&"charge_pitch_target", 0.0) _legs.set(&"tail_ragdoll", false) _slam_pending = false - if _bull_anim: - _bull_anim.speed_scale = 1.0 + _play_idle() + + # ── 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 ─────────────────────────────────────── # Left only → thrust NE (forward-right diagonal) @@ -911,8 +1044,7 @@ func _physics_process(delta: float) -> void: _ability_timer = 0.0 _legs.set(&"charge_pitch_target", 0.0) _legs.set(&"tail_ragdoll", false) - if _bull_anim: - _bull_anim.speed_scale = 1.0 + _play_idle() _spawn_slam_fx() _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up")) diff --git a/project.godot b/project.godot index 98a4bad..16a26a9 100644 --- a/project.godot +++ b/project.godot @@ -19,8 +19,9 @@ config/run/main_scene="res://MainMenu.tscn" [autoload] DP="*res://debug_params.gd" -DebugMenu="*res://debug_menu.gd" +Console="*res://console.gd" Controls="*res://controls_manager.gd" +DebugDraw="*res://debug_draw.gd" [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) ] } +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] diff --git a/tests/gameplay_test.gd b/tests/gameplay_test.gd index 4ca21a4..bb185ff 100644 --- a/tests/gameplay_test.gd +++ b/tests/gameplay_test.gd @@ -39,10 +39,14 @@ func _run() -> void: var scene: Node = scene_res.instantiate() root.add_child(scene) + current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene # Let physics, AI, and IK warm up. 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). for i in range(5): _capture_frame("motion_%02d.png" % i) @@ -61,12 +65,156 @@ func _run() -> void: await create_timer(0.8).timeout _capture_frame("ragdoll_result.png") _check_ragdoll_sanity(mat, start_pos) + _check_score_wiring(scene) else: _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() +# ── 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 ──────────────────────────────────────────────────────────── func _check_tail_integrity() -> void: diff --git a/tests/logic_test.gd b/tests/logic_test.gd index 5007569..4673020 100644 --- a/tests/logic_test.gd +++ b/tests/logic_test.gd @@ -20,6 +20,8 @@ func _run() -> void: test_debug_params_defaults() test_debug_params_clamp_values() test_debug_params_save_load_cycle() + test_debug_params_flat_cache() + test_debug_console_commands() test_matador_spawn_positions() test_matador_wander_target_in_bounds() 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") +# 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 / 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 ' 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: print("\n-- test_matador_spawn_positions --") var dp: Node = root.get_node_or_null("/root/DP") diff --git a/tests/performance_test.gd b/tests/performance_test.gd index c1267b1..d3c9a58 100644 --- a/tests/performance_test.gd +++ b/tests/performance_test.gd @@ -43,7 +43,9 @@ func _run() -> void: push_error("performance_test: failed to load scene.tscn") quit(1) 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