menu interactive, refactor, perf tests, charge bar, debug menu stuff

This commit is contained in:
2026-07-22 22:57:02 +03:00
parent 631df017d1
commit 603a674e08
11 changed files with 772 additions and 315 deletions
Binary file not shown.
+8
View File
@@ -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
+4 -4
View File
@@ -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
-1
View File
@@ -14,7 +14,6 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
&"move_back",
&"move_left",
&"move_right",
&"charge",
&"ability_kick",
&"ability_slam",
&"ability_dash",
+428 -177
View File
@@ -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] <name>[/color] — read current value + range")
_log_raw("[color=#888888] <name> <value>[/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)
-1
View File
@@ -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)
+55 -4
View File
@@ -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()
+159 -122
View File
@@ -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:
-6
View File
@@ -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)
+4
View File
@@ -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) ==="
+114
View File
@@ -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)