Reorganize into role-based folders and consolidate art
Move every script and scene into role-based directories (actors/, systems/, ui/, levels/, props/) and all raw art under art/ (grouped by role, snake_case, Extract-Textures GLBs), dropping Blender version-suffix noise. Filenames are snake_case (PascalCase reserved for class_name) to avoid case-insensitive filesystem aliasing on the web/macOS targets. Routes the bull's hits through the typed DamageEvent pipeline and wires the bull's abilities to the Fx library. Updates CLAUDE.md's folder-layout overview to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
This commit is contained in:
@@ -0,0 +1,679 @@
|
||||
extends Node
|
||||
## Quake-style drop-down developer console (autoload "Console"). Toggle with the
|
||||
## backtick/tilde key (`/~) — matched by physical position so it works on any
|
||||
## keyboard layout and with or without Shift.
|
||||
## Query a param: type its name. Set it: "name value".
|
||||
## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close.
|
||||
|
||||
const CONSOLE_HEIGHT_RATIO := 0.25
|
||||
const SLIDE_DURATION := 0.18
|
||||
const MAX_SUGGESTIONS := 10
|
||||
const LOG_MAX_LINES := 300
|
||||
|
||||
# Built-in commands (name → one-line help). Surfaced in autocomplete alongside
|
||||
# param keys and printed by `help`. None collide with param key names.
|
||||
const _COMMANDS := {
|
||||
"save": "persist values to disk",
|
||||
"reset": "reset all, or reset <name>",
|
||||
"diff": "list params changed from default",
|
||||
"toggle": "flip a bool param",
|
||||
"list": "print params (optional section)",
|
||||
"level": "select level and reload (level <id> — arena / arena_boss / bear)",
|
||||
"restart": "reload the current level scene",
|
||||
"clear": "clear the log",
|
||||
"help": "show this help",
|
||||
}
|
||||
|
||||
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 # -1 = editing the live line
|
||||
var _pending_line: String = "" # live line stashed while browsing history
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Keep the console (and its slide tween/input) alive while the tree is paused.
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_build_ui()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var mono := SystemFont.new()
|
||||
mono.font_names = PackedStringArray([
|
||||
"JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", "monospace",
|
||||
])
|
||||
|
||||
_canvas = CanvasLayer.new()
|
||||
_canvas.layer = 128
|
||||
add_child(_canvas)
|
||||
|
||||
_panel = PanelContainer.new()
|
||||
_panel.anchor_left = 0.0
|
||||
_panel.anchor_right = 1.0
|
||||
_panel.anchor_top = 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.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)
|
||||
|
||||
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)
|
||||
|
||||
# ── 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)
|
||||
# Monospace so the rpad-aligned suggestion/list columns actually line up.
|
||||
_log.add_theme_font_override("normal_font", mono)
|
||||
_log.add_theme_font_override("bold_font", mono)
|
||||
_log.add_theme_font_override("mono_font", mono)
|
||||
root_vbox.add_child(_log)
|
||||
|
||||
# ── Suggestion panel ──────────────────────────────────────────────────────
|
||||
_suggest_panel = PanelContainer.new()
|
||||
_suggest_panel.visible = false
|
||||
_suggest_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
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)
|
||||
|
||||
_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)
|
||||
lbl.add_theme_font_override("font", mono)
|
||||
_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)
|
||||
prompt_lbl.add_theme_font_override("font", mono)
|
||||
in_row.add_child(prompt_lbl)
|
||||
|
||||
_input_field = LineEdit.new()
|
||||
_input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_input_field.placeholder_text = "param · param value · toggle <bool> · save reset diff list clear help"
|
||||
_input_field.clear_button_enabled = true
|
||||
_input_field.add_theme_font_size_override("font_size", 14)
|
||||
_input_field.add_theme_font_override("font", mono)
|
||||
# Drop the default LineEdit chrome (the white box border) — the panel is the frame.
|
||||
var flat := StyleBoxFlat.new()
|
||||
flat.bg_color = Color(0, 0, 0, 0)
|
||||
flat.set_border_width_all(0)
|
||||
flat.set_content_margin_all(2)
|
||||
_input_field.add_theme_stylebox_override("normal", flat)
|
||||
_input_field.add_theme_stylebox_override("focus", flat)
|
||||
in_row.add_child(_input_field)
|
||||
|
||||
_input_field.text_changed.connect(_on_text_changed)
|
||||
_input_field.text_submitted.connect(_on_submitted)
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
# Backtick/tilde — toggle regardless of focus. Match by physical position so a
|
||||
# non-US layout or a Shift-produced ~ both open the console.
|
||||
if ke.physical_keycode == KEY_QUOTELEFT:
|
||||
_toggle()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
if not _is_open:
|
||||
return
|
||||
|
||||
# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
|
||||
if ke.ctrl_pressed:
|
||||
match ke.keycode:
|
||||
KEY_C:
|
||||
_clear_input()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
KEY_L:
|
||||
_log.clear()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
match ke.keycode:
|
||||
KEY_TAB:
|
||||
_accept_completion()
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_UP:
|
||||
_arrow(-1)
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_DOWN:
|
||||
_arrow(1)
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_ESCAPE:
|
||||
_toggle()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
|
||||
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
|
||||
# while the console is open — cheap, and it never lets the command line go dark.
|
||||
func _process(_delta: float) -> void:
|
||||
if _is_open and not _input_field.has_focus():
|
||||
# Diagnostic: name whatever currently holds focus before we snatch it back, so
|
||||
# a persistent thief (a HUD button, a menu control) shows up in the log instead
|
||||
# of us guessing. Remove once the culprit is identified.
|
||||
var owner: Control = get_viewport().gui_get_focus_owner()
|
||||
var who: String = String(owner.name) if owner != null else "<nothing>"
|
||||
_log_warn("focus stolen by %s — reclaiming" % who)
|
||||
_input_field.grab_focus()
|
||||
|
||||
|
||||
func _toggle() -> void:
|
||||
_is_open = not _is_open
|
||||
get_tree().paused = _is_open
|
||||
var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO
|
||||
_panel.offset_bottom = h
|
||||
|
||||
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 = []
|
||||
# Commands rank above params on an equal score so a leading "re" surfaces the
|
||||
# `reset` verb, not just params that happen to contain those letters.
|
||||
for name: String in _COMMANDS:
|
||||
var cs := _fuzzy_score(ql, name)
|
||||
if cs > 0:
|
||||
scored.append([cs + 1, name])
|
||||
for key: String in DP.get_all():
|
||||
var s := _fuzzy_score(ql, key.to_lower())
|
||||
if s > 0:
|
||||
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():
|
||||
_suggest_labels[i].text = _suggestion_text(_suggestions[i])
|
||||
_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 _is_command(name: String) -> bool:
|
||||
return _COMMANDS.has(name)
|
||||
|
||||
|
||||
func _suggestion_text(name: String) -> String:
|
||||
if _is_command(name):
|
||||
return " %s [cmd] %s" % [name.rpad(10), _COMMANDS[name]]
|
||||
var meta: Dictionary = DP.get_all()[name]
|
||||
return " %s %s [%s]" % [name.rpad(28), _fmt_val(name, meta).rpad(8), meta["section"]]
|
||||
|
||||
|
||||
func _suggestion_color(idx: int) -> Color:
|
||||
if idx == _selected_idx:
|
||||
return Color(1.0, 1.0, 0.4) # selected — yellow
|
||||
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 _fmt_num(meta["value"] as float)
|
||||
if meta["type"] == TYPE_BOOL:
|
||||
var bval: bool = meta["value"]
|
||||
return "true" if bval else "false"
|
||||
return str(meta["value"])
|
||||
|
||||
|
||||
# Render a float as a plain decimal — trailing zeros trimmed but always at least one
|
||||
# place, so it always reads as a float (4.0, 0.4, 0.005) never an int (4).
|
||||
func _fmt_num(v: float) -> String:
|
||||
var s := ("%.4f" % v).rstrip("0")
|
||||
if s.ends_with("."):
|
||||
s += "0"
|
||||
return s
|
||||
|
||||
|
||||
func _clear_suggestions() -> void:
|
||||
_suggestions.clear()
|
||||
_selected_idx = -1
|
||||
for lbl in _suggest_labels:
|
||||
lbl.visible = false
|
||||
_suggest_panel.visible = false
|
||||
|
||||
|
||||
# ↑/↓ move through the suggestion list while it's open; once it's gone they recall
|
||||
# command history instead. Same keys, context decides — no separate binding to learn.
|
||||
func _arrow(dir: int) -> void:
|
||||
if _suggest_panel.visible and not _suggestions.is_empty():
|
||||
_move_selection(dir)
|
||||
else:
|
||||
_history_step(-dir)
|
||||
|
||||
|
||||
# Highlight the next/prev suggestion and drop "<name> <value>" into the field with the
|
||||
# value pre-selected, so you just type your number over it.
|
||||
func _move_selection(dir: int) -> void:
|
||||
if _selected_idx < 0:
|
||||
_selected_idx = 0 if dir > 0 else _suggestions.size() - 1
|
||||
else:
|
||||
_selected_idx = wrapi(_selected_idx + dir, 0, _suggestions.size())
|
||||
_recolor_suggestions()
|
||||
_fill_selected()
|
||||
|
||||
|
||||
# Tab commits the highlighted suggestion (top match if none yet) and closes the list.
|
||||
func _accept_completion() -> void:
|
||||
if _suggestions.is_empty():
|
||||
return
|
||||
_selected_idx = maxi(_selected_idx, 0)
|
||||
_fill_selected()
|
||||
_clear_suggestions()
|
||||
|
||||
|
||||
func _fill_selected() -> void:
|
||||
var key := _suggestions[_selected_idx]
|
||||
# A command drops in as "verb " ready for its argument (or a bare Enter); a
|
||||
# param drops in as "key value" with the value pre-selected to type over.
|
||||
# Suggestions stay open either way so ↑↓ keeps cycling.
|
||||
if _is_command(key):
|
||||
_input_field.text = key + " "
|
||||
_input_field.caret_column = _input_field.text.length()
|
||||
return
|
||||
var meta: Dictionary = DP.get_all()[key]
|
||||
_input_field.text = "%s %s" % [key, _fmt_val(key, meta)]
|
||||
_input_field.caret_column = _input_field.text.length()
|
||||
_input_field.select(key.length() + 1)
|
||||
|
||||
|
||||
func _recolor_suggestions() -> void:
|
||||
for i in MAX_SUGGESTIONS:
|
||||
if _suggest_labels[i].visible:
|
||||
_suggest_labels[i].modulate = _suggestion_color(i)
|
||||
|
||||
|
||||
# ↑ recalls older commands, ↓ newer ones; index -1 is the live (in-progress) line,
|
||||
# which is stashed on the first ↑ and restored when you step back down to it.
|
||||
func _history_step(dir: int) -> void:
|
||||
if _history.is_empty():
|
||||
return
|
||||
var target := _history_idx + dir
|
||||
if target < -1 or target >= _history.size():
|
||||
return
|
||||
if _history_idx == -1 and dir > 0:
|
||||
_pending_line = _input_field.text
|
||||
_history_idx = target
|
||||
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
|
||||
|
||||
|
||||
# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
|
||||
# and resets history browsing so the next ↑ starts fresh.
|
||||
func _clear_input() -> void:
|
||||
_input_field.clear()
|
||||
_history_idx = -1
|
||||
_pending_line = ""
|
||||
_clear_suggestions()
|
||||
|
||||
|
||||
func _set_input(text: String) -> void:
|
||||
_input_field.text = text
|
||||
_input_field.caret_column = text.length()
|
||||
_clear_suggestions()
|
||||
|
||||
|
||||
# ── Command execution ─────────────────────────────────────────────────────────
|
||||
|
||||
func _on_submitted(raw: String) -> void:
|
||||
raw = raw.strip_edges()
|
||||
if raw.is_empty():
|
||||
return
|
||||
if _history.is_empty() or _history[0] != raw:
|
||||
_history.push_front(raw)
|
||||
_history_idx = -1
|
||||
_pending_line = ""
|
||||
_input_field.clear()
|
||||
_clear_suggestions()
|
||||
_execute(raw)
|
||||
|
||||
|
||||
func _execute(cmd: String) -> void:
|
||||
_log_raw("[color=#3a5a3a]> %s[/color]" % _esc(cmd))
|
||||
var parts := cmd.split(" ", false)
|
||||
if parts.is_empty():
|
||||
return
|
||||
|
||||
if _run_command(parts):
|
||||
return
|
||||
|
||||
# Not a command — treat as a param key, with fuzzy fall-back for partial names.
|
||||
var key := _resolve_key(parts[0])
|
||||
if key.is_empty():
|
||||
return
|
||||
var meta: Dictionary = DP.get_all()[key]
|
||||
if parts.size() == 1:
|
||||
_read_param(key, meta)
|
||||
else:
|
||||
_set_param(key, meta, parts[1])
|
||||
|
||||
|
||||
# Run a built-in command; returns false if parts[0] isn't one (caller falls through
|
||||
# to param handling). Kept separate from _execute to keep each dispatch shallow.
|
||||
func _run_command(parts: PackedStringArray) -> bool:
|
||||
match parts[0].to_lower():
|
||||
"save":
|
||||
DP.save()
|
||||
_log_ok("Saved.")
|
||||
"reset":
|
||||
_cmd_reset(parts)
|
||||
"toggle":
|
||||
_cmd_toggle(parts)
|
||||
"diff":
|
||||
_cmd_diff()
|
||||
"list":
|
||||
_cmd_list(parts)
|
||||
"level":
|
||||
_cmd_level(parts)
|
||||
"restart":
|
||||
_log_ok("Restarting…")
|
||||
get_tree().paused = false
|
||||
get_tree().reload_current_scene()
|
||||
"clear":
|
||||
_log.clear()
|
||||
"help":
|
||||
_cmd_help()
|
||||
_:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
# Resolve a user-typed token to a param key: exact match, else unique fuzzy match.
|
||||
# Logs an ambiguity / unknown warning (and an arrow on a fuzzy hit) and returns
|
||||
# "" when it can't narrow to a single key.
|
||||
func _resolve_key(raw: String) -> String:
|
||||
if DP.get_all().has(raw):
|
||||
return raw
|
||||
var matches := _fuzzy_find_all(raw)
|
||||
if matches.size() == 1:
|
||||
_log_raw("[color=#8888ff]→ %s[/color]" % matches[0])
|
||||
return matches[0]
|
||||
if matches.size() > 1:
|
||||
_log_warn("Ambiguous '%s': %s" % [_esc(raw), ", ".join(matches.slice(0, 6))])
|
||||
else:
|
||||
_log_warn("Unknown: %s" % _esc(raw))
|
||||
return ""
|
||||
|
||||
|
||||
func _cmd_reset(parts: PackedStringArray) -> void:
|
||||
if parts.size() == 1:
|
||||
DP.reset_all()
|
||||
_log_ok("All params reset to defaults.")
|
||||
return
|
||||
var key := _resolve_key(parts[1])
|
||||
if key.is_empty():
|
||||
return
|
||||
DP.reset(key)
|
||||
_log_ok("[b]%s[/b] reset to [color=#ffe080]%s[/color]" % [key, _fmt_val(key, DP.get_all()[key])])
|
||||
|
||||
|
||||
func _cmd_toggle(parts: PackedStringArray) -> void:
|
||||
if parts.size() < 2:
|
||||
_log_warn("Usage: toggle <bool param>")
|
||||
return
|
||||
var key := _resolve_key(parts[1])
|
||||
if key.is_empty():
|
||||
return
|
||||
var meta: Dictionary = DP.get_all()[key]
|
||||
if meta["type"] != TYPE_BOOL:
|
||||
_log_warn("%s is not a bool — use '%s <value>'" % [key, key])
|
||||
return
|
||||
var v := not (meta["value"] as bool)
|
||||
DP.set_value(key, v)
|
||||
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, "true" if v else "false"])
|
||||
|
||||
|
||||
# List only params that currently differ from their registered default, grouped by
|
||||
# section — the "what have I actually changed?" view before a save.
|
||||
func _cmd_diff() -> void:
|
||||
var all := DP.get_all()
|
||||
var sections: Dictionary = {}
|
||||
for key: String in all:
|
||||
if not DP.is_modified(key):
|
||||
continue
|
||||
var sec: String = all[key]["section"]
|
||||
if not sections.has(sec):
|
||||
sections[sec] = []
|
||||
sections[sec].append(key)
|
||||
if sections.is_empty():
|
||||
_log_ok("All params at defaults.")
|
||||
return
|
||||
for sec: String in sections:
|
||||
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
|
||||
for key: String in sections[sec]:
|
||||
var meta: Dictionary = all[key]
|
||||
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color] [color=#484848](def %s)[/color]" % [
|
||||
key, _fmt_val(key, meta), _fmt_default(meta)
|
||||
])
|
||||
|
||||
|
||||
func _cmd_help() -> void:
|
||||
_log_raw("[color=#888888] <name>[/color] — read current value + range")
|
||||
_log_raw("[color=#888888] <name> <value>[/color] — set it (number, or true/false)")
|
||||
for name: String in _COMMANDS:
|
||||
_log_raw("[color=#888888] %s[/color] — %s" % [name.rpad(20), _COMMANDS[name]])
|
||||
_log_raw(" [color=#555555]↑↓ pick suggestion · Tab complete · ↑↓ history when empty · ` close[/color]")
|
||||
|
||||
|
||||
func _fmt_default(meta: Dictionary) -> String:
|
||||
if meta["type"] == TYPE_FLOAT:
|
||||
return _fmt_num(meta["default"] as float)
|
||||
return "true" if (meta["default"] as bool) else "false"
|
||||
|
||||
|
||||
func _read_param(key: String, meta: Dictionary) -> void:
|
||||
if meta["type"] == TYPE_FLOAT:
|
||||
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](%s … %s)[/color]" % [
|
||||
key, _fmt_val(key, meta),
|
||||
_fmt_num(meta["min"] as float), _fmt_num(meta["max"] as float),
|
||||
])
|
||||
else:
|
||||
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](true / false)[/color]" % [
|
||||
key, _fmt_val(key, meta),
|
||||
])
|
||||
|
||||
|
||||
func _set_param(key: String, meta: Dictionary, raw: String) -> void:
|
||||
if meta["type"] == TYPE_FLOAT:
|
||||
var lo := meta["min"] as float
|
||||
var hi := meta["max"] as float
|
||||
if not raw.is_valid_float() or not is_finite(raw.to_float()):
|
||||
_log_warn("%s wants a number" % key)
|
||||
return
|
||||
var v := raw.to_float()
|
||||
DP.set_value(key, v)
|
||||
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
|
||||
elif meta["type"] == TYPE_BOOL:
|
||||
match raw.to_lower():
|
||||
"1", "true", "yes", "on":
|
||||
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("%s wants true or false" % key)
|
||||
else:
|
||||
_log_warn("Cannot set param of type %d" % meta["type"])
|
||||
|
||||
|
||||
func _cmd_level(parts: PackedStringArray) -> void:
|
||||
if parts.size() == 1:
|
||||
var cur := Run.active_level()
|
||||
var cur_name := (cur.id as String) if cur != null else "none"
|
||||
_log_ok("Current level: [b]%s[/b]" % cur_name)
|
||||
var ids: Array[StringName] = Run.level_ids()
|
||||
_log_raw("Available: " + " ".join(ids.map(func(id: StringName) -> String: return str(id))))
|
||||
return
|
||||
var id := StringName(parts[1].to_lower())
|
||||
var level := Run.get_level(id)
|
||||
if level == null:
|
||||
var known := " ".join(Run.level_ids().map(func(i: StringName) -> String: return str(i)))
|
||||
_log_warn("Unknown level '%s'. Try: %s" % [parts[1], known])
|
||||
return
|
||||
Run.debug_level_override = level
|
||||
_log_ok("Level set to [b]%s[/b] — reloading…" % str(id))
|
||||
get_tree().paused = false
|
||||
get_tree().change_scene_to_file(level.scene_path)
|
||||
|
||||
|
||||
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 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 _fuzzy_find_all(query: String) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var ql := query.to_lower()
|
||||
for key: String in DP.get_all():
|
||||
if _fuzzy_score(ql, key.to_lower()) > 0:
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
## Neutralise BBCode in user-supplied text before it reaches the RichTextLabel,
|
||||
## so malformed or hostile tags (e.g. [img]) render literally instead of running.
|
||||
func _esc(text: String) -> String:
|
||||
return text.replace("[", "[lb]")
|
||||
|
||||
|
||||
func _log_raw(text: String) -> void:
|
||||
if _log.get_line_count() > LOG_MAX_LINES:
|
||||
_log.clear()
|
||||
_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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://via815ow6p5r
|
||||
@@ -0,0 +1,537 @@
|
||||
extends Node
|
||||
## Runtime debug overlays (autoload "DebugDraw").
|
||||
##
|
||||
## Two mechanisms, both gated by DP "Debug" bool params so they cost nothing when off:
|
||||
## • Immediate-mode 3D lines — call DebugDraw.line()/ray() each frame; flushed once
|
||||
## per frame into a single ImmediateMesh. Used for raycasts and the bone overlay.
|
||||
## • Shape overlays — a MeshInstance3D attached to each CollisionShape3D, reconciled
|
||||
## incrementally so static geometry is never re-instanced and freshly spawned
|
||||
## bodies get picked up within one interval.
|
||||
##
|
||||
## Toggle from the console:
|
||||
## show_hitboxes · show_attacks · show_bones · show_raycasts (true / false)
|
||||
## show_hitboxes — the combatants' hurt volumes: each HitArea + the player/enemy body
|
||||
## capsules, drawn as calm translucent-blue volumes (what abilities/blades hit)
|
||||
## show_attacks — the attack hitboxes (bear claw/smash/leap, matador blade, thrown
|
||||
## spear), filled translucent red and shown ONLY while the swing is actually live
|
||||
## show_states — floating AI-state tags + velocity vectors over each matador
|
||||
## show_animation_name — floating current-clip tag over every AnimationPlayer host
|
||||
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
|
||||
## show_grid — 1 m reference grid on the ground plane around the origin
|
||||
## show_axes — RGB world-axis gizmo at the origin
|
||||
## show_wireframe — render the whole viewport in wireframe
|
||||
|
||||
const REBUILD_INTERVAL := 0.5
|
||||
const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame
|
||||
|
||||
const COLOR_AREA := Color(0.0, 0.9, 1.0) # combatant hurt volumes (cyan, translucent)
|
||||
const COLOR_ATTACK := Color(1.0, 0.30, 0.15) # attack hitboxes (red, translucent, overlaid)
|
||||
const COLOR_BONE := Color(1.0, 0.35, 0.9) # skeleton bones (magenta)
|
||||
const COLOR_VEL := Color(1.0, 0.85, 0.2) # matador velocity vectors (amber)
|
||||
const COLOR_ANIM := Color(0.55, 0.85, 1.0) # animation-name tags (sky blue)
|
||||
const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5)
|
||||
const AREA_ALPHA := 0.25
|
||||
const ATTACK_ALPHA := 0.32
|
||||
|
||||
const GRID_HALF := 20 # grid extends ±GRID_HALF cells from the origin
|
||||
const GRID_STEP := 1.0 # metres between grid lines
|
||||
const AXIS_LEN := 3.0 # length of each world-axis gizmo arm
|
||||
|
||||
var _line_mesh: ImmediateMesh
|
||||
var _lines: PackedVector3Array = PackedVector3Array()
|
||||
var _colors: PackedColorArray = PackedColorArray()
|
||||
|
||||
var _overlays: Dictionary = {} # CollisionShape3D instance id -> MeshInstance3D
|
||||
var _skeletons: Array[Skeleton3D] = []
|
||||
var _anim_players: Array[AnimationPlayer] = []
|
||||
var _reconcile_t: float = 0.0
|
||||
var _prev_flags: String = ""
|
||||
|
||||
var _state_labels: Dictionary = {} # matador instance id -> Label3D
|
||||
var _anim_labels: Dictionary = {} # AnimationPlayer instance id -> Label3D
|
||||
|
||||
var _stats_layer: CanvasLayer = null
|
||||
var _stats_label: Label = null
|
||||
var _stats_t: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS # keep drawing while the console pauses the game
|
||||
process_priority = 1000 # flush after everyone has queued their lines
|
||||
# One-time: wireframe debug-draw needs the wireframe index buffers generated.
|
||||
RenderingServer.set_debug_generate_wireframes(true)
|
||||
_line_mesh = ImmediateMesh.new()
|
||||
var inst := MeshInstance3D.new()
|
||||
inst.mesh = _line_mesh
|
||||
inst.material_override = _line_material()
|
||||
inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(inst)
|
||||
|
||||
|
||||
# ── Public immediate-mode API ──────────────────────────────────────────────────
|
||||
|
||||
func line(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
|
||||
_lines.append(from)
|
||||
_lines.append(to)
|
||||
_colors.append(color)
|
||||
_colors.append(color)
|
||||
|
||||
|
||||
func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
|
||||
line(from, to, color)
|
||||
|
||||
|
||||
# ── Frame flush ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_reconcile(delta)
|
||||
_pulse_attacks()
|
||||
if DP.b("show_bones"):
|
||||
_draw_bones()
|
||||
if DP.b("show_states"):
|
||||
_update_state_labels()
|
||||
elif not _state_labels.is_empty():
|
||||
_clear_state_labels()
|
||||
if DP.b("show_animation_name"):
|
||||
_update_anim_labels()
|
||||
elif not _anim_labels.is_empty():
|
||||
_clear_anim_labels()
|
||||
if DP.b("show_grid"):
|
||||
_draw_grid()
|
||||
if DP.b("show_axes"):
|
||||
_draw_axes()
|
||||
_update_wireframe()
|
||||
_update_stats(delta)
|
||||
_flush_lines()
|
||||
|
||||
|
||||
func _flush_lines() -> void:
|
||||
_line_mesh.clear_surfaces()
|
||||
if not _lines.is_empty():
|
||||
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
|
||||
for i in _lines.size():
|
||||
_line_mesh.surface_set_color(_colors[i])
|
||||
_line_mesh.surface_add_vertex(_lines[i])
|
||||
_line_mesh.surface_end()
|
||||
_lines.clear()
|
||||
_colors.clear()
|
||||
|
||||
|
||||
# ── Bone overlay ───────────────────────────────────────────────────────────────
|
||||
|
||||
func _draw_bones() -> void:
|
||||
for sk in _skeletons:
|
||||
if not is_instance_valid(sk):
|
||||
continue
|
||||
var gx := sk.global_transform
|
||||
for b in sk.get_bone_count():
|
||||
var parent := sk.get_bone_parent(b)
|
||||
if parent < 0:
|
||||
continue
|
||||
var a := gx * sk.get_bone_global_pose(parent).origin
|
||||
var c := gx * sk.get_bone_global_pose(b).origin
|
||||
line(a, c, COLOR_BONE)
|
||||
|
||||
|
||||
# ── Matador AI-state overlay ───────────────────────────────────────────────────
|
||||
# A billboarded state tag hovers over each matador, coloured by state, with an
|
||||
# amber velocity vector — reads the whole arena's AI at a glance while tuning combat.
|
||||
|
||||
func _update_state_labels() -> void:
|
||||
var seen: Dictionary = {}
|
||||
for node: Node in get_tree().get_nodes_in_group(&"matador"):
|
||||
var m := node as Node3D
|
||||
if m == null:
|
||||
continue
|
||||
var id := m.get_instance_id()
|
||||
seen[id] = true
|
||||
var lbl: Label3D = _state_labels.get(id)
|
||||
if lbl == null:
|
||||
lbl = _new_state_label()
|
||||
_state_labels[id] = lbl
|
||||
var state_name := "?"
|
||||
if m.has_method(&"ai_state_name"):
|
||||
state_name = m.call(&"ai_state_name")
|
||||
lbl.text = state_name
|
||||
lbl.modulate = _state_color(state_name)
|
||||
lbl.global_position = m.global_position + Vector3.UP * 2.2
|
||||
if m is CharacterBody3D:
|
||||
var v: Vector3 = (m as CharacterBody3D).velocity
|
||||
v.y = 0.0
|
||||
if v.length() > 0.5:
|
||||
var base := m.global_position + Vector3.UP * 0.15
|
||||
line(base, base + v * 0.18, COLOR_VEL)
|
||||
for id: int in _state_labels.keys():
|
||||
if not seen.has(id):
|
||||
var lbl: Label3D = _state_labels[id]
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_state_labels.erase(id)
|
||||
|
||||
|
||||
func _new_state_label() -> Label3D:
|
||||
var lbl := Label3D.new()
|
||||
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
lbl.no_depth_test = true
|
||||
lbl.fixed_size = true
|
||||
lbl.pixel_size = 0.0006
|
||||
lbl.font_size = 64
|
||||
lbl.outline_size = 12
|
||||
lbl.outline_modulate = Color(0.0, 0.0, 0.0, 0.9)
|
||||
add_child(lbl)
|
||||
return lbl
|
||||
|
||||
|
||||
func _clear_state_labels() -> void:
|
||||
for id: int in _state_labels:
|
||||
var lbl: Label3D = _state_labels[id]
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_state_labels.clear()
|
||||
|
||||
|
||||
const _STATE_COLORS := {
|
||||
"WANDER": Color(0.70, 0.70, 0.70),
|
||||
"FLEE": Color(0.40, 0.80, 1.00),
|
||||
"BRACE": Color(1.00, 0.90, 0.30),
|
||||
"SIDESTEP": Color(1.00, 0.60, 0.10),
|
||||
"ATTACK": Color(1.00, 0.30, 0.30),
|
||||
"ROLL": Color(0.60, 1.00, 0.60),
|
||||
"THROW": Color(1.00, 0.40, 0.90),
|
||||
"RAGDOLL": Color(0.40, 0.40, 0.40),
|
||||
}
|
||||
|
||||
|
||||
func _state_color(state_name: String) -> Color:
|
||||
return _STATE_COLORS.get(state_name, Color.WHITE)
|
||||
|
||||
|
||||
# ── Animation-name overlay ─────────────────────────────────────────────────────
|
||||
# A billboarded tag showing the clip each AnimationPlayer is currently playing,
|
||||
# hovering above its nearest Node3D host — catches wrong / stuck animation states
|
||||
# at a glance. Players are re-collected on the reconcile tick (see _collect).
|
||||
|
||||
func _update_anim_labels() -> void:
|
||||
var seen: Dictionary = {}
|
||||
for ap in _anim_players:
|
||||
if not is_instance_valid(ap):
|
||||
continue
|
||||
var clip := ap.current_animation
|
||||
# Skip finished / dormant sub-players so a host with several AnimationPlayers
|
||||
# (e.g. a matador's main + olé + death players) doesn't stack empty tags.
|
||||
if clip.is_empty() and not ap.is_playing():
|
||||
continue
|
||||
var host := _node3d_host(ap)
|
||||
if host == null:
|
||||
continue
|
||||
var id := ap.get_instance_id()
|
||||
seen[id] = true
|
||||
var lbl: Label3D = _anim_labels.get(id)
|
||||
if lbl == null:
|
||||
lbl = _new_state_label()
|
||||
lbl.modulate = COLOR_ANIM
|
||||
_anim_labels[id] = lbl
|
||||
lbl.text = clip if not clip.is_empty() else "—"
|
||||
lbl.global_position = host.global_position + Vector3.UP * 2.6
|
||||
for id: int in _anim_labels.keys():
|
||||
if not seen.has(id):
|
||||
var lbl: Label3D = _anim_labels[id]
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_anim_labels.erase(id)
|
||||
|
||||
|
||||
func _clear_anim_labels() -> void:
|
||||
for id: int in _anim_labels:
|
||||
var lbl: Label3D = _anim_labels[id]
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_anim_labels.clear()
|
||||
|
||||
|
||||
# Nearest Node3D at or above `node` — the anchor an AnimationPlayer's tag hovers over.
|
||||
func _node3d_host(node: Node) -> Node3D:
|
||||
var n := node
|
||||
while n != null:
|
||||
if n is Node3D:
|
||||
return n as Node3D
|
||||
n = n.get_parent()
|
||||
return null
|
||||
|
||||
|
||||
# ── World reference overlays ───────────────────────────────────────────────────
|
||||
|
||||
func _draw_grid() -> void:
|
||||
var extent := GRID_HALF * GRID_STEP
|
||||
for i in range(-GRID_HALF, GRID_HALF + 1):
|
||||
var o := i * GRID_STEP
|
||||
line(Vector3(o, 0.01, -extent), Vector3(o, 0.01, extent), COLOR_GRID)
|
||||
line(Vector3(-extent, 0.01, o), Vector3(extent, 0.01, o), COLOR_GRID)
|
||||
|
||||
|
||||
func _draw_axes() -> void:
|
||||
line(Vector3.ZERO, Vector3.RIGHT * AXIS_LEN, Color.RED) # +X
|
||||
line(Vector3.ZERO, Vector3.UP * AXIS_LEN, Color.GREEN) # +Y
|
||||
line(Vector3.ZERO, Vector3(0.0, 0.0, 1.0) * AXIS_LEN, Color.DODGER_BLUE) # +Z
|
||||
|
||||
|
||||
func _update_wireframe() -> void:
|
||||
var vp := get_viewport()
|
||||
var want := Viewport.DEBUG_DRAW_WIREFRAME if DP.b("show_wireframe") else Viewport.DEBUG_DRAW_DISABLED
|
||||
if vp.debug_draw != want:
|
||||
vp.debug_draw = want
|
||||
|
||||
|
||||
# ── Stats overlay ──────────────────────────────────────────────────────────────
|
||||
# A 2D corner readout of frame cost + scene load, plus a per-state matador tally so
|
||||
# a combat slowdown or a stuck AI swarm shows up immediately.
|
||||
|
||||
func _update_stats(delta: float) -> void:
|
||||
if not DP.b("show_stats"):
|
||||
if _stats_layer != null:
|
||||
_stats_layer.visible = false
|
||||
return
|
||||
_ensure_stats_ui()
|
||||
_stats_layer.visible = true
|
||||
_stats_t -= delta
|
||||
if _stats_t > 0.0:
|
||||
return
|
||||
_stats_t = STATS_INTERVAL
|
||||
_stats_label.text = _compose_stats()
|
||||
|
||||
|
||||
func _ensure_stats_ui() -> void:
|
||||
if _stats_layer != null:
|
||||
return
|
||||
var mono := SystemFont.new()
|
||||
mono.font_names = PackedStringArray(["JetBrains Mono", "DejaVu Sans Mono", "monospace"])
|
||||
|
||||
_stats_layer = CanvasLayer.new()
|
||||
_stats_layer.layer = 64
|
||||
add_child(_stats_layer)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.position = Vector2(8.0, 8.0)
|
||||
var bg := StyleBoxFlat.new()
|
||||
bg.bg_color = Color(0.0, 0.0, 0.0, 0.55)
|
||||
bg.set_content_margin_all(6.0)
|
||||
panel.add_theme_stylebox_override("panel", bg)
|
||||
_stats_layer.add_child(panel)
|
||||
|
||||
_stats_label = Label.new()
|
||||
_stats_label.add_theme_font_override("font", mono)
|
||||
_stats_label.add_theme_font_size_override("font_size", 13)
|
||||
_stats_label.add_theme_color_override("font_color", Color(0.60, 1.0, 0.60))
|
||||
panel.add_child(_stats_label)
|
||||
|
||||
|
||||
func _compose_stats() -> String:
|
||||
var fps := int(Performance.get_monitor(Performance.TIME_FPS))
|
||||
var proc := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||
var phys := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||
var draws := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||||
var prims := int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
|
||||
var vmem := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0)
|
||||
var nodes := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
var phys3d := int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
|
||||
return "fps %d proc %.1fms phys %.1fms\ndraws %d prims %s vmem %dMB\nnodes %d phys3d %d\n%s" % [
|
||||
fps, proc, phys, draws, _si(prims), vmem, nodes, phys3d, _matador_tally()]
|
||||
|
||||
|
||||
func _matador_tally() -> String:
|
||||
var mats := get_tree().get_nodes_in_group(&"matador")
|
||||
if mats.is_empty():
|
||||
return "matadors 0"
|
||||
var counts: Dictionary = {}
|
||||
for node: Node in mats:
|
||||
var s := "?"
|
||||
if node.has_method(&"ai_state_name"):
|
||||
s = node.call(&"ai_state_name")
|
||||
counts[s] = int(counts.get(s, 0)) + 1
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for s: String in counts:
|
||||
parts.append("%s:%d" % [s.substr(0, 2), counts[s]])
|
||||
return "matadors %d %s" % [mats.size(), " ".join(parts)]
|
||||
|
||||
|
||||
func _si(n: int) -> String:
|
||||
if n >= 1000000:
|
||||
return "%.1fM" % (n / 1000000.0)
|
||||
if n >= 1000:
|
||||
return "%.0fk" % (n / 1000.0)
|
||||
return str(n)
|
||||
|
||||
|
||||
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
|
||||
|
||||
func _reconcile(delta: float) -> void:
|
||||
var hit := DP.b("show_hitboxes")
|
||||
var atk := DP.b("show_attacks")
|
||||
var bone := DP.b("show_bones")
|
||||
var anim := DP.b("show_animation_name")
|
||||
if not (hit or atk or bone or anim):
|
||||
if not _overlays.is_empty():
|
||||
_clear_overlays()
|
||||
_skeletons.clear()
|
||||
_anim_players.clear()
|
||||
_prev_flags = ""
|
||||
return
|
||||
|
||||
var flags := "%d%d%d%d" % [int(hit), int(atk), int(bone), int(anim)]
|
||||
_reconcile_t -= delta
|
||||
if flags != _prev_flags: # a toggle just changed — refresh immediately
|
||||
_prev_flags = flags
|
||||
_reconcile_t = 0.0
|
||||
if _reconcile_t > 0.0:
|
||||
return
|
||||
_reconcile_t = REBUILD_INTERVAL
|
||||
|
||||
_skeletons.clear()
|
||||
_anim_players.clear()
|
||||
var wanted: Dictionary = {}
|
||||
var scene := get_tree().current_scene
|
||||
if scene != null:
|
||||
_collect(scene, hit, atk, bone, anim, wanted)
|
||||
|
||||
# Drop overlays whose shape is gone or no longer wanted. The overlay is a child of
|
||||
# the collision shape, so it's freed with it — hold it as Variant, since assigning a
|
||||
# freed instance to any *typed* var (even Object) throws before is_instance_valid()
|
||||
# can filter it out.
|
||||
for id: int in _overlays.keys():
|
||||
var mi: Variant = _overlays[id]
|
||||
if not wanted.has(id) or not is_instance_valid(mi):
|
||||
if is_instance_valid(mi):
|
||||
(mi as Node).queue_free()
|
||||
_overlays.erase(id)
|
||||
# Add overlays for newly seen shapes.
|
||||
for id: int in wanted:
|
||||
if not _overlays.has(id):
|
||||
var entry: Array = wanted[id]
|
||||
_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as String)
|
||||
|
||||
|
||||
func _collect(node: Node, hit: bool, atk: bool, bone: bool, anim: bool,
|
||||
wanted: Dictionary) -> void:
|
||||
# The audience ("public") is ambient decoration, not a debug target — skip the whole
|
||||
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
|
||||
if node.is_in_group(&"public"):
|
||||
return
|
||||
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
|
||||
_skeletons.append(node as Skeleton3D)
|
||||
if anim and node is AnimationPlayer:
|
||||
_anim_players.append(node as AnimationPlayer)
|
||||
if node is CollisionShape3D:
|
||||
var cs := node as CollisionShape3D
|
||||
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
|
||||
var kind := _classify(cs.get_parent())
|
||||
var show := (kind == "attack" and atk) or (kind == "hitbox" and hit)
|
||||
if show:
|
||||
wanted[cs.get_instance_id()] = [cs, kind]
|
||||
for child in node.get_children():
|
||||
_collect(child, hit, atk, bone, anim, wanted)
|
||||
|
||||
|
||||
# Sort a shape's owning body/area into an overlay bucket, or "" to skip.
|
||||
# attack — a swing volume (a Hitbox area, or a thrown-spear projectile body)
|
||||
# hitbox — a combatant's hurt volume: its HitArea, or its own body capsule
|
||||
func _classify(parent: Node) -> String:
|
||||
if parent is Hitbox or parent is SpearProjectile:
|
||||
return "attack"
|
||||
if parent is Area3D:
|
||||
return "hitbox"
|
||||
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for the
|
||||
# skeleton; otherwise every corpse floods the view with capsules.
|
||||
if parent is PhysicsBody3D and not (parent is PhysicalBone3D):
|
||||
if parent.is_in_group(&"player") or parent.is_in_group(&"matador"):
|
||||
return "hitbox"
|
||||
return ""
|
||||
|
||||
|
||||
# Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena
|
||||
# geometry is fine — Godot caches each shape's debug mesh, so it's built at most once.
|
||||
func _drawable(shape: Shape3D) -> bool:
|
||||
return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D)
|
||||
|
||||
|
||||
func _attach(cs: CollisionShape3D, kind: String) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var solid := _solid_mesh(cs.shape)
|
||||
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
|
||||
if kind == "attack":
|
||||
# Overlaid so a brief swing volume reads clearly on top of the action; its
|
||||
# visibility is pulsed per-frame in _pulse_attacks so it only shows while live.
|
||||
mi.material_override = _translucent_material(COLOR_ATTACK, ATTACK_ALPHA, true)
|
||||
else:
|
||||
# The calm translucent-blue volume — depth-tested so it sits in the world.
|
||||
mi.material_override = _translucent_material(COLOR_AREA, AREA_ALPHA, false)
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
cs.add_child(mi)
|
||||
return mi
|
||||
|
||||
|
||||
# Attack overlays exist for as long as show_attacks is on, but a swing hitbox is only a
|
||||
# hazard while its Hitbox is monitoring — so show each attack volume only when it's live.
|
||||
# (A thrown spear has no monitoring gate; it stays visible for its whole flight.)
|
||||
func _pulse_attacks() -> void:
|
||||
for id: int in _overlays:
|
||||
var mi: Variant = _overlays[id]
|
||||
if not is_instance_valid(mi):
|
||||
continue
|
||||
var owner := (mi as Node).get_parent().get_parent()
|
||||
if owner is Hitbox:
|
||||
(mi as MeshInstance3D).visible = (owner as Area3D).monitoring
|
||||
|
||||
|
||||
func _clear_overlays() -> void:
|
||||
for id: int in _overlays:
|
||||
var mi: Variant = _overlays[id]
|
||||
if is_instance_valid(mi):
|
||||
(mi as Node).queue_free()
|
||||
_overlays.clear()
|
||||
|
||||
|
||||
# ── Meshes & materials ─────────────────────────────────────────────────────────
|
||||
|
||||
# A filled mesh matching a primitive shape, for translucent volume overlays. Returns
|
||||
# null for shapes without a clean solid equivalent (caller falls back to wireframe).
|
||||
func _solid_mesh(shape: Shape3D) -> Mesh:
|
||||
if shape is BoxShape3D:
|
||||
var m := BoxMesh.new()
|
||||
m.size = (shape as BoxShape3D).size
|
||||
return m
|
||||
if shape is SphereShape3D:
|
||||
var m := SphereMesh.new()
|
||||
m.radius = (shape as SphereShape3D).radius
|
||||
m.height = m.radius * 2.0
|
||||
return m
|
||||
if shape is CapsuleShape3D:
|
||||
var m := CapsuleMesh.new()
|
||||
m.radius = (shape as CapsuleShape3D).radius
|
||||
m.height = (shape as CapsuleShape3D).height
|
||||
return m
|
||||
if shape is CylinderShape3D:
|
||||
var m := CylinderMesh.new()
|
||||
m.top_radius = (shape as CylinderShape3D).radius
|
||||
m.bottom_radius = (shape as CylinderShape3D).radius
|
||||
m.height = (shape as CylinderShape3D).height
|
||||
return m
|
||||
return null
|
||||
|
||||
|
||||
func _line_material() -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
mat.no_depth_test = true
|
||||
return mat
|
||||
|
||||
|
||||
func _translucent_material(color: Color, alpha: float, overlay: bool) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = Color(color.r, color.g, color.b, alpha)
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||
mat.no_depth_test = overlay
|
||||
return mat
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgty4al6aqey3
|
||||
@@ -0,0 +1,558 @@
|
||||
extends Node
|
||||
## Central registry for all runtime-tunable parameters.
|
||||
## Add a new param with one _reg_f() call in _register_all() — the menu picks it up automatically.
|
||||
## Access: DP.f("key") • Persist: DP.save() / DP.load_saved() • Reset: DP.reset_all()
|
||||
|
||||
signal any_changed(key: String, value: Variant)
|
||||
|
||||
const SAVE_PATH := "user://debug_params.cfg"
|
||||
|
||||
# _params holds the full metadata (min/max/step/default/section/type) and is the
|
||||
# authority the debug menu reads. _values is a flat key→value mirror so the hot
|
||||
# f()/b() reads — called dozens of times per matador per physics frame — cost a
|
||||
# single hash lookup instead of a nested dict access. Every write updates both.
|
||||
var _params: Dictionary = {}
|
||||
var _values: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_register_all()
|
||||
|
||||
|
||||
func _register_all() -> void:
|
||||
#Gamepad vibration
|
||||
_reg_b("Gamepad", "gamepad_vibration", true)
|
||||
#Level Unlocking
|
||||
_reg_b("Gate", "gate_unlocked_bear", false)
|
||||
# ── Movement ──────────────────────────────────────────────────────────────
|
||||
# WASD/arrows drive the bull: move_accel ramps flat velocity toward max_speed in
|
||||
# the input direction; move_friction bleeds it off when no direction is held.
|
||||
_reg_f("Movement", "max_speed", 10.0, 2.0, 1000.0)
|
||||
_reg_f("Movement", "move_accel", 120.0, 5.0, 600.0)
|
||||
_reg_f("Movement", "move_friction", 90.0, 5.0, 600.0)
|
||||
_reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0)
|
||||
_reg_f("Movement", "visual_turn_speed", 12.0, 1.0, 200.0)
|
||||
_reg_f("Movement", "bounce_restitution", 0.55, 0.0, 2.0, 0.05)
|
||||
_reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0)
|
||||
_reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05)
|
||||
_reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0)
|
||||
# ── Bull health ───────────────────────────────────────────────────────────
|
||||
# The bull soaks bull_max_hp clean hits before it dies; each hit grants
|
||||
# bull_hit_iframes seconds of invulnerability so a single pass can't drain it.
|
||||
_reg_f("Bull", "bull_max_hp", 10.0, 1.0, 100.0, 1.0)
|
||||
_reg_f("Bull", "bull_hit_iframes", 0.8, 0.0, 3.0, 0.1)
|
||||
# Half-angle (deg) of the bull's horns-first cone: running into an enemy only wounds it when the
|
||||
# bull's velocity points within this of the enemy (not a sideways brush). 90 = any contact at speed.
|
||||
# (The dash ability bypasses this.)
|
||||
_reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0)
|
||||
# ── Dust speed thresholds ─────────────────────────────────────────────────
|
||||
# The dust LOOK (colour, size, velocity) lives in systems/fx/bull_hoof_dust.tscn now; these
|
||||
# two only decide when walk vs charge dust kicks in by ground speed.
|
||||
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
|
||||
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
|
||||
# ── Tail ──────────────────────────────────────────────────────────────────
|
||||
_reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0)
|
||||
_reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01)
|
||||
_reg_f("Tail", "tail_body_radius", 0.35, 0.05, 2.0, 0.05)
|
||||
_reg_f("Tail", "tail_wag_freq", 4.0, 0.5, 15.0)
|
||||
_reg_f("Tail", "tail_wag_idle", 0.3, 0.0, 2.0, 0.05)
|
||||
# ── Charge pose (horns-down lean during dash / roll) ─────────────────────
|
||||
_reg_f("Charge", "charge_head_pitch", 0.3, 0.0, 1.2, 0.01)
|
||||
_reg_f("Charge", "charge_head_speed", 5.0, 0.5, 20.0)
|
||||
# ── Body ──────────────────────────────────────────────────────────────────
|
||||
_reg_f("Body", "body_tilt_fwd", 0.015, 0.0, 0.1, 0.001)
|
||||
_reg_f("Body", "body_tilt_side", 0.01, 0.0, 0.1, 0.001)
|
||||
_reg_f("Body", "aerial_pitch_scale", 0.6, 0.0, 2.0)
|
||||
_reg_f("Body", "aerial_pitch_speed", 5.0, 0.5, 20.0)
|
||||
# ── Legs ──────────────────────────────────────────────────────────────────
|
||||
_reg_f("Legs", "leg_step_threshold", 0.15, 0.01, 1.0, 0.01)
|
||||
_reg_f("Legs", "leg_step_duration", 0.12, 0.02, 0.5, 0.01)
|
||||
_reg_f("Legs", "leg_step_height", 0.15, 0.0, 0.5)
|
||||
_reg_f("Legs", "leg_step_overshoot", 0.3, 0.0, 1.0)
|
||||
_reg_f("Legs", "gait_freq", 1.5, 0.1, 5.0)
|
||||
_reg_f("Legs", "launch_hold_time", 0.15, 0.0, 1.0, 0.01)
|
||||
_reg_f("Legs", "ragdoll_wobble_amp", 0.15, 0.0, 1.0)
|
||||
_reg_f("Legs", "tuck_apex_threshold", 0.5, -5.0, 5.0)
|
||||
_reg_f("Legs", "jump_tuck_speed", 8.0, 1.0, 30.0)
|
||||
_reg_f("Legs", "jump_tuck_up", 0.2, 0.0, 1.0)
|
||||
_reg_f("Legs", "jump_tuck_fwd", -0.1, -0.5, 0.5)
|
||||
_reg_f("Legs", "jump_land_threshold", 1.5, 0.5, 5.0)
|
||||
# ── Camera ────────────────────────────────────────────────────────────────
|
||||
_reg_f("Camera", "charge_zoom_factor", 0.65, 0.3, 1.0, 0.05)
|
||||
_reg_f("Camera", "cam_lerp_power", 6.0, 0.5, 30.0)
|
||||
_reg_f("Camera", "cam_sensitivity", 0.005, 0.001, 0.05, 0.001)
|
||||
_reg_f("Camera", "cam_min_zoom", 3.5, 0.5, 20.0)
|
||||
_reg_f("Camera", "cam_max_zoom", 40.0, 2.0, 80.0)
|
||||
_reg_f("Camera", "cam_spring_length", 7.0, 1.0, 30.0)
|
||||
_reg_f("Camera", "cam_min_vert_angle", -60.0, -90.0, 0.0, 1.0)
|
||||
_reg_f("Camera", "cam_max_vert_angle", 45.0, 0.0, 90.0, 1.0)
|
||||
# Trailing "drag" camera: the focus point lags the bull and slides opposite to
|
||||
# its velocity, so the camera visibly drags behind during a fast charge.
|
||||
# cam_drag = metres of trail per m/s of bull speed (0 = rigid follow)
|
||||
# cam_drag_speed = how fast the trail offset eases toward its target
|
||||
# cam_follow_lerp = how tightly the focus point chases the bull (higher = snappier)
|
||||
_reg_f("Camera", "cam_drag", 0.12, 0.0, 1.0, 0.01)
|
||||
_reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0)
|
||||
_reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0)
|
||||
# ── Matador ───────────────────────────────────────────────────────────────
|
||||
# Wave system: matadors enter the arena in escalating waves (1 → 2 → … → N where N
|
||||
# is the number of placed spawn points). mat_spawn_stagger is the delay between each
|
||||
# matador leaping in within a wave; mat_wave_pause is the breather after a wave clears.
|
||||
_reg_f("Matador", "mat_wave_size", 8.0, 1, 50)
|
||||
_reg_f("Matador", "mat_spawn_stagger", 0.4, 0.0, 3.0, 0.05)
|
||||
_reg_f("Matador", "mat_wave_pause", 2.0, 0.0, 8.0, 0.25)
|
||||
_reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0)
|
||||
_reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0)
|
||||
# Radius of the inner fighting floor: bull + matadors spawn on this ring (so
|
||||
# they stay off the benches). Tune in the console to hug the bench edge.
|
||||
_reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5)
|
||||
_reg_f("Matador", "mat_idle_min", 1.2, 0.0, 5.0, 0.1)
|
||||
_reg_f("Matador", "mat_idle_max", 4.0, 0.5, 10.0, 0.1)
|
||||
_reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0)
|
||||
# Planar reach at which the bull's swept charge path gores a matador (~bull + HitArea radii).
|
||||
# Raise for a more forgiving charge, lower for a more precise one.
|
||||
_reg_f("Matador", "mat_charge_gore_radius", 1.2, 0.5, 3.0, 0.05)
|
||||
_reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0)
|
||||
# Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its
|
||||
# shape; higher angular damp settles the flail instead of flopping flat.
|
||||
_reg_f("Matador", "mat_joint_swing", 35.0, 0.0, 180.0, 1.0)
|
||||
_reg_f("Matador", "mat_joint_twist", 40.0, 0.0, 180.0, 1.0)
|
||||
_reg_f("Matador", "mat_ragdoll_damp", 1.5, 0.0, 10.0, 0.1)
|
||||
_reg_f("Matador", "mat_flee_range", 3.5, 2.0, 40.0)
|
||||
_reg_f("Matador", "mat_flee_speed", 7.0, 0.5, 12.0)
|
||||
_reg_f("Matador", "mat_charge_speed", 10.0, 1.0, 30.0)
|
||||
_reg_f("Matador", "mat_brace_range", 5.0, 2.0, 20.0)
|
||||
_reg_f("Matador", "mat_brace_duration", 0.7, 0.05, 1.5, 0.05)
|
||||
_reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1)
|
||||
_reg_f("Matador", "mat_step_speed", 6.5, 2.0, 20.0)
|
||||
_reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05)
|
||||
# How strongly the sidestep biases toward the bull so the blade sweeps through
|
||||
# its path during the pass (0 = pure lateral dodge, 1 = straight at the bull).
|
||||
_reg_f("Matador", "mat_pass_lunge", 0.35, 0.0, 1.0, 0.05)
|
||||
_reg_f("Matador", "mat_dodge_cooldown", 2.5, 0.5, 12.0, 0.25)
|
||||
_reg_f("Matador", "mat_dodge_chance", 0.72, 0.0, 1.0, 0.05)
|
||||
_reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0)
|
||||
_reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0)
|
||||
_reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1)
|
||||
# Reach of the reach-based melee hit and its re-strike gap. This now governs ONLY the sidestep
|
||||
# PASS (mat charges the bull past with the blade out): a fast lateral sweep where the thin blade
|
||||
# Area would tunnel, so a center-to-center distance check is the reliable gore. The stationary
|
||||
# ATTACK stab no longer uses this — it drives off the sword's BladeHitbox collider (see below).
|
||||
_reg_f("Matador", "mat_stab_reach", 2.0, 0.5, 4.0, 0.1)
|
||||
# Distance the matador plants at to swing. The ATTACK stab damages off the animated BladeHitbox,
|
||||
# whose thrust reaches ~3.75 m at full extension — so this MUST stay under that or the blade never
|
||||
# touches the bull and the stab whiffs. 3.5 m keeps the matador clear of the bull's ~1.8 m-long
|
||||
# body while the thrust still lands. Raise toward 3.75 for more gap, lower to stab from closer.
|
||||
_reg_f("Matador", "mat_stab_standoff", 3.5, 0.5, 4.0, 0.1)
|
||||
# Fraction through the Attack clip at which the blade's collider is armed. The clip is guard →
|
||||
# wind-up (blade pulled back, phase ~0.3-0.5) → thrust (blade drives out to ~3.5 m, phase ~0.55+).
|
||||
# Arming at the thrust keeps the bull from being gored during the wind-up (blade not yet extended)
|
||||
# or the guard pose. The BladeHitbox overlap still decides the exact hit; this just gates timing.
|
||||
_reg_f("Matador", "mat_stab_strike_at", 0.5, 0.0, 1.0, 0.05)
|
||||
# Chance, each time a stab finishes, that the matador breaks off into something else (give ground,
|
||||
# circle, or throw) instead of immediately stabbing again — keeps it from drilling the same thrust
|
||||
# on repeat. 0 = always re-stab if in range, 1 = always break off after every stab.
|
||||
_reg_f("Matador", "mat_stab_followup_chance", 0.6, 0.0, 1.0, 0.05)
|
||||
# Beat after every stab where the brain won't re-commit to ATTACK, so it can't machine-gun the
|
||||
# bull — it circles/gives ground instead. 0 = stab as fast as range allows.
|
||||
_reg_f("Matador", "mat_stab_recovery", 0.9, 0.0, 3.0, 0.1)
|
||||
# Chance, when a stab actually CONNECTS, that the matador breaks into a crowd-pleasing taunt and
|
||||
# gives ground instead of pressing — milking the hit rather than spamming. High by design.
|
||||
_reg_f("Matador", "mat_taunt_after_hit", 0.85, 0.0, 1.0, 0.05)
|
||||
_reg_f("Matador", "mat_stab_cooldown", 0.6, 0.1, 3.0, 0.1)
|
||||
# Half-angle (deg) the matador must be facing the bull within for a stab to land.
|
||||
# Stops "sword's off to the side but I still died" — you're only gored when the
|
||||
# blade is actually presented at you.
|
||||
_reg_f("Matador", "mat_stab_arc", 55.0, 5.0, 180.0, 5.0)
|
||||
# Chance a matador rolls clear (survives) instead of ragdolling when the bull's charge connects.
|
||||
_reg_f("Matador", "mat_roll_chance", 0.8, 0.0, 1.0, 0.05)
|
||||
# How much a committed charge pierces the survival roll: roll chance fades toward
|
||||
# mat_roll_chance*(1-this) as bull speed climbs to mat_charge_speed. 1 = full-speed charge always
|
||||
# gores. (A dash connect ignores the roll entirely.)
|
||||
_reg_f("Matador", "mat_charge_pierce", 0.8, 0.0, 1.0, 0.05)
|
||||
_reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05)
|
||||
# Playback rate of the Draw_weapon clip used for both drawing and sheathing —
|
||||
# higher = snappier unholster/holster.
|
||||
_reg_f("Matador", "mat_draw_speed", 1.9, 0.25, 3.0, 0.05)
|
||||
# Cross-fade between matador animation clips — smooths Run/Attack/Taunt cuts.
|
||||
_reg_f("Matador", "mat_anim_blend", 0.12, 0.0, 0.5, 0.01)
|
||||
# ── Matador heuristics (utility AI) ───────────────────────────────────────
|
||||
# Every mat_decide_interval seconds the matador scores its options — dodge the
|
||||
# pass, close for a strike, retreat, throw the blade, or strut/taunt — and picks
|
||||
# the highest. These weights bias the personality: crank aggression for a reckless
|
||||
# bullfighter, caution for a slippery one. mat_w_commit is a stickiness bonus for
|
||||
# the current choice so it doesn't dither between two near-tied options.
|
||||
_reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05)
|
||||
_reg_f("Matador", "mat_w_aggression", 1.1, 0.0, 3.0, 0.05)
|
||||
_reg_f("Matador", "mat_w_caution", 1.0, 0.0, 3.0, 0.05)
|
||||
_reg_f("Matador", "mat_w_flee", 0.8, 0.0, 3.0, 0.05)
|
||||
# Showmanship (strut, circle the bull, taunt) — the supporting matadors lean on
|
||||
# this to spread out and give the lead room, so a whole crowd reads as a bullring,
|
||||
# not a swarm. Crank it for more posturing, drop it for a relentless mob.
|
||||
_reg_f("Matador", "mat_w_showmanship", 1.6, 0.0, 3.0, 0.05)
|
||||
# Weight on hurling the blade. Kept low — a thrown sword is an occasional flourish,
|
||||
# not a barrage; raising this brings back the pelting.
|
||||
_reg_f("Matador", "mat_w_throw", 0.35, 0.0, 3.0, 0.05)
|
||||
# How many matadors press the bull at once. The nearest this-many engage (close,
|
||||
# throw); everyone else keeps their distance and taunts, taking turns as the bull
|
||||
# moves. 1 = strict one-on-one, higher = a bolder pack.
|
||||
_reg_f("Matador", "mat_engage_slots", 2.0, 1.0, 8.0, 1.0)
|
||||
# Preferred distance (m) a taunting matador circles the bull at while waiting its
|
||||
# turn — the showmanship ring the crowd spreads out onto.
|
||||
_reg_f("Matador", "mat_taunt_ring", 7.0, 3.0, 15.0, 0.5)
|
||||
# How long (s) a matador safely out of the action holds a taunt before drifting to
|
||||
# a new spot — much longer than the between-passes idle, so bystanders mostly pose
|
||||
# for the crowd instead of milling about. Actual hold is randomised ±30%.
|
||||
_reg_f("Matador", "mat_taunt_hold", 8.0, 1.0, 20.0, 0.5)
|
||||
# Small so it smooths dithering without locking the matador into idling.
|
||||
_reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05)
|
||||
# ── Bear (boss) ─────────────────────────────────────────────────────────────
|
||||
# A Dark-Souls-style boss with a learnable moveset. It soaks bear_max_hp clean hits
|
||||
# (ram or ability), staggering on each, and only topples when worn down. From NEUTRAL
|
||||
# it weighs its options by the range band to the bull and commits to one telegraphed
|
||||
# routine (wind-up → active → recovery); each routine plays to the end (the recovery
|
||||
# is the player's punish window). Below bear_enrage_frac HP it enters phase 2: faster
|
||||
# tells and recovery (× bear_enrage_scale) and longer combo chains.
|
||||
_reg_f("Bear", "bear_max_hp", 30.0, 1.0, 100.0, 1.0)
|
||||
# HP fraction at which phase 2 ("Fury") triggers, and how much it multiplies every
|
||||
# wind-up / recovery timer (lower = snappier, more dangerous).
|
||||
_reg_f("Bear", "bear_enrage_frac", 0.4, 0.0, 1.0, 0.05)
|
||||
_reg_f("Bear", "bear_enrage_scale", 0.7, 0.3, 1.0, 0.05)
|
||||
# Range bands (m) that pick the moveset: ≤close it strikes (swipe / smash), ≤mid it
|
||||
# walks in to close the gap (stalk) or leaps, beyond it lopes in (prowl) or leaps.
|
||||
# close is deliberately generous — the bear commits to melee "from a bit further" and
|
||||
# steps in during the swing (see bear_lunge_speed) rather than needing to be on top.
|
||||
_reg_f("Bear", "bear_close_range", 5.0, 1.0, 12.0, 0.1)
|
||||
_reg_f("Bear", "bear_mid_range", 10.0, 3.0, 20.0, 0.5)
|
||||
_reg_f("Bear", "bear_long_range", 15.0, 3.0, 100.0, 0.5)
|
||||
# Beat spent in NEUTRAL sizing up the bull between routines — kept short so the bear
|
||||
# flows straight from one action into the next instead of pausing (raise for a more
|
||||
# hesitant, readable boss; near-0 for relentless pressure). Shrinks again in phase 2.
|
||||
_reg_f("Bear", "bear_think_time", 0.06, 0.0, 1.0, 0.01)
|
||||
# Locomotion: the all-fours prowl (close a big gap), the menacing 2-leg walk-in
|
||||
# (stalk), the facing rate while positioning/winding up, and the softer tracking rate
|
||||
# a swing keeps on the bull mid-attack so it's harder to just walk out of.
|
||||
_reg_f("Bear", "bear_run_speed", 11.0, 1.0, 20.0, 0.1)
|
||||
_reg_f("Bear", "bear_stalk_speed", 5.5, 0.5, 12.0, 0.1)
|
||||
_reg_f("Bear", "bear_turn_rate", 7.0, 1.0, 20.0, 0.5)
|
||||
_reg_f("Bear", "bear_track_active", 3.5, 0.0, 12.0, 0.1)
|
||||
# Forward step-in during a swing so a strike lands even when the bull is a touch out of
|
||||
# reach (also carries the tracking); 0 = a purely stationary swing.
|
||||
_reg_f("Bear", "bear_lunge_speed", 7.0, 0.0, 16.0, 0.5)
|
||||
# Playback multiplier for the swipe/smash clips — >1 makes the punches snappier.
|
||||
_reg_f("Bear", "bear_attack_anim_speed", 1.35, 0.5, 3.0, 0.05)
|
||||
# Half-arc (deg) the bear must be facing the bull within for a directional swing to
|
||||
# connect; slams use their own radius instead of an arc.
|
||||
_reg_f("Bear", "bear_hit_arc", 70.0, 5.0, 180.0, 5.0)
|
||||
# Double Swipe (ONE_TWO_HIT) — bread-and-butter close combo, two quick hits.
|
||||
_reg_f("Bear", "bear_swipe_windup", 0.3, 0.05, 2.0, 0.05)
|
||||
_reg_f("Bear", "bear_swipe_recover", 0.35, 0.05, 3.0, 0.05)
|
||||
_reg_f("Bear", "bear_swipe_reach", 4.2, 1.0, 8.0, 0.1)
|
||||
# Which claw-slash FX plays on the first vs second swipe hit — the L/R clips are easy to
|
||||
# mix up, so 0 = first hit L then R, 1 = swap them. Purely cosmetic (no gameplay effect).
|
||||
_reg_f("Bear", "bear_fx_claw_swap", 0.0, 0.0, 1.0, 1.0)
|
||||
# Live yaw (deg) applied to the whole Bear_FX node before each effect fires, so the claw
|
||||
# rake / debris can be spun to line up with the bear's facing by eye. Bake the good value.
|
||||
_reg_f("Bear", "bear_fx_yaw", 0.0, -180.0, 180.0, 5.0)
|
||||
# The baked crater clip only pops up (no shrink), so hold it on the ground this long after a
|
||||
# leap slam before hiding it — otherwise it flashes for a single frame and reads as invisible.
|
||||
_reg_f("Bear", "bear_fx_crater_linger", 0.8, 0.0, 3.0, 0.1)
|
||||
# Overhead Smash (SMASH_HIT) — slower tell, biggest punish window, AoE on impact.
|
||||
_reg_f("Bear", "bear_smash_windup", 0.6, 0.1, 3.0, 0.05)
|
||||
_reg_f("Bear", "bear_smash_recover", 0.75, 0.1, 4.0, 0.05)
|
||||
# The overhead smash's hitbox is a forward box lane whose size + travel are authored in
|
||||
# BearAttackHitboxes.tscn's AnimationPlayer ("smash_travel"). Here we only set the upward
|
||||
# pop the bull takes off its feet at the far end (it ramps 0→this with hit distance).
|
||||
_reg_f("Bear", "bear_smash_knockup", 6.0, 0.0, 16.0, 0.5)
|
||||
# Leap Slam (JUMP_SMASH, 47 frames @30fps) — timed to the clip by FRAME: it plays the
|
||||
# crouch grounded, springs at the takeoff frame, and the arc is solved so the paws hit
|
||||
# ground exactly on the impact frame (25), leaving the tail frames to settle on the
|
||||
# ground — no finishing the anim in mid-air. The arc uses its own gravity so peak height
|
||||
# and airtime are independent: bear_leap_height is the peak reached in that fixed airtime.
|
||||
_reg_f("Bear", "bear_leap_takeoff_frame", 6.0, 0.0, 46.0, 1.0)
|
||||
_reg_f("Bear", "bear_leap_impact_frame", 25.0, 1.0, 47.0, 1.0)
|
||||
_reg_f("Bear", "bear_leap_height", 2.6, 0.5, 8.0, 0.1)
|
||||
_reg_f("Bear", "bear_leap_recover", 0.35, 0.0, 4.0, 0.05)
|
||||
_reg_f("Bear", "bear_leap_speed", 14.0, 4.0, 30.0, 0.5)
|
||||
# Where the leap-slam launch zone starts, as a fraction of the circle radius. 0 = launch anywhere
|
||||
# inside; raise to carve a grounded inner zone that only mauls (0.75 = only the outer quarter ring).
|
||||
_reg_f("Bear", "bear_leap_ring_frac", 0.0, 0.0, 1.0, 0.05)
|
||||
_reg_f("Bear", "bear_leap_knockup", 7.0, 0.0, 16.0, 0.5)
|
||||
# Stalk (WALK_ON2LEGS) — how long it will keep walking the bull down before re-deciding.
|
||||
_reg_f("Bear", "bear_stalk_time", 2.5, 0.0, 6.0, 0.1)
|
||||
# Odds a close combo chains a follow-up smash after the swipe (phase 2 always chains).
|
||||
_reg_f("Bear", "bear_combo_chance", 0.7, 0.0, 1.0, 0.05)
|
||||
# Minimum bull speed (ram) that counts as a hit on the bear — a gentle nudge is
|
||||
# shrugged off, only a real charge or an ability wounds it. (The bear itself never
|
||||
# damages the bull by colliding — only its swings and slams hurt.)
|
||||
_reg_f("Bear", "bear_hit_threshold", 6.0, 1.0, 30.0, 0.5)
|
||||
# Planar reach at which a horns-first bull charge wounds the bear (~bear body radius + bull reach).
|
||||
# The bull's swept path is tested against this, so a tunnelling or point-blank charge still lands.
|
||||
_reg_f("Bear", "bear_ram_reach", 2.1, 0.5, 5.0, 0.1)
|
||||
# How long (s) the bear is rocked back and can't act after taking a hit.
|
||||
_reg_f("Bear", "bear_stagger_time", 0.5, 0.0, 2.0, 0.05)
|
||||
# Poise: after a stagger the bear shrugs off staggers for this long (hits still wound
|
||||
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
|
||||
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
|
||||
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
|
||||
# ── Sword ─────────────────────────────────────────────────────────────────
|
||||
# Local seating of the sword in the right hand (drawn / fighting).
|
||||
# The blade model runs along its local +Z, but weapon_bone (like every rig bone)
|
||||
# points down its local +Y — so identity leaves the blade jutting out sideways.
|
||||
# rot_x = -90 maps the blade's +Z onto the bone's +Y so it thrusts straight along
|
||||
# the bone; rot_y is then the free roll (crossguard tilt) about the blade axis.
|
||||
_reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "sword_pos_z", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "sword_rot_x", -90.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0)
|
||||
# Local seating of the sword in the hip holster (sheathed / wandering).
|
||||
# Independent of the hand grip so each pose can be tuned without affecting the
|
||||
# other. weapon_rest_bone sits on the left hip; these angle the blade down and
|
||||
# out along the OUTSIDE of the left thigh (rot_x/rot_y aim the blade, rot_z rolls
|
||||
# it about its own axis) so it trails back beside the leg instead of clipping it.
|
||||
_reg_f("Sword", "sword_rest_pos_x", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "sword_rest_pos_y", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "sword_rest_pos_z", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "sword_rest_rot_x", -110.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "sword_rest_rot_y", -133.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0)
|
||||
# ── Spear throw (matador ranged attack) ──────────────────────────────────
|
||||
# Local seating of the spear in the throwing hand during the wind-up. Tune to
|
||||
# seat the shaft in the grip without re-exporting; flight orientation is derived
|
||||
# from the aim direction at release, not from this pose.
|
||||
_reg_f("Sword", "spear_pos_x", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "spear_pos_y", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "spear_pos_z", 0.0, -1.0, 1.0, 0.01)
|
||||
_reg_f("Sword", "spear_rot_x", 0.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "spear_rot_y", 0.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "spear_rot_z", 0.0, -180.0, 180.0, 1.0)
|
||||
# Delay before a fresh sword reappears in the holster if one is ever lost.
|
||||
_reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5)
|
||||
_reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0)
|
||||
_reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1)
|
||||
_reg_f("Sword", "mat_throw_windup", 0.5, 0.2, 2.0, 0.05)
|
||||
_reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01)
|
||||
# Horizontal speed of the thrown spear; the vertical launch is solved from this
|
||||
# and the gravity below so the arc lands on the bull. Lower = a higher, slower lob.
|
||||
_reg_f("Sword", "mat_throw_speed", 11.0, 5.0, 60.0)
|
||||
# Gravity scale on the spear (higher = a more pronounced arc) and the vertical aim
|
||||
# offset from the bull origin down onto its low collision body.
|
||||
_reg_f("Sword", "mat_throw_gravity", 1.0, 0.1, 3.0, 0.05)
|
||||
_reg_f("Sword", "mat_throw_aim_y", -0.45,-1.5, 0.5, 0.05)
|
||||
# Aim scatter (± degrees) so thrown blades can be read and dodged on the move.
|
||||
_reg_f("Sword", "mat_throw_spread", 11.0, 0.0, 30.0, 0.5)
|
||||
_reg_f("Sword", "mat_throw_arm_cock", 70.0,-180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0)
|
||||
# ── Abilities ─────────────────────────────────────────────────────────────
|
||||
_reg_f("Abilities", "kick_cooldown", 2, 0.2, 600, 0.1)
|
||||
_reg_f("Abilities", "kick_range", 6.0, 0.5, 8.0, 0.1)
|
||||
_reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0)
|
||||
_reg_f("Abilities", "slam_cooldown", 12, 0.5, 600, 0.1)
|
||||
_reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0)
|
||||
# Descent gravity multiplier — higher = faster crash, less airtime.
|
||||
_reg_f("Abilities", "slam_fall_mult", 3.0, 1.0, 8.0, 0.5)
|
||||
# Upward launch velocity (m/s) given to a slammed matador's ragdoll — pops them
|
||||
# skyward. ~7 ≈ a 2.5 m hop; 0 disables the launch.
|
||||
_reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5)
|
||||
_reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1)
|
||||
_reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0)
|
||||
_reg_f("Abilities", "dash_cooldown", 3, 0.1, 600.0, 0.1)
|
||||
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
|
||||
# Exponential rate the dash bleeds from dash_speed back to cruise (max_speed).
|
||||
# Higher = a snappier bullet that dies off quicker; lower = a longer glide.
|
||||
_reg_f("Abilities", "dash_decel", 7.0, 0.5, 30.0, 0.5)
|
||||
# ── Roll (homing chain ball) ──────────────────────────────────────────────
|
||||
# The bull curls into a ball and ACCELERATES the longer it rolls, from
|
||||
# roll_base_speed up to roll_max_speed over roll_rampup_time. It HOMES on the
|
||||
# closest matador; on slamming one the ball POPS it (launched skyward via
|
||||
# roll_knockup) then CHAINS to the next closest, up to roll_chain_count times.
|
||||
# roll_turn is the homing turn rate. When no chain targets remain, the roll ends.
|
||||
_reg_f("Roll", "roll_cooldown", 20, 0.5, 600, 0.1)
|
||||
_reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time
|
||||
_reg_f("Roll", "roll_chain_count", 2.0, 0.0, 8.0, 1.0) # extra matadors chained after the first
|
||||
_reg_f("Roll", "roll_base_speed", 28.0, 5.0, 120.0) # speed at the start of the roll
|
||||
_reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped
|
||||
_reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed
|
||||
_reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # homing turn rate (higher = tighter tracking)
|
||||
_reg_f("Roll", "roll_spin_radius", 1.4, 0.3, 4.0, 0.1) # ball radius for tumble spin (smaller = faster)
|
||||
_reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball
|
||||
_reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop
|
||||
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
|
||||
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
|
||||
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
|
||||
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
|
||||
# rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the
|
||||
# whole overlay. Ranges mirror the shader's hint_range so the console can't push
|
||||
# a uniform out of bounds.
|
||||
_reg_b("PS1", "show_ps1", true)
|
||||
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
|
||||
_reg_f("PS1", "ps1_color_depth", 4.5, 2.0, 8.0, 1.0)
|
||||
_reg_f("PS1", "ps1_dither_strength", 1, 0.0, 2.0, 0.05)
|
||||
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
|
||||
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
|
||||
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
|
||||
_reg_b("Debug", "show_hitboxes", false)
|
||||
_reg_b("Debug", "show_attacks", false)
|
||||
_reg_b("Debug", "show_bones", false)
|
||||
_reg_b("Debug", "show_raycasts", false)
|
||||
_reg_b("Debug", "show_tail", false)
|
||||
_reg_b("Debug", "show_stats", false)
|
||||
_reg_b("Debug", "show_states", false)
|
||||
_reg_b("Debug", "show_animation_name", false)
|
||||
_reg_b("Debug", "show_wireframe", false)
|
||||
_reg_b("Debug", "show_grid", false)
|
||||
_reg_b("Debug", "show_axes", false)
|
||||
# Force the on-screen touch controls (joystick + ability buttons) on even on a
|
||||
# desktop build — otherwise they appear only when a touchscreen is detected.
|
||||
_reg_b("Touch", "force_touch_controls", false)
|
||||
# On-screen readout of live touch state (event count, last position, active zone) so
|
||||
# touch problems can be diagnosed on the actual device, where no console is reachable.
|
||||
_reg_b("Touch", "touch_debug", false)
|
||||
# Rebuild skinned characters as rigid, bone-attached mesh pieces instead of GPU vertex
|
||||
# skinning. This was a workaround for older Mali/ANGLE web GPUs where Compatibility skinning
|
||||
# rendered characters invisible — but the Godot 4.7 web templates fixed that (confirmed
|
||||
# on-device), so native skinning is now the default everywhere: it renders the bear's dense
|
||||
# mesh without the rigid-seam holes AND drops the per-spawn SurfaceTool rebuild that was
|
||||
# spiking the arena on matador spawn. Kept as an opt-in fallback (DP toggle, or ?skin=1 in
|
||||
# the URL) in case a device is found where Compatibility skinning still fails.
|
||||
_reg_b("Render", "use_rigid_skin", false)
|
||||
|
||||
|
||||
func _reg_f(section: String, key: String, default: float,
|
||||
min_val: float, max_val: float, step: float = 0.05) -> void:
|
||||
_params[key] = {
|
||||
"value": default,
|
||||
"default": default,
|
||||
"section": section,
|
||||
"type": TYPE_FLOAT,
|
||||
"min": min_val,
|
||||
"max": max_val,
|
||||
"step": step,
|
||||
}
|
||||
_values[key] = default
|
||||
|
||||
|
||||
func _reg_b(section: String, key: String, default: bool) -> void:
|
||||
_params[key] = {
|
||||
"value": default,
|
||||
"default": default,
|
||||
"section": section,
|
||||
"type": TYPE_BOOL,
|
||||
}
|
||||
_values[key] = default
|
||||
|
||||
|
||||
## Read a float param. Panics on unknown key — intentional: typos should be loud.
|
||||
func f(key: String) -> float:
|
||||
return _values[key] as float
|
||||
|
||||
|
||||
## Read a bool param.
|
||||
func b(key: String) -> bool:
|
||||
return _values[key] as bool
|
||||
|
||||
|
||||
func set_value(key: String, value: Variant) -> void:
|
||||
if not _params.has(key):
|
||||
return
|
||||
_params[key]["value"] = value
|
||||
_values[key] = value
|
||||
any_changed.emit(key, value)
|
||||
|
||||
|
||||
## Returns the full metadata dict — used by Console to build UI.
|
||||
func get_all() -> Dictionary:
|
||||
return _params
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var path := "res://systems/debug/debug_params.gd"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("DP.save: cannot open " + path)
|
||||
return
|
||||
var src := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
for key: String in _params:
|
||||
var p: Dictionary = _params[key]
|
||||
if p["value"] == p["default"]:
|
||||
continue
|
||||
var re := RegEx.new()
|
||||
var literal := ""
|
||||
if p["type"] == TYPE_FLOAT:
|
||||
re.compile('(_reg_f\\("[^"]+",\\s*"%s",\\s*)([-\\d.e]+)' % key)
|
||||
literal = _fmt_float(p["value"] as float)
|
||||
else:
|
||||
re.compile('(_reg_b\\("[^"]+",\\s*"%s",\\s*)(true|false)' % key)
|
||||
literal = "true" if bool(p["value"]) else "false"
|
||||
var m := re.search(src)
|
||||
if m == null:
|
||||
push_warning("DP.save: no _reg_ line found for '%s' — skipped" % key)
|
||||
continue
|
||||
# Splice the value in by index rather than RegEx.sub(): sub()'s replacement
|
||||
# string parses "$n" as a backreference, so "$1"+"12.5" becomes "$112.5"
|
||||
# (group 112 → PCRE2 error → mangled output). Indexed splice inserts the
|
||||
# literal verbatim, exactly the value the user entered.
|
||||
src = src.substr(0, m.get_start(2)) + literal + src.substr(m.get_end(2))
|
||||
p["default"] = p["value"]
|
||||
|
||||
var wfile := FileAccess.open(path, FileAccess.WRITE)
|
||||
if wfile == null:
|
||||
push_error("DP.save: cannot write " + path)
|
||||
return
|
||||
wfile.store_string(src)
|
||||
wfile.close()
|
||||
|
||||
|
||||
# Godot's String % operator has no "g" conversion — "%.6g" % v slips past the
|
||||
# compiler (v is not const) but emits the literal "%.6g" at runtime, which would be
|
||||
# written straight into the source. "%s" gives the shortest round-tripping form,
|
||||
# matching the value the user entered; the guard keeps it a float literal.
|
||||
func _fmt_float(v: float) -> String:
|
||||
var s := "%s" % v
|
||||
if not ("." in s or "e" in s):
|
||||
s += ".0"
|
||||
return s
|
||||
|
||||
|
||||
func load_saved() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SAVE_PATH) != OK:
|
||||
return
|
||||
for section: String in cfg.get_sections():
|
||||
for key: String in cfg.get_section_keys(section):
|
||||
if not _params.has(key):
|
||||
continue
|
||||
var p: Dictionary = _params[key]
|
||||
var val: Variant = cfg.get_value(section, key)
|
||||
if p["type"] == TYPE_FLOAT:
|
||||
val = clampf(val as float, p["min"] as float, p["max"] as float)
|
||||
_params[key]["value"] = val
|
||||
_values[key] = val
|
||||
|
||||
|
||||
func reset_all() -> void:
|
||||
for key: String in _params:
|
||||
var default: Variant = _params[key]["default"]
|
||||
_params[key]["value"] = default
|
||||
_values[key] = default
|
||||
any_changed.emit("__all__", null)
|
||||
|
||||
|
||||
## Reset a single param to its default. No-op on unknown key.
|
||||
func reset(key: String) -> void:
|
||||
if not _params.has(key):
|
||||
return
|
||||
var default: Variant = _params[key]["default"]
|
||||
_params[key]["value"] = default
|
||||
_values[key] = default
|
||||
any_changed.emit(key, default)
|
||||
|
||||
|
||||
## True when the param currently differs from its registered default.
|
||||
func is_modified(key: String) -> bool:
|
||||
return _params.has(key) and _params[key]["value"] != _params[key]["default"]
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3ix5yi7b3ynh
|
||||
Reference in New Issue
Block a user