menu interactive, refactor, perf tests, charge bar, debug menu stuff
This commit is contained in:
Binary file not shown.
@@ -90,6 +90,7 @@ bash run_tests.sh
|
|||||||
# Individual tests:
|
# Individual tests:
|
||||||
gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
|
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/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
|
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
|
||||||
|
|
||||||
# Gameplay test with real rendering (inspect frame strip visually):
|
# 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.
|
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
|
## Godot 4.x specifics
|
||||||
|
|
||||||
- `wrapf` / `wrap` instead of manual modulo for angles
|
- `wrapf` / `wrap` instead of manual modulo for angles
|
||||||
|
|||||||
+4
-4
@@ -59,10 +59,10 @@ grow_vertical = 2
|
|||||||
|
|
||||||
[node name="PlayButton" type="Button" parent="MainPanel"]
|
[node name="PlayButton" type="Button" parent="MainPanel"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchor_left = 0.459
|
anchor_left = 0.4530
|
||||||
anchor_top = 0.449
|
anchor_top = 0.6513
|
||||||
anchor_right = 0.459
|
anchor_right = 0.4530
|
||||||
anchor_bottom = 0.449
|
anchor_bottom = 0.6513
|
||||||
offset_left = -120.0
|
offset_left = -120.0
|
||||||
offset_top = -55.0
|
offset_top = -55.0
|
||||||
offset_right = 120.0
|
offset_right = 120.0
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
|
|||||||
&"move_back",
|
&"move_back",
|
||||||
&"move_left",
|
&"move_left",
|
||||||
&"move_right",
|
&"move_right",
|
||||||
&"charge",
|
|
||||||
&"ability_kick",
|
&"ability_kick",
|
||||||
&"ability_slam",
|
&"ability_slam",
|
||||||
&"ability_dash",
|
&"ability_dash",
|
||||||
|
|||||||
+428
-177
@@ -1,10 +1,28 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Runtime debug panel. Toggle with F1.
|
## Quake-style drop-down debug console. Toggle with ~ (Shift+`).
|
||||||
## Reads DP.get_all() to build controls — adding a param to DP is all that's needed to show it here.
|
## Query a param: type its name. Set it: "name value".
|
||||||
|
## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close.
|
||||||
|
|
||||||
var _panel: Control
|
const CONSOLE_HEIGHT_RATIO := 0.48
|
||||||
var _status_label: Label
|
const SLIDE_DURATION := 0.18
|
||||||
var _prev_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_CAPTURED
|
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:
|
func _ready() -> void:
|
||||||
@@ -12,205 +30,438 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
var canvas := CanvasLayer.new()
|
_canvas = CanvasLayer.new()
|
||||||
canvas.layer = 128
|
_canvas.layer = 128
|
||||||
add_child(canvas)
|
add_child(_canvas)
|
||||||
|
|
||||||
_panel = PanelContainer.new()
|
_panel = PanelContainer.new()
|
||||||
_panel.anchor_left = 1.0
|
_panel.anchor_left = 0.0
|
||||||
_panel.anchor_right = 1.0
|
_panel.anchor_right = 1.0
|
||||||
_panel.anchor_top = 0.0
|
_panel.anchor_top = 0.0
|
||||||
_panel.anchor_bottom = 1.0
|
_panel.anchor_bottom = 0.0
|
||||||
_panel.offset_left = -390.0
|
_panel.offset_bottom = 500.0
|
||||||
_panel.offset_right = 0.0
|
|
||||||
_panel.offset_top = 0.0
|
|
||||||
_panel.offset_bottom = 0.0
|
|
||||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
_panel.visible = false
|
_panel.visible = false
|
||||||
|
|
||||||
var bg := StyleBoxFlat.new()
|
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)
|
_panel.add_theme_stylebox_override("panel", bg)
|
||||||
canvas.add_child(_panel)
|
_canvas.add_child(_panel)
|
||||||
|
|
||||||
var root_vbox := VBoxContainer.new()
|
var root_vbox := VBoxContainer.new()
|
||||||
root_vbox.size_flags_horizontal = Control.SIZE_FILL
|
root_vbox.size_flags_horizontal = Control.SIZE_FILL
|
||||||
|
root_vbox.size_flags_vertical = Control.SIZE_FILL
|
||||||
_panel.add_child(root_vbox)
|
_panel.add_child(root_vbox)
|
||||||
|
|
||||||
# ── Toolbar ───────────────────────────────────────────────────────────────
|
# ── Header ────────────────────────────────────────────────────────────────
|
||||||
var toolbar_margin := MarginContainer.new()
|
var header_margin := MarginContainer.new()
|
||||||
toolbar_margin.add_theme_constant_override("margin_left", 8)
|
header_margin.add_theme_constant_override("margin_left", 10)
|
||||||
toolbar_margin.add_theme_constant_override("margin_right", 8)
|
header_margin.add_theme_constant_override("margin_top", 4)
|
||||||
toolbar_margin.add_theme_constant_override("margin_top", 6)
|
header_margin.add_theme_constant_override("margin_bottom", 2)
|
||||||
toolbar_margin.add_theme_constant_override("margin_bottom", 4)
|
root_vbox.add_child(header_margin)
|
||||||
root_vbox.add_child(toolbar_margin)
|
|
||||||
|
|
||||||
var toolbar := HBoxContainer.new()
|
var header_lbl := Label.new()
|
||||||
toolbar.add_theme_constant_override("separation", 6)
|
header_lbl.text = "BULLOSSEUM CONSOLE [~ close · Tab complete · ↑↓ browse]"
|
||||||
toolbar_margin.add_child(toolbar)
|
header_lbl.modulate = Color(0.35, 0.9, 0.35, 0.60)
|
||||||
|
header_margin.add_child(header_lbl)
|
||||||
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)
|
|
||||||
|
|
||||||
root_vbox.add_child(HSeparator.new())
|
root_vbox.add_child(HSeparator.new())
|
||||||
|
|
||||||
# ── Scrollable content ────────────────────────────────────────────────────
|
# ── Log ───────────────────────────────────────────────────────────────────
|
||||||
var scroll := ScrollContainer.new()
|
_log = RichTextLabel.new()
|
||||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
_log.bbcode_enabled = true
|
||||||
scroll.size_flags_horizontal = Control.SIZE_FILL
|
_log.scroll_following = true
|
||||||
root_vbox.add_child(scroll)
|
_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()
|
# ── Suggestion panel ──────────────────────────────────────────────────────
|
||||||
content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_suggest_panel = PanelContainer.new()
|
||||||
content_margin.add_theme_constant_override("margin_left", 8)
|
_suggest_panel.visible = false
|
||||||
content_margin.add_theme_constant_override("margin_right", 8)
|
_suggest_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
content_margin.add_theme_constant_override("margin_top", 4)
|
|
||||||
content_margin.add_theme_constant_override("margin_bottom", 8)
|
|
||||||
scroll.add_child(content_margin)
|
|
||||||
|
|
||||||
var content := VBoxContainer.new()
|
var sg_bg := StyleBoxFlat.new()
|
||||||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
sg_bg.bg_color = Color(0.06, 0.10, 0.06, 0.97)
|
||||||
content_margin.add_child(content)
|
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:
|
# ── Input handling ────────────────────────────────────────────────────────────
|
||||||
# Group keys by section preserving registration order
|
|
||||||
var sections: Dictionary = {}
|
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():
|
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):
|
if not sections.has(sec):
|
||||||
sections[sec] = []
|
sections[sec] = []
|
||||||
sections[sec].append(key)
|
sections[sec].append(key)
|
||||||
|
for sec: String in sections:
|
||||||
for section: String in sections:
|
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
|
||||||
content.add_child(HSeparator.new())
|
for key: String in sections[sec]:
|
||||||
|
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color]" % [
|
||||||
var header := Label.new()
|
key, _fmt_val(key, all[key])
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
func _add_float_row(parent: VBoxContainer, key: String, meta: Dictionary) -> void:
|
func _fuzzy_find_all(query: String) -> Array[String]:
|
||||||
var row := HBoxContainer.new()
|
var result: Array[String] = []
|
||||||
row.size_flags_horizontal = Control.SIZE_FILL
|
var ql := query.to_lower()
|
||||||
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:
|
|
||||||
for key: String in DP.get_all():
|
for key: String in DP.get_all():
|
||||||
var meta: Dictionary = DP.get_all()[key]
|
if _fuzzy_score(ql, key.to_lower()) > 0:
|
||||||
if meta["type"] == TYPE_FLOAT:
|
result.append(key)
|
||||||
var v := snappedf(randf_range(meta["min"], meta["max"]), meta["step"])
|
return result
|
||||||
DP.set_value(key, v)
|
|
||||||
|
|
||||||
|
# ── 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)
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ func _build_controls_panel() -> void:
|
|||||||
_row(vbox, move_keys, "Move (not intended)", _MUTED)
|
_row(vbox, move_keys, "Move (not intended)", _MUTED)
|
||||||
_row(vbox, "LMB (hold)", "Right horn", _CREAM)
|
_row(vbox, "LMB (hold)", "Right horn", _CREAM)
|
||||||
_row(vbox, "RMB (hold)", "Left 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_kick"), "Kick", _CREAM)
|
||||||
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
|
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
|
||||||
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
|
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
|
||||||
|
|||||||
+55
-4
@@ -1,19 +1,57 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
## Intro menu: Siim's arena mockup as a looping video background with an
|
## Intro menu: Siim's arena animation as a looping video background.
|
||||||
## overlaid Play / Options / Credits / Exit UI positioned over his sketch.
|
## Play / Options / Credits / Exit UI are overlaid; Play follows the cape.
|
||||||
## Play fades to black and loads the game; Options rebinds controls.
|
|
||||||
|
|
||||||
const GAME_SCENE := "res://scene.tscn"
|
const GAME_SCENE := "res://scene.tscn"
|
||||||
const FADE_TIME := 0.6
|
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).
|
## Friendly labels for the rebindable actions (order defines list order).
|
||||||
const ACTION_LABELS: Dictionary = {
|
const ACTION_LABELS: Dictionary = {
|
||||||
&"move_forward": "Move Forward",
|
&"move_forward": "Move Forward",
|
||||||
&"move_back": "Move Back",
|
&"move_back": "Move Back",
|
||||||
&"move_left": "Move Left",
|
&"move_left": "Move Left",
|
||||||
&"move_right": "Move Right",
|
&"move_right": "Move Right",
|
||||||
&"charge": "Charge",
|
|
||||||
&"ability_kick": "Kick",
|
&"ability_kick": "Kick",
|
||||||
&"ability_slam": "Slam",
|
&"ability_slam": "Slam",
|
||||||
&"ability_dash": "Dash",
|
&"ability_dash": "Dash",
|
||||||
@@ -55,6 +93,19 @@ func _ready() -> void:
|
|||||||
play_button.grab_focus()
|
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:
|
func _build_rebind_rows() -> void:
|
||||||
for child in rebind_list.get_children():
|
for child in rebind_list.get_children():
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ var _charge_ready_emitter: CPUParticles3D = null
|
|||||||
var _slam_pending: bool = false
|
var _slam_pending: bool = false
|
||||||
var _charge_trail_emitter: CPUParticles3D = null
|
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
|
const MAX_HEALTH: int = 10
|
||||||
var health: int = MAX_HEALTH
|
var health: int = MAX_HEALTH
|
||||||
signal health_changed(new_health: int)
|
signal health_changed(new_health: int)
|
||||||
@@ -55,6 +65,7 @@ func _ready() -> void:
|
|||||||
_setup_hoof_dust()
|
_setup_hoof_dust()
|
||||||
_setup_legs()
|
_setup_legs()
|
||||||
_setup_charge_indicators()
|
_setup_charge_indicators()
|
||||||
|
_setup_charge_bar()
|
||||||
_setup_audio()
|
_setup_audio()
|
||||||
_bull_anim = _find_anim_player(cube_guy)
|
_bull_anim = _find_anim_player(cube_guy)
|
||||||
DP.any_changed.connect(_on_dp_changed)
|
DP.any_changed.connect(_on_dp_changed)
|
||||||
@@ -69,18 +80,7 @@ func _setup_legs() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _setup_hoof_dust() -> void:
|
func _setup_hoof_dust() -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var sphere := _particle_sphere(0.10, 4, 2, _unshaded_material())
|
||||||
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
|
|
||||||
|
|
||||||
_walk_dust_ramp = Gradient.new()
|
_walk_dust_ramp = Gradient.new()
|
||||||
_walk_dust_ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0))
|
_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:
|
func _setup_charge_indicators() -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material())
|
||||||
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
|
|
||||||
|
|
||||||
# Orange spark burst when single-button charge hits max
|
# Orange spark burst when single-button charge hits max
|
||||||
var burst_ramp := Gradient.new()
|
var burst_ramp := Gradient.new()
|
||||||
@@ -163,18 +152,7 @@ func _setup_charge_indicators() -> void:
|
|||||||
cube_guy.add_child(_charge_ready_emitter)
|
cube_guy.add_child(_charge_ready_emitter)
|
||||||
|
|
||||||
# Blue energy trail — streams backward during charge
|
# Blue energy trail — streams backward during charge
|
||||||
var trail_mat := StandardMaterial3D.new()
|
var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material())
|
||||||
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_ramp := Gradient.new()
|
var trail_ramp := Gradient.new()
|
||||||
trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0))
|
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)
|
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:
|
func _setup_audio() -> void:
|
||||||
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
|
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
|
||||||
_huff_player = AudioStreamPlayer.new()
|
_huff_player = AudioStreamPlayer.new()
|
||||||
@@ -273,8 +340,8 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
|
|||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if _ability_active:
|
# Abilities can be chained: a new one interrupts whatever is currently
|
||||||
return
|
# active, gated only by each ability's own cooldown.
|
||||||
if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0:
|
if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0:
|
||||||
_activate_kick()
|
_activate_kick()
|
||||||
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0:
|
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0:
|
||||||
@@ -301,6 +368,8 @@ func _activate_kick() -> void:
|
|||||||
_ability_active = true
|
_ability_active = true
|
||||||
_active_ability = 0
|
_active_ability = 0
|
||||||
_ability_timer = 0.6
|
_ability_timer = 0.6
|
||||||
|
if _bull_anim:
|
||||||
|
_bull_anim.speed_scale = 1.0
|
||||||
if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"):
|
if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"):
|
||||||
_bull_anim.play(&"Armature|FOOTKICK")
|
_bull_anim.play(&"Armature|FOOTKICK")
|
||||||
_ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").length
|
_ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").length
|
||||||
@@ -335,6 +404,8 @@ func _activate_dash() -> void:
|
|||||||
_ability_active = true
|
_ability_active = true
|
||||||
_active_ability = 2
|
_active_ability = 2
|
||||||
_ability_timer = 0.45
|
_ability_timer = 0.45
|
||||||
|
if _bull_anim:
|
||||||
|
_bull_anim.speed_scale = 1.0
|
||||||
if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"):
|
if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"):
|
||||||
_bull_anim.play(&"Armature|DASH")
|
_bull_anim.play(&"Armature|DASH")
|
||||||
_ability_timer = _bull_anim.get_animation(&"Armature|DASH").length
|
_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"):
|
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
||||||
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
||||||
to_mat.y = 0.0
|
to_mat.y = 0.0
|
||||||
if to_mat.length() > range_m:
|
var dist := to_mat.length()
|
||||||
|
if dist > range_m:
|
||||||
continue
|
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
|
continue
|
||||||
mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength)
|
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"):
|
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
||||||
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
||||||
to_mat.y = 0.0
|
to_mat.y = 0.0
|
||||||
if to_mat.length() > range_m:
|
var dist := to_mat.length()
|
||||||
|
if dist > range_m:
|
||||||
continue
|
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()
|
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)
|
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:
|
func _spawn_slam_fx() -> void:
|
||||||
if not _slam_ring_mesh:
|
if not _slam_ring_mesh:
|
||||||
_slam_ring_mesh = _make_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:
|
func _spawn_slam_dust(origin: Vector3) -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material())
|
||||||
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 ramp := Gradient.new()
|
var ramp := Gradient.new()
|
||||||
ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0))
|
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)
|
get_parent().add_child(p)
|
||||||
p.global_position = origin
|
p.global_position = origin
|
||||||
p.restart()
|
p.restart()
|
||||||
get_tree().create_timer(2.5).timeout.connect(func() -> void:
|
_free_after(p, 2.5)
|
||||||
if is_instance_valid(p): p.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_kick_fx(back_dir: Vector3) -> void:
|
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:
|
func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material())
|
||||||
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 ramp := Gradient.new()
|
var ramp := Gradient.new()
|
||||||
ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95))
|
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)
|
get_parent().add_child(p)
|
||||||
p.global_position = origin
|
p.global_position = origin
|
||||||
p.restart()
|
p.restart()
|
||||||
get_tree().create_timer(2.5).timeout.connect(func() -> void:
|
_free_after(p, 2.5)
|
||||||
if is_instance_valid(p): p.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
|
func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material())
|
||||||
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 ramp := Gradient.new()
|
var ramp := Gradient.new()
|
||||||
ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0))
|
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)
|
get_parent().add_child(p)
|
||||||
p.global_position = origin
|
p.global_position = origin
|
||||||
p.restart()
|
p.restart()
|
||||||
get_tree().create_timer(2.0).timeout.connect(func() -> void:
|
_free_after(p, 2.0)
|
||||||
if is_instance_valid(p): p.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_kick_flash(origin: Vector3) -> void:
|
func _spawn_kick_flash(origin: Vector3) -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var mat := _unshaded_material()
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
||||||
mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0)
|
mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0)
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
||||||
|
|
||||||
var sphere := SphereMesh.new()
|
var sphere := _particle_sphere(1.0, 8, 4, mat)
|
||||||
sphere.radius = 1.0
|
|
||||||
sphere.height = 2.0
|
|
||||||
sphere.radial_segments = 8
|
|
||||||
sphere.rings = 4
|
|
||||||
sphere.material = mat
|
|
||||||
|
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = sphere
|
mi.mesh = sphere
|
||||||
@@ -601,15 +659,9 @@ func _spawn_kick_flash(origin: Vector3) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _spawn_dash_burst() -> 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()
|
var box := BoxMesh.new()
|
||||||
box.size = Vector3(0.045, 0.045, 0.30)
|
box.size = Vector3(0.045, 0.045, 0.30)
|
||||||
box.material = mat
|
box.material = _unshaded_material()
|
||||||
|
|
||||||
var ramp := Gradient.new()
|
var ramp := Gradient.new()
|
||||||
ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0))
|
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)
|
get_parent().add_child(p)
|
||||||
p.global_position = global_position + Vector3(0.0, 0.2, 0.0)
|
p.global_position = global_position + Vector3(0.0, 0.2, 0.0)
|
||||||
p.restart()
|
p.restart()
|
||||||
get_tree().create_timer(0.6).timeout.connect(func() -> void:
|
_free_after(p, 0.6)
|
||||||
if is_instance_valid(p): p.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void:
|
func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void:
|
||||||
var mat := StandardMaterial3D.new()
|
var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material())
|
||||||
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 ramp := Gradient.new()
|
var ramp := Gradient.new()
|
||||||
ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0))
|
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)
|
get_parent().add_child(p)
|
||||||
p.global_position = origin
|
p.global_position = origin
|
||||||
p.restart()
|
p.restart()
|
||||||
get_tree().create_timer(1.0).timeout.connect(func() -> void:
|
_free_after(p, 1.0)
|
||||||
if is_instance_valid(p): p.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _make_slam_ring_mesh() -> ArrayMesh:
|
func _make_slam_ring_mesh() -> ArrayMesh:
|
||||||
|
|||||||
@@ -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)
|
"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={
|
thrust_left={
|
||||||
"deadzone": 0.2,
|
"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)
|
"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)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ echo ""
|
|||||||
echo "=== Logic tests ==="
|
echo "=== Logic tests ==="
|
||||||
"$GODOT" --headless --script tests/logic_test.gd
|
"$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
|
if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ==="
|
echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ==="
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user