diff --git a/Assets/intro_bg.ogv b/Assets/intro_bg.ogv index e6928ac..1ece759 100644 Binary files a/Assets/intro_bg.ogv and b/Assets/intro_bg.ogv differ diff --git a/CLAUDE.md b/CLAUDE.md index 89dd319..c1f461e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,7 @@ bash run_tests.sh # Individual tests: gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv) godot --headless --script tests/logic_test.gd # pure logic, ~2 s +godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s # Gameplay test with real rendering (inspect frame strip visually): @@ -107,6 +108,13 @@ godot --script tests/gameplay_test.gd # opens a window, saves re Headless screenshots will be blank (Forward Plus has no display). Run without `--headless` to get real frame strips. +### What the performance test catches +Loads the full scene with `STRESS_MATADORS` (20) matadors, samples per-frame time +during normal play and during a mass-ragdoll spike, and fails if the average +exceeds 16 ms or any single frame exceeds 100 ms. Budgets are generous regression +guards (not a target frame rate); the measured avg/peak/fps print every run so a +gradual creep shows up before it trips the ceiling. + ## Godot 4.x specifics - `wrapf` / `wrap` instead of manual modulo for angles diff --git a/MainMenu.tscn b/MainMenu.tscn index c58d04d..59d8184 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -59,10 +59,10 @@ grow_vertical = 2 [node name="PlayButton" type="Button" parent="MainPanel"] layout_mode = 1 -anchor_left = 0.459 -anchor_top = 0.449 -anchor_right = 0.459 -anchor_bottom = 0.449 +anchor_left = 0.4530 +anchor_top = 0.6513 +anchor_right = 0.4530 +anchor_bottom = 0.6513 offset_left = -120.0 offset_top = -55.0 offset_right = 120.0 diff --git a/controls_manager.gd b/controls_manager.gd index bba50b0..f17b1fc 100644 --- a/controls_manager.gd +++ b/controls_manager.gd @@ -14,7 +14,6 @@ const REBINDABLE_ACTIONS: PackedStringArray = [ &"move_back", &"move_left", &"move_right", - &"charge", &"ability_kick", &"ability_slam", &"ability_dash", diff --git a/debug_menu.gd b/debug_menu.gd index 357f05a..0399629 100644 --- a/debug_menu.gd +++ b/debug_menu.gd @@ -1,10 +1,28 @@ extends Node -## Runtime debug panel. Toggle with F1. -## Reads DP.get_all() to build controls — adding a param to DP is all that's needed to show it here. +## Quake-style drop-down debug console. Toggle with ~ (Shift+`). +## Query a param: type its name. Set it: "name value". +## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close. -var _panel: Control -var _status_label: Label -var _prev_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_CAPTURED +const CONSOLE_HEIGHT_RATIO := 0.48 +const SLIDE_DURATION := 0.18 +const MAX_SUGGESTIONS := 10 +const LOG_MAX_LINES := 300 + +var _canvas: CanvasLayer +var _panel: PanelContainer +var _log: RichTextLabel +var _suggest_panel: PanelContainer +var _suggest_box: VBoxContainer +var _suggest_labels: Array[Label] = [] +var _input_field: LineEdit + +var _tween: Tween +var _is_open: bool = false +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 func _ready() -> void: @@ -12,205 +30,438 @@ func _ready() -> void: func _build_ui() -> void: - var canvas := CanvasLayer.new() - canvas.layer = 128 - add_child(canvas) + _canvas = CanvasLayer.new() + _canvas.layer = 128 + add_child(_canvas) _panel = PanelContainer.new() - _panel.anchor_left = 1.0 + _panel.anchor_left = 0.0 _panel.anchor_right = 1.0 _panel.anchor_top = 0.0 - _panel.anchor_bottom = 1.0 - _panel.offset_left = -390.0 - _panel.offset_right = 0.0 - _panel.offset_top = 0.0 - _panel.offset_bottom = 0.0 + _panel.anchor_bottom = 0.0 + _panel.offset_bottom = 500.0 _panel.mouse_filter = Control.MOUSE_FILTER_STOP _panel.visible = false var bg := StyleBoxFlat.new() - bg.bg_color = Color(0.08, 0.08, 0.10, 0.95) + bg.bg_color = Color(0.04, 0.06, 0.04, 0.94) + bg.border_width_bottom = 2 + bg.border_color = Color(0.2, 0.8, 0.2, 1.0) _panel.add_theme_stylebox_override("panel", bg) - canvas.add_child(_panel) + _canvas.add_child(_panel) var root_vbox := VBoxContainer.new() root_vbox.size_flags_horizontal = Control.SIZE_FILL + root_vbox.size_flags_vertical = Control.SIZE_FILL _panel.add_child(root_vbox) - # ── Toolbar ─────────────────────────────────────────────────────────────── - var toolbar_margin := MarginContainer.new() - toolbar_margin.add_theme_constant_override("margin_left", 8) - toolbar_margin.add_theme_constant_override("margin_right", 8) - toolbar_margin.add_theme_constant_override("margin_top", 6) - toolbar_margin.add_theme_constant_override("margin_bottom", 4) - root_vbox.add_child(toolbar_margin) + # ── 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 toolbar := HBoxContainer.new() - toolbar.add_theme_constant_override("separation", 6) - toolbar_margin.add_child(toolbar) - - var title := Label.new() - title.text = "DEBUG [F1]" - title.size_flags_horizontal = Control.SIZE_EXPAND_FILL - toolbar.add_child(title) - - _status_label = Label.new() - _status_label.modulate = Color(0.4, 1.0, 0.5) - toolbar.add_child(_status_label) - - var save_btn := Button.new() - save_btn.text = "Save" - save_btn.pressed.connect(_on_save) - toolbar.add_child(save_btn) - - var rand_all_btn := Button.new() - rand_all_btn.text = "Randomize All" - rand_all_btn.pressed.connect(_on_randomize_all) - toolbar.add_child(rand_all_btn) - - var reset_btn := Button.new() - reset_btn.text = "Reset All" - reset_btn.pressed.connect(DP.reset_all) - toolbar.add_child(reset_btn) + 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()) - # ── Scrollable content ──────────────────────────────────────────────────── - var scroll := ScrollContainer.new() - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.size_flags_horizontal = Control.SIZE_FILL - root_vbox.add_child(scroll) + # ── Log ─────────────────────────────────────────────────────────────────── + _log = RichTextLabel.new() + _log.bbcode_enabled = true + _log.scroll_following = true + _log.size_flags_vertical = Control.SIZE_EXPAND_FILL + _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) + root_vbox.add_child(_log) - var content_margin := MarginContainer.new() - content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - content_margin.add_theme_constant_override("margin_left", 8) - content_margin.add_theme_constant_override("margin_right", 8) - content_margin.add_theme_constant_override("margin_top", 4) - content_margin.add_theme_constant_override("margin_bottom", 8) - scroll.add_child(content_margin) + # ── Suggestion panel ────────────────────────────────────────────────────── + _suggest_panel = PanelContainer.new() + _suggest_panel.visible = false + _suggest_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - var content := VBoxContainer.new() - content.size_flags_horizontal = Control.SIZE_EXPAND_FILL - content_margin.add_child(content) + var sg_bg := StyleBoxFlat.new() + sg_bg.bg_color = Color(0.06, 0.10, 0.06, 0.97) + sg_bg.border_width_top = 1 + sg_bg.border_color = Color(0.15, 0.55, 0.15, 0.8) + _suggest_panel.add_theme_stylebox_override("panel", sg_bg) + root_vbox.add_child(_suggest_panel) - _build_params(content) + _suggest_box = VBoxContainer.new() + _suggest_box.add_theme_constant_override("separation", 0) + _suggest_panel.add_child(_suggest_box) + + for _i in MAX_SUGGESTIONS: + var lbl := Label.new() + lbl.visible = false + lbl.add_theme_font_size_override("font_size", 13) + _suggest_box.add_child(lbl) + _suggest_labels.append(lbl) + + root_vbox.add_child(HSeparator.new()) + + # ── Input row ───────────────────────────────────────────────────────────── + var in_margin := MarginContainer.new() + in_margin.add_theme_constant_override("margin_left", 10) + in_margin.add_theme_constant_override("margin_right", 10) + in_margin.add_theme_constant_override("margin_top", 4) + in_margin.add_theme_constant_override("margin_bottom", 6) + root_vbox.add_child(in_margin) + + var in_row := HBoxContainer.new() + in_row.add_theme_constant_override("separation", 6) + in_margin.add_child(in_row) + + var prompt_lbl := Label.new() + prompt_lbl.text = ">" + prompt_lbl.modulate = Color(0.35, 1.0, 0.35) + 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.clear_button_enabled = true + _input_field.add_theme_font_size_override("font_size", 14) + 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("") -func _build_params(content: VBoxContainer) -> void: - # Group keys by section preserving registration order - var sections: Dictionary = {} +# ── Input handling ──────────────────────────────────────────────────────────── + +func _input(event: InputEvent) -> void: + if not (event is InputEventKey and (event as InputEventKey).pressed + and not (event as InputEventKey).echo): + return + var ke := event as InputEventKey + + # ~ (Shift+backtick) — toggle regardless of focus + if ke.keycode == KEY_QUOTELEFT and ke.shift_pressed: + _toggle() + get_viewport().set_input_as_handled() + return + + if not _is_open: + return + + match ke.keycode: + KEY_TAB: + _do_autocomplete() + get_viewport().set_input_as_handled() + KEY_UP: + _navigate(-1) + get_viewport().set_input_as_handled() + KEY_DOWN: + _navigate(1) + get_viewport().set_input_as_handled() + KEY_ESCAPE: + _toggle() + get_viewport().set_input_as_handled() + + +func _toggle() -> void: + _is_open = not _is_open + var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO + _panel.offset_bottom = h + + if _tween: + _tween.kill() + _tween = create_tween().set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUART) + + if _is_open: + _prev_mouse = Input.mouse_mode + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _panel.position.y = -h + _panel.visible = true + _tween.tween_property(_panel, "position:y", 0.0, SLIDE_DURATION) + _tween.tween_callback(_input_field.grab_focus) + else: + _tween.tween_property(_panel, "position:y", -h, SLIDE_DURATION) + _tween.tween_callback(func() -> void: _panel.visible = false) + Input.mouse_mode = _prev_mouse + _clear_suggestions() + + +# ── Fuzzy autocomplete ──────────────────────────────────────────────────────── + +func _on_text_changed(text: String) -> void: + _selected_idx = -1 + _history_idx = -1 + var query := text.get_slice(" ", 0).strip_edges() + if query.is_empty(): + _clear_suggestions() + return + _refresh_suggestions(query) + + +func _refresh_suggestions(query: String) -> void: + var ql := query.to_lower() + var scored : Array = [] for key: String in DP.get_all(): - var sec: String = DP.get_all()[key]["section"] + var s := _fuzzy_score(ql, key.to_lower()) + if s > 0: + scored.append([s, key]) + scored.sort_custom(func(a: Array, b: Array) -> bool: return a[0] > b[0]) + + _suggestions.clear() + for i in mini(scored.size(), MAX_SUGGESTIONS): + _suggestions.append(scored[i][1]) + + 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].modulate = _suggestion_color(i) + _suggest_labels[i].visible = true + else: + _suggest_labels[i].visible = false + _suggest_panel.visible = not _suggestions.is_empty() + + +func _suggestion_color(idx: int) -> Color: + if idx == _selected_idx: + return Color(1.0, 1.0, 0.4) # selected — yellow + if idx == 0: + return Color(0.85, 1.0, 0.70) # top match — bright green + return Color(0.60, 0.78, 0.50) # rest — muted green + + +func _fuzzy_score(query: String, target: String) -> int: + var qi := 0 + var score := 0 + var last := -1 + for ti in target.length(): + if qi >= query.length(): + break + if target[ti] == query[qi]: + score += 10 + (5 if ti == last + 1 else 0) + last = ti + qi += 1 + if qi < query.length(): + return 0 # not all query chars matched + if target.begins_with(query): + score += 50 + if target == query: + score += 100 + return score + + +func _fmt_val(_key: String, meta: Dictionary) -> String: + if meta["type"] == TYPE_FLOAT: + return "%.4g" % (meta["value"] as float) + if meta["type"] == TYPE_BOOL: + var bval: bool = meta["value"] + return "true" if bval else "false" + return str(meta["value"]) + + +func _clear_suggestions() -> void: + _suggestions.clear() + _selected_idx = -1 + for lbl in _suggest_labels: + lbl.visible = false + _suggest_panel.visible = false + + +func _do_autocomplete() -> 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() + _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() + return + + # 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()) + + 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() + + +# ── Command execution ───────────────────────────────────────────────────────── + +func _on_submitted(raw: String) -> void: + raw = raw.strip_edges() + if raw.is_empty(): + return + _history.push_front(raw) + _history_idx = -1 + _input_field.clear() + _clear_suggestions() + _execute(raw) + + +func _execute(cmd: String) -> void: + _log_raw("[color=#3a5a3a]> %s[/color]" % 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 + + # 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] + if parts.size() == 1: + _read_param(key, meta) + else: + _set_param(key, meta, parts[1]) + + +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), + ] + ) + else: + _log_raw("[b]%s[/b] = [color=#ffe080]%s[/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) + return + var v := snappedf( + clampf(raw.to_float(), meta["min"] as float, meta["max"] as float), + meta["step"] as float + ) + DP.set_value(key, v) + _log_ok("[b]%s[/b] ← [color=#ffe080]%.4g[/color]" % [key, v]) + elif meta["type"] == TYPE_BOOL: + match raw.to_lower(): + "1", "true", "yes", "on": + DP.set_value(key, true) + _log_ok("[b]%s[/b] ← [color=#ffe080]true[/color]" % key) + "0", "false", "no", "off": + DP.set_value(key, false) + _log_ok("[b]%s[/b] ← [color=#ffe080]false[/color]" % key) + _: + _log_warn("Expected true/false, got: %s" % raw) + else: + _log_warn("Cannot set param of type %d" % meta["type"]) + + +func _cmd_list(parts: PackedStringArray) -> void: + var filter := parts[1].to_lower() if parts.size() > 1 else "" + var all := DP.get_all() + var sections: Dictionary = {} + for key: String in all: + var sec: String = all[key]["section"] + if filter.length() > 0: + if not sec.to_lower().begins_with(filter) and not key.to_lower().contains(filter): + continue if not sections.has(sec): sections[sec] = [] sections[sec].append(key) - - for section: String in sections: - content.add_child(HSeparator.new()) - - var header := Label.new() - header.text = section.to_upper() - header.modulate = Color(0.75, 0.85, 1.0) - content.add_child(header) - - for key: String in sections[section]: - var meta: Dictionary = DP.get_all()[key] - if meta["type"] == TYPE_FLOAT: - _add_float_row(content, key, meta) + for sec: String in sections: + _log_raw("[color=#6699ff][b]%s[/b][/color]" % sec) + for key: String in sections[sec]: + _log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color]" % [ + key, _fmt_val(key, all[key]) + ]) -func _add_float_row(parent: VBoxContainer, key: String, meta: Dictionary) -> void: - var row := HBoxContainer.new() - row.size_flags_horizontal = Control.SIZE_FILL - parent.add_child(row) - - var lbl := Label.new() - lbl.text = key.replace("_", " ") - lbl.custom_minimum_size.x = 140 - lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - row.add_child(lbl) - - var slider := HSlider.new() - slider.min_value = meta["min"] - slider.max_value = meta["max"] - slider.step = meta["step"] - slider.value = meta["value"] - slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL - slider.custom_minimum_size.x = 60 - row.add_child(slider) - - var spin := SpinBox.new() - spin.min_value = meta["min"] - spin.max_value = meta["max"] - spin.step = meta["step"] - spin.value = meta["value"] - spin.custom_minimum_size.x = 85 - row.add_child(spin) - - var rand_btn := Button.new() - rand_btn.text = "🎲" - rand_btn.tooltip_text = "Randomize" - rand_btn.pressed.connect(func() -> void: - var v := randf_range(meta["min"], meta["max"]) - v = snappedf(v, meta["step"]) - slider.value = v - ) - row.add_child(rand_btn) - - # Slider → SpinBox + DP (use no_signal to avoid echo) - slider.value_changed.connect(func(v: float) -> void: - spin.set_value_no_signal(v) - DP.set_value(key, v) - ) - # SpinBox → Slider + DP - spin.value_changed.connect(func(v: float) -> void: - slider.set_value_no_signal(v) - DP.set_value(key, v) - ) - # External changes (reset, load) → sync both controls - DP.any_changed.connect(func(changed_key: String, _val: Variant) -> void: - if changed_key != key and changed_key != "__all__": - return - var v := DP.f(key) - slider.set_value_no_signal(v) - spin.set_value_no_signal(v) - ) - - - -func _on_save() -> void: - DP.save() - _status_label.text = "Saved!" - get_tree().create_timer(1.5).timeout.connect(func() -> void: - _status_label.text = "" - ) - - - - -func _input(event: InputEvent) -> void: - if _panel.visible and Input.is_key_pressed(KEY_CTRL) and event is InputEventMouseMotion: - get_viewport().set_input_as_handled() - - -func _unhandled_input(event: InputEvent) -> void: - if not (event is InputEventKey and event.pressed and not event.echo): - return - if (event as InputEventKey).keycode != KEY_F1: - return - _panel.visible = not _panel.visible - if _panel.visible: - _prev_mouse_mode = Input.mouse_mode - Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - else: - Input.mouse_mode = _prev_mouse_mode - get_viewport().set_input_as_handled() - - - -func _on_randomize_all() -> void: +func _fuzzy_find_all(query: String) -> Array[String]: + var result: Array[String] = [] + var ql := query.to_lower() for key: String in DP.get_all(): - var meta: Dictionary = DP.get_all()[key] - if meta["type"] == TYPE_FLOAT: - var v := snappedf(randf_range(meta["min"], meta["max"]), meta["step"]) - DP.set_value(key, v) + if _fuzzy_score(ql, key.to_lower()) > 0: + result.append(key) + return result + + +# ── Logging ─────────────────────────────────────────────────────────────────── + +func _log_raw(text: String) -> void: + if _log.get_line_count() > LOG_MAX_LINES: + _log.clear() + _log.append_text(text + "\n") + + +func _log_ok(text: String) -> void: + _log_raw("[color=#66ee88]%s[/color]" % text) + + +func _log_warn(text: String) -> void: + _log_raw("[color=#ff7744]%s[/color]" % text) diff --git a/hud.gd b/hud.gd index 1c0a371..d9ce37b 100644 --- a/hud.gd +++ b/hud.gd @@ -267,7 +267,6 @@ func _build_controls_panel() -> void: _row(vbox, move_keys, "Move (not intended)", _MUTED) _row(vbox, "LMB (hold)", "Right horn", _CREAM) _row(vbox, "RMB (hold)", "Left horn", _CREAM) - _row(vbox, Controls.get_key_label(&"charge"), "Charge", _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_dash"), "Dash", _CREAM) diff --git a/main_menu.gd b/main_menu.gd index 50a456a..84e159b 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -1,19 +1,57 @@ extends Control -## Intro menu: Siim's arena mockup as a looping video background with an -## overlaid Play / Options / Credits / Exit UI positioned over his sketch. -## Play fades to black and loads the game; Options rebinds controls. +## Intro menu: Siim's arena animation as a looping video background. +## Play / Options / Credits / Exit UI are overlaid; Play follows the cape. const GAME_SCENE := "res://scene.tscn" const FADE_TIME := 0.6 +## Cape centroid per frame (normalized 0-1), detected from red-pixel centroid. +## 34 frames at 24 fps = 1.4167 s loop. +const VIDEO_FPS := 24.0 +const CAPE_POS: Array = [ + Vector2(0.4530, 0.6513), + Vector2(0.4521, 0.6516), + Vector2(0.4495, 0.6524), + Vector2(0.4456, 0.6537), + Vector2(0.4415, 0.6550), + Vector2(0.4373, 0.6578), + Vector2(0.4338, 0.6616), + Vector2(0.4306, 0.6669), + Vector2(0.4273, 0.6673), + Vector2(0.4256, 0.6586), + Vector2(0.4258, 0.6428), + Vector2(0.4281, 0.6243), + Vector2(0.4324, 0.6086), + Vector2(0.4382, 0.5997), + Vector2(0.4439, 0.5972), + Vector2(0.4483, 0.5970), + Vector2(0.4512, 0.5986), + Vector2(0.4529, 0.6025), + Vector2(0.4535, 0.6078), + Vector2(0.4533, 0.6135), + Vector2(0.4539, 0.6159), + Vector2(0.4545, 0.6120), + Vector2(0.4546, 0.6050), + Vector2(0.4541, 0.5988), + Vector2(0.4528, 0.5961), + Vector2(0.4508, 0.6012), + Vector2(0.4485, 0.6123), + Vector2(0.4466, 0.6256), + Vector2(0.4456, 0.6388), + Vector2(0.4457, 0.6491), + Vector2(0.4473, 0.6552), + Vector2(0.4492, 0.6567), + Vector2(0.4513, 0.6556), + Vector2(0.4524, 0.6526), +] + ## Friendly labels for the rebindable actions (order defines list order). const ACTION_LABELS: Dictionary = { &"move_forward": "Move Forward", &"move_back": "Move Back", &"move_left": "Move Left", &"move_right": "Move Right", - &"charge": "Charge", &"ability_kick": "Kick", &"ability_slam": "Slam", &"ability_dash": "Dash", @@ -55,6 +93,19 @@ func _ready() -> void: play_button.grab_focus() +func _process(_delta: float) -> void: + if not main_panel.visible or video.stream == null: + return + var frame_count := CAPE_POS.size() + var t := video.stream_position + var frame_idx := int(t * VIDEO_FPS) % frame_count + var cape: Vector2 = CAPE_POS[frame_idx] + play_button.anchor_left = cape.x + play_button.anchor_right = cape.x + play_button.anchor_top = cape.y + play_button.anchor_bottom = cape.y + + func _build_rebind_rows() -> void: for child in rebind_list.get_children(): child.queue_free() diff --git a/player.gd b/player.gd index ff72d6e..69a7461 100644 --- a/player.gd +++ b/player.gd @@ -35,6 +35,16 @@ var _charge_ready_emitter: CPUParticles3D = null var _slam_pending: bool = false var _charge_trail_emitter: CPUParticles3D = null +# Floating 3D charge bar (mouse-button thrust charge indicator) +const _CHARGE_BAR_WIDTH: float = 1.1 +const _CHARGE_BAR_HEIGHT: float = 1.45 +var _charge_bar: Node3D = null +var _charge_bar_fill: MeshInstance3D = null +var _charge_bar_fill_mat: StandardMaterial3D = null +var _charge_bar_ramp: Gradient = null +var _charge_bar_time: float = 0.0 +var _charge_bar_shown: float = 0.0 + const MAX_HEALTH: int = 10 var health: int = MAX_HEALTH signal health_changed(new_health: int) @@ -55,6 +65,7 @@ func _ready() -> void: _setup_hoof_dust() _setup_legs() _setup_charge_indicators() + _setup_charge_bar() _setup_audio() _bull_anim = _find_anim_player(cube_guy) DP.any_changed.connect(_on_dp_changed) @@ -69,18 +80,7 @@ func _setup_legs() -> void: func _setup_hoof_dust() -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.10 - sphere.height = 0.20 - sphere.radial_segments = 4 - sphere.rings = 2 - sphere.material = mat + var sphere := _particle_sphere(0.10, 4, 2, _unshaded_material()) _walk_dust_ramp = Gradient.new() _walk_dust_ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) @@ -120,18 +120,7 @@ func _setup_hoof_dust() -> void: func _setup_charge_indicators() -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.07 - sphere.height = 0.14 - sphere.radial_segments = 4 - sphere.rings = 2 - sphere.material = mat + var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material()) # Orange spark burst when single-button charge hits max var burst_ramp := Gradient.new() @@ -163,18 +152,7 @@ func _setup_charge_indicators() -> void: cube_guy.add_child(_charge_ready_emitter) # Blue energy trail — streams backward during charge - var trail_mat := StandardMaterial3D.new() - trail_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - trail_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - trail_mat.vertex_color_use_as_albedo = true - trail_mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var trail_sphere := SphereMesh.new() - trail_sphere.radius = 0.065 - trail_sphere.height = 0.13 - trail_sphere.radial_segments = 4 - trail_sphere.rings = 2 - trail_sphere.material = trail_mat + var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material()) var trail_ramp := Gradient.new() trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0)) @@ -206,6 +184,95 @@ func _setup_charge_indicators() -> void: cube_guy.add_child(_charge_trail_emitter) +func _setup_charge_bar() -> void: + _charge_bar = Node3D.new() + _charge_bar.visible = false + add_child(_charge_bar) + + var w := _CHARGE_BAR_WIDTH + var thickness := 0.16 + + # Dark translucent track behind the fill, slightly larger for a border look. + var track_mat := StandardMaterial3D.new() + track_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + track_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + track_mat.albedo_color = Color(0.02, 0.02, 0.04, 0.55) + var track_box := BoxMesh.new() + track_box.size = Vector3(w + 0.07, thickness + 0.07, thickness + 0.07) + track_box.material = track_mat + var track := MeshInstance3D.new() + track.mesh = track_box + track.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _charge_bar.add_child(track) + + # Glowing fill bar; scaled along X each frame to show progress. + _charge_bar_fill_mat = StandardMaterial3D.new() + _charge_bar_fill_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _charge_bar_fill_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _charge_bar_fill_mat.emission_enabled = true + var fill_box := BoxMesh.new() + fill_box.size = Vector3(w, thickness, thickness) + fill_box.material = _charge_bar_fill_mat + _charge_bar_fill = MeshInstance3D.new() + _charge_bar_fill.mesh = fill_box + _charge_bar_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _charge_bar.add_child(_charge_bar_fill) + + # Cool → hot colour ramp as the charge fills (matches the spark FX palette). + _charge_bar_ramp = Gradient.new() + _charge_bar_ramp.offsets = PackedFloat32Array([0.0, 0.6, 1.0]) + _charge_bar_ramp.colors = PackedColorArray([ + Color(0.25, 0.65, 1.0), + Color(1.0, 0.85, 0.2), + Color(1.0, 0.28, 0.05), + ]) + + +func _process(delta: float) -> void: + _update_charge_bar(delta) + + +func _update_charge_bar(delta: float) -> void: + if _charge_bar == null: + return + _charge_bar_time += delta + + var max_charge := DP.f("thrust_charge_time") + var charge := maxf(_thrust_left_charge, _thrust_right_charge) + var frac := clampf(charge / max_charge, 0.0, 1.0) if max_charge > 0.0 else 0.0 + + # Ease the whole bar in/out so it pops up when charging and fades when done. + var target := 1.0 if frac > 0.001 else 0.0 + _charge_bar_shown = move_toward(_charge_bar_shown, target, delta * 6.0) + if _charge_bar_shown <= 0.001: + _charge_bar.visible = false + return + _charge_bar.visible = true + + # Billboard toward the camera on the Y axis, staying upright. + var to_cam := camera_pivot.global_position - _charge_bar.global_position + _charge_bar.rotation.y = atan2(to_cam.x, to_cam.z) + + # Grow the fill from the left edge. + var w := _CHARGE_BAR_WIDTH + _charge_bar_fill.scale.x = maxf(frac, 0.0001) + _charge_bar_fill.position.x = -0.5 * w + 0.5 * w * frac + + # Colour + glow, with a fast pulse once fully charged. + var col := _charge_bar_ramp.sample(frac) + var pulse := 1.0 + (0.4 * sin(_charge_bar_time * 16.0) if frac >= 1.0 else 0.0) + _charge_bar_fill_mat.albedo_color = Color(col.r, col.g, col.b, 0.95) + _charge_bar_fill_mat.emission = col + _charge_bar_fill_mat.emission_energy_multiplier = 1.7 * pulse + + # Float above the bull with a gentle bob; a small bounce + rise on the pop-in. + var bob := 0.03 * sin(_charge_bar_time * 4.0) + _charge_bar.position.y = _CHARGE_BAR_HEIGHT + bob - (1.0 - _charge_bar_shown) * 0.3 + var appear := _charge_bar_shown * (1.06 - 0.06 * cos(_charge_bar_shown * PI)) + var full_pop := 1.0 + (0.08 * sin(_charge_bar_time * 16.0) if frac >= 1.0 else 0.0) + _charge_bar.scale = Vector3.ONE * appear * full_pop + + func _setup_audio() -> void: if ResourceLoader.exists("res://sounds/bull_huff.ogg"): _huff_player = AudioStreamPlayer.new() @@ -273,8 +340,8 @@ func _find_anim_player(node: Node) -> AnimationPlayer: func _unhandled_input(event: InputEvent) -> void: - if _ability_active: - return + # Abilities can be chained: a new one interrupts whatever is currently + # active, gated only by each ability's own cooldown. if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0: _activate_kick() elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0: @@ -301,6 +368,8 @@ func _activate_kick() -> void: _ability_active = true _active_ability = 0 _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 @@ -335,6 +404,8 @@ func _activate_dash() -> void: _ability_active = true _active_ability = 2 _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 @@ -356,9 +427,10 @@ func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float, for mat: Node in get_tree().get_nodes_in_group(&"matador"): var to_mat: Vector3 = (mat as Node3D).global_position - global_position to_mat.y = 0.0 - if to_mat.length() > range_m: + var dist := to_mat.length() + if dist > range_m: continue - if to_mat.length() > 0.01 and forward.dot(to_mat.normalized()) < cos(half_angle_rad): + if dist > 0.01 and forward.dot(to_mat / dist) < cos(half_angle_rad): continue mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength) @@ -367,13 +439,46 @@ func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0 for mat: Node in get_tree().get_nodes_in_group(&"matador"): var to_mat: Vector3 = (mat as Node3D).global_position - global_position to_mat.y = 0.0 - if to_mat.length() > range_m: + var dist := to_mat.length() + if dist > range_m: continue - var away := to_mat.normalized() if to_mat.length() > 0.01 \ + var away := to_mat / dist if dist > 0.01 \ else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized() mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost) +# ── Particle helpers ────────────────────────────────────────────────────────── +# All FX in this script use unshaded, vertex-coloured, alpha-blended particles; +# these three helpers remove the boilerplate each spawn function used to repeat. + +func _unshaded_material() -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.vertex_color_use_as_albedo = true + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + return mat + + +# Low-poly sphere sized for a particle mesh (height is always the diameter). +func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh: + var sphere := SphereMesh.new() + sphere.radius = radius + sphere.height = radius * 2.0 + sphere.radial_segments = segments + sphere.rings = rings + sphere.material = mat + return sphere + + +# Queue-free a transient FX node once its particles have finished. +func _free_after(node: Node, delay: float) -> void: + get_tree().create_timer(delay).timeout.connect(func() -> void: + if is_instance_valid(node): + node.queue_free() + ) + + func _spawn_slam_fx() -> void: if not _slam_ring_mesh: _slam_ring_mesh = _make_slam_ring_mesh() @@ -417,18 +522,7 @@ func _launch_slam_ring(origin: Vector3, max_r: float, func _spawn_slam_dust(origin: Vector3) -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.09 - sphere.height = 0.18 - sphere.radial_segments = 4 - sphere.rings = 2 - sphere.material = mat + var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material()) var ramp := Gradient.new() ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0)) @@ -459,9 +553,7 @@ func _spawn_slam_dust(origin: Vector3) -> void: get_parent().add_child(p) p.global_position = origin p.restart() - get_tree().create_timer(2.5).timeout.connect(func() -> void: - if is_instance_valid(p): p.queue_free() - ) + _free_after(p, 2.5) func _spawn_kick_fx(back_dir: Vector3) -> void: @@ -474,18 +566,7 @@ func _spawn_kick_fx(back_dir: Vector3) -> void: func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.12 - sphere.height = 0.24 - sphere.radial_segments = 4 - sphere.rings = 2 - sphere.material = mat + var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material()) var ramp := Gradient.new() ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95)) @@ -516,24 +597,11 @@ func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void: get_parent().add_child(p) p.global_position = origin p.restart() - get_tree().create_timer(2.5).timeout.connect(func() -> void: - if is_instance_valid(p): p.queue_free() - ) + _free_after(p, 2.5) func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.055 - sphere.height = 0.11 - sphere.radial_segments = 3 - sphere.rings = 1 - sphere.material = mat + var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material()) var ramp := Gradient.new() ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0)) @@ -564,24 +632,14 @@ func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void: get_parent().add_child(p) p.global_position = origin p.restart() - get_tree().create_timer(2.0).timeout.connect(func() -> void: - if is_instance_valid(p): p.queue_free() - ) + _free_after(p, 2.0) func _spawn_kick_flash(origin: Vector3) -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + var mat := _unshaded_material() mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0) - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - var sphere := SphereMesh.new() - sphere.radius = 1.0 - sphere.height = 2.0 - sphere.radial_segments = 8 - sphere.rings = 4 - sphere.material = mat + var sphere := _particle_sphere(1.0, 8, 4, mat) var mi := MeshInstance3D.new() mi.mesh = sphere @@ -601,15 +659,9 @@ func _spawn_kick_flash(origin: Vector3) -> void: func _spawn_dash_burst() -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - var box := BoxMesh.new() box.size = Vector3(0.045, 0.045, 0.30) - box.material = mat + box.material = _unshaded_material() var ramp := Gradient.new() ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0)) @@ -639,24 +691,11 @@ func _spawn_dash_burst() -> void: get_parent().add_child(p) p.global_position = global_position + Vector3(0.0, 0.2, 0.0) p.restart() - get_tree().create_timer(0.6).timeout.connect(func() -> void: - if is_instance_valid(p): p.queue_free() - ) + _free_after(p, 0.6) func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.04 - sphere.height = 0.08 - sphere.radial_segments = 3 - sphere.rings = 1 - sphere.material = mat + var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material()) var ramp := Gradient.new() ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0)) @@ -687,9 +726,7 @@ func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void: get_parent().add_child(p) p.global_position = origin p.restart() - get_tree().create_timer(1.0).timeout.connect(func() -> void: - if is_instance_valid(p): p.queue_free() - ) + _free_after(p, 1.0) func _make_slam_ring_mesh() -> ArrayMesh: diff --git a/project.godot b/project.godot index fec5ea9..98a4bad 100644 --- a/project.godot +++ b/project.godot @@ -75,12 +75,6 @@ toggle_mouse_capture={ "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":4,"position":Vector2(461, 22),"global_position":Vector2(470, 70),"factor":1.0,"button_index":3,"canceled":false,"pressed":true,"double_click":false,"script":null) ] } -charge={ -"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":4194325,"key_label":0,"unicode":0,"location":1,"echo":false,"script":null) -, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null) -] -} thrust_left={ "deadzone": 0.2, "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null) diff --git a/run_tests.sh b/run_tests.sh index 478b92c..25d0334 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -14,6 +14,10 @@ echo "" echo "=== Logic tests ===" "$GODOT" --headless --script tests/logic_test.gd +echo "" +echo "=== Performance tests (frame-budget regression guard) ===" +"$GODOT" --headless --script tests/performance_test.gd + if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then echo "" echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ===" diff --git a/tests/performance_test.gd b/tests/performance_test.gd new file mode 100644 index 0000000..c1267b1 --- /dev/null +++ b/tests/performance_test.gd @@ -0,0 +1,114 @@ +extends SceneTree +## Performance regression test — loads the full scene under a heavy matador load, +## samples per-frame times during normal play and during a mass-ragdoll stress +## spike, and asserts the frame budget holds. +## +## Run (headless): +## godot --headless --script tests/performance_test.gd +## +## Exit code 0 = within budget, 1 = a budget was exceeded (or a frame was non-finite). +## +## Budgets are deliberately generous regression guards, not a target frame rate: +## they only trip on a pathological slowdown, so the test stays stable across the +## range of machines it runs on. Every run also prints the measured numbers so a +## gradual creep is visible even when it doesn't fail. + +const STRESS_MATADORS := 20 +const WARMUP_SEC := 1.0 +const SAMPLE_FRAMES := 120 + +# Generous ceilings — a healthy build sits far below these. +const AVG_BUDGET_MS := 16.0 # sustained cost must leave headroom for 60 fps +const MAX_BUDGET_MS := 100.0 # a single hitch this large is a real regression + +var _passed: int = 0 +var _failed: int = 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + print("=".repeat(60)) + print("PERFORMANCE TEST") + print("=".repeat(60)) + + var dp: Node = root.get_node_or_null("/root/DP") + if dp: + dp.set_value("mat_spawn_count", STRESS_MATADORS) + + var scene_res := load("res://scene.tscn") + if not scene_res: + push_error("performance_test: failed to load scene.tscn") + quit(1) + return + root.add_child(scene_res.instantiate()) + + await create_timer(WARMUP_SEC).timeout + + var matadors := get_nodes_in_group(&"matador") + print(" matadors alive: %d" % matadors.size()) + + var normal := await _sample_frames(SAMPLE_FRAMES) + _report_phase("normal play", normal) + + # Stress spike: ragdoll every matador on the same frame. + for mat: Node in get_nodes_in_group(&"matador"): + if mat.has_method("_enter_ragdoll"): + mat._enter_ragdoll(Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized(), 12.0) + + var stress := await _sample_frames(SAMPLE_FRAMES) + _report_phase("mass ragdoll", stress) + + _finish() + + +# Awaits SAMPLE_FRAMES idle frames, returning [avg_ms, max_ms, all_finite]. +func _sample_frames(count: int) -> Array: + var total_us := 0 + var max_us := 0 + var all_finite := true + var last := Time.get_ticks_usec() + for _i in count: + await process_frame + var now := Time.get_ticks_usec() + var frame_us := now - last + last = now + total_us += frame_us + max_us = maxi(max_us, frame_us) + if not is_finite(float(frame_us)): + all_finite = false + var avg_ms := (total_us / float(count)) / 1000.0 + return [avg_ms, max_us / 1000.0, all_finite] + + +func _report_phase(label: String, sample: Array) -> void: + var avg_ms: float = sample[0] + var max_ms: float = sample[1] + var all_finite: bool = sample[2] + print("\n-- %s --" % label) + print(" avg %.2f ms/frame (~%.0f fps) peak %.2f ms" % [ + avg_ms, 1000.0 / maxf(avg_ms, 0.001), max_ms]) + _assert_true(all_finite, "%s: all frame times finite" % label) + _assert_true(avg_ms < AVG_BUDGET_MS, + "%s: avg %.2f ms under %.0f ms budget" % [label, avg_ms, AVG_BUDGET_MS]) + _assert_true(max_ms < MAX_BUDGET_MS, + "%s: peak %.2f ms under %.0f ms budget" % [label, max_ms, MAX_BUDGET_MS]) + + +func _assert_true(condition: bool, desc: String) -> void: + if condition: + _passed += 1 + print(" PASS: %s" % desc) + else: + _failed += 1 + print(" FAIL: %s" % desc) + + +func _finish() -> void: + print("") + print("=".repeat(60)) + print("PERFORMANCE TEST: %d passed, %d failed" % [_passed, _failed]) + print("=".repeat(60)) + quit(1 if _failed > 0 else 0)