extends CanvasLayer ## Консоль разработчика: ввод команд поверх игры. ## ## Автозагрузка, поэтому доступна из любой сцены. Своих знаний об игре у неё ## нет — команды регистрируют сцены через register(). Так консоль не тянет за ## собой игровую логику, а команды живут рядом с тем, чем управляют. ## ## Открывается тильдой или F1. Слой обрабатывается всегда, поэтому работает ## и на паузе — иначе отлаживать остановленную игру было бы нечем. const HISTORY_LIMIT := 40 ## Файловый мост для внешних инструментов (MCP, скрипты, CI). ## ## Внедрённые события клавиатуры доносят до LineEdit код клавиши, но не символ, ## поэтому набрать команду извне нельзя. Мост обходит клавиатуру: во входной ## файл кладут строки, консоль их выполняет и удаляет файл, а весь вывод ## дублирует в лог. Только в отладочной сборке — в релиз это не попадёт. const BRIDGE_IN := "user://console_in.txt" const BRIDGE_LOG := "user://console_log.txt" const BRIDGE_INTERVAL := 0.25 const COLOR_ECHO := "#5d7288" const COLOR_INFO := "#dbe7f0" const COLOR_OK := "#2ee6c5" const COLOR_ERROR := "#f2704a" @onready var _root: Control = $Root @onready var _output: RichTextLabel = $Root/Panel/Body/Output @onready var _entry: LineEdit = $Root/Panel/Body/InputRow/Input ## Имя команды -> {description, usage, handler} var _commands := {} var _history: Array[String] = [] var _history_index := 0 var _log: FileAccess func _ready() -> void: _root.visible = false _entry.text_submitted.connect(_on_submitted) _register_builtin() _start_bridge() print_line("Консоль разработчика. `help` — список команд.", COLOR_INFO) func _input(event: InputEvent) -> void: if not (event is InputEventKey and event.is_pressed() and not event.is_echo()): return var key := (event as InputEventKey).keycode if key == KEY_QUOTELEFT or key == KEY_F1: toggle() get_viewport().set_input_as_handled() return if not _root.visible: return # История — стрелками, как в настоящей консоли. match key: KEY_UP: _recall(-1) get_viewport().set_input_as_handled() KEY_DOWN: _recall(1) get_viewport().set_input_as_handled() KEY_ESCAPE: close() get_viewport().set_input_as_handled() # --- Открытие / закрытие ---------------------------------------------------- func toggle() -> void: if _root.visible: close() else: open() func open() -> void: _root.visible = true _entry.clear() _entry.grab_focus() func close() -> void: _root.visible = false _entry.release_focus() # --- Реестр команд ---------------------------------------------------------- ## Сцены регистрируют свои команды здесь. `handler` принимает Array[String] ## аргументов и возвращает String — что напечатать. func register(command: String, description: String, handler: Callable, usage := "") -> void: _commands[command] = { "description": description, "usage": usage, "handler": handler, } func unregister(command: String) -> void: _commands.erase(command) func print_line(text: String, color := COLOR_INFO) -> void: _output.append_text("[color=%s]%s[/color]\n" % [color, text]) if _log != null: # Сбрасываем сразу: внешний инструмент читает файл, пока игра жива. _log.store_line(text) _log.flush() # --- Файловый мост ---------------------------------------------------------- func _start_bridge() -> void: if not OS.is_debug_build(): return # Лог перезаписывается на каждый запуск — так в нём только текущая сессия. _log = FileAccess.open(BRIDGE_LOG, FileAccess.WRITE) var timer := Timer.new() timer.wait_time = BRIDGE_INTERVAL timer.autostart = true timer.timeout.connect(_poll_bridge) add_child(timer) func _poll_bridge() -> void: if not FileAccess.file_exists(BRIDGE_IN): return var file := FileAccess.open(BRIDGE_IN, FileAccess.READ) if file == null: return var payload := file.get_as_text() file.close() # Удаляем сразу, иначе команды выполнятся повторно на следующем тике. DirAccess.remove_absolute(BRIDGE_IN) for line: String in payload.split("\n", false): _on_submitted(line) # --- Выполнение ------------------------------------------------------------- func _on_submitted(line: String) -> void: _entry.clear() var trimmed := line.strip_edges() if trimmed.is_empty(): return _remember(trimmed) print_line("> " + trimmed, COLOR_ECHO) var parts := trimmed.split(" ", false) var command := String(parts[0]).to_lower() var arguments: Array[String] = [] for index in range(1, parts.size()): arguments.append(String(parts[index])) _execute(command, arguments) func _execute(command: String, arguments: Array[String]) -> void: if not _commands.has(command): print_line("Неизвестная команда: %s. `help` — список." % command, COLOR_ERROR) return var entry: Dictionary = _commands[command] var handler: Callable = entry["handler"] # Сцена могла смениться — её команды умирают вместе с ней. if not handler.is_valid(): _commands.erase(command) print_line("Команда %s больше недоступна в этой сцене." % command, COLOR_ERROR) return var result: Variant = handler.call(arguments) if result is String and not String(result).is_empty(): print_line(String(result), COLOR_OK) # --- История ---------------------------------------------------------------- func _remember(line: String) -> void: _history.append(line) if _history.size() > HISTORY_LIMIT: _history.remove_at(0) _history_index = _history.size() func _recall(step: int) -> void: if _history.is_empty(): return _history_index = clampi(_history_index + step, 0, _history.size()) if _history_index == _history.size(): _entry.clear() return _entry.text = _history[_history_index] _entry.caret_column = _entry.text.length() # --- Встроенные команды ----------------------------------------------------- func _register_builtin() -> void: register("help", "список команд", _cmd_help) register("cls", "очистить вывод", _cmd_clear) func _cmd_help(_arguments: Array[String]) -> String: var names := _commands.keys() names.sort() var lines := PackedStringArray() for name: String in names: var entry: Dictionary = _commands[name] var usage := String(entry["usage"]) var head := name if usage.is_empty() else "%s %s" % [name, usage] lines.append(" %-24s %s" % [head, String(entry["description"])]) return "\n".join(lines) func _cmd_clear(_arguments: Array[String]) -> String: _output.clear() return ""