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 ", "diff": "list params changed from default", "toggle": "flip a bool param", "list": "print params (optional section)", "clear": "clear the log", "help": "show this help", } var _canvas: CanvasLayer var _panel: PanelContainer var _log: RichTextLabel 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 · 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 "" _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 " " 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) "clear": _log.clear() "help": _cmd_help() _: return false return true # Resolve a user-typed token to a param key: exact match, else unique fuzzy match. # Logs an ambiguity / unknown warning (and an arrow on a fuzzy hit) and returns # "" when it can't narrow to a single key. func _resolve_key(raw: String) -> String: if DP.get_all().has(raw): return raw var matches := _fuzzy_find_all(raw) if matches.size() == 1: _log_raw("[color=#8888ff]→ %s[/color]" % matches[0]) return matches[0] if matches.size() > 1: _log_warn("Ambiguous '%s': %s" % [_esc(raw), ", ".join(matches.slice(0, 6))]) else: _log_warn("Unknown: %s" % _esc(raw)) return "" func _cmd_reset(parts: PackedStringArray) -> void: if parts.size() == 1: DP.reset_all() _log_ok("All params reset to defaults.") return var key := _resolve_key(parts[1]) if key.is_empty(): return DP.reset(key) _log_ok("[b]%s[/b] reset to [color=#ffe080]%s[/color]" % [key, _fmt_val(key, DP.get_all()[key])]) func _cmd_toggle(parts: PackedStringArray) -> void: if parts.size() < 2: _log_warn("Usage: toggle ") return var key := _resolve_key(parts[1]) if key.is_empty(): return var meta: Dictionary = DP.get_all()[key] if meta["type"] != TYPE_BOOL: _log_warn("%s is not a bool — use '%s '" % [key, key]) return var v := not (meta["value"] as bool) DP.set_value(key, v) _log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, "true" if v else "false"]) # List only params that currently differ from their registered default, grouped by # section — the "what have I actually changed?" view before a save. func _cmd_diff() -> void: var all := DP.get_all() var sections: Dictionary = {} for key: String in all: if not DP.is_modified(key): continue var sec: String = all[key]["section"] if not sections.has(sec): sections[sec] = [] sections[sec].append(key) if sections.is_empty(): _log_ok("All params at defaults.") return for sec: String in sections: _log_raw("[color=#6699ff][b]%s[/b][/color]" % sec) for key: String in sections[sec]: var meta: Dictionary = all[key] _log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color] [color=#484848](def %s)[/color]" % [ key, _fmt_val(key, meta), _fmt_default(meta) ]) func _cmd_help() -> void: _log_raw("[color=#888888] [/color] — read current value + range") _log_raw("[color=#888888] [/color] — set it (number, or true/false)") for name: String in _COMMANDS: _log_raw("[color=#888888] %s[/color] — %s" % [name.rpad(20), _COMMANDS[name]]) _log_raw(" [color=#555555]↑↓ pick suggestion · Tab complete · ↑↓ history when empty · ` close[/color]") func _fmt_default(meta: Dictionary) -> String: if meta["type"] == TYPE_FLOAT: return _fmt_num(meta["default"] as float) return "true" if (meta["default"] as bool) else "false" func _read_param(key: String, meta: Dictionary) -> void: if meta["type"] == TYPE_FLOAT: _log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](%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 %s … %s" % [key, _fmt_num(lo), _fmt_num(hi)]) return var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float) DP.set_value(key, v) _log_ok("[b]%s[/b] ← [color=#ffe080]%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_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)