// Assets/_Project/Scripts/Dev/DebugConsole.cs using System; using System.Collections; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.UIElements; using TD.UI; using TD.Gameplay.Draft; namespace TD.Dev { /// /// Standalone debug console overlay: press the toggle key to open a text /// input, type a command, Enter to submit, Escape to cancel. /// /// /// Deliberately decoupled from — own GameObject, /// own UIDocument (it shares HUDPanelSettings, drawn above the HUD via a /// higher sorting order) — so it can be dropped out of a build entirely by /// not including that GameObject, the same way /// is kept separate from shipping systems. /// /// The grammar lives in a tree (see /// ) rather than in hardcoded string compares, /// so the same definition drives both execution and the autocomplete list. /// Supports one command so far: "get <type> <name>" grants a /// to the local player via /// . /// /// Keys while open: Up/Down move the suggestion highlight, Tab accepts it, /// Enter accepts it while the command is still incomplete and submits once /// it is complete, Escape closes without submitting. /// [RequireComponent(typeof(UIDocument))] public class DebugConsole : MonoBehaviour { public static DebugConsole Instance { get; private set; } /// /// True while the console overlay is showing. Legacy IMGUI (OnGUI) dev tools /// draw after every runtime UI Toolkit panel regardless of sorting order, so they /// have to check this and skip drawing rather than rely on layering. /// public static bool IsOpen => Instance != null && Instance.consoleOpen; [Tooltip("Keyboard shortcut that opens and closes the console. Uses the new Input " + "System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")] [SerializeField] private Key toggleKey = Key.Backquote; // Beyond this many candidates the list is truncated with a "+N more" row so a // large DraftPool can't grow the console bar off the top of the screen. private const int MaxVisibleSuggestions = 12; private VisualElement consoleContainer; private TextField commandInput; private VisualElement suggestionList; private bool consoleOpen; // Frame on which the console was opened or closed. The toggle key and Enter // are ignored on that frame and the next so the keypress that opened/closed // the console doesn't also get typed into the field or immediately submit it. // Mirrors HUDController's chatToggleSuppressFrame. private int toggleSuppressFrame = -1; // Current completion candidates for what's typed, the highlighted one, and the // index in the input text where accepting a candidate starts overwriting. private List suggestions = new List(); private int selectedIndex; private int fragmentStart; private void Awake() { if (Instance != null && Instance != this) { Debug.LogError("[DebugConsole] Duplicate instance detected. Destroying the new one."); Destroy(gameObject); return; } Instance = this; } private void Start() { var doc = GetComponent(); if (doc == null) { Debug.LogError("[DebugConsole] No UIDocument component found."); return; } var root = doc.rootVisualElement; if (root == null) { Debug.LogError("[DebugConsole] rootVisualElement is null. " + "Check that Panel Settings is assigned."); return; } BuildConsoleUi(root); } private void BuildConsoleUi(VisualElement uiRoot) { // Top-anchored bar, closed by default. Distinct position from the HUD's // bottom-left chat panel so the two never overlap. consoleContainer = new VisualElement(); consoleContainer.pickingMode = PickingMode.Ignore; consoleContainer.style.position = Position.Absolute; consoleContainer.style.left = 0; consoleContainer.style.right = 0; consoleContainer.style.top = 0; consoleContainer.style.paddingTop = 8; consoleContainer.style.paddingBottom = 8; consoleContainer.style.paddingLeft = 12; consoleContainer.style.paddingRight = 12; // Fully opaque: the console is a text-entry overlay that has to stay readable // over the level, the HUD, and whatever else is on screen. consoleContainer.style.backgroundColor = new Color(0f, 0f, 0f, 1f); consoleContainer.style.display = DisplayStyle.None; commandInput = new TextField(); commandInput.style.minHeight = 28; commandInput.style.width = Length.Percent(100); commandInput.maxLength = 200; commandInput.isDelayed = false; StyleConsoleInput(commandInput); // Focus tracking — contributes to the same flag HUDController's chat // input sets, so camera/builder/spell input all stay gated correctly // regardless of which text field currently has focus. commandInput.RegisterCallback(_ => HUDController.IsTextInputActive = true); commandInput.RegisterCallback(_ => HUDController.IsTextInputActive = false); commandInput.RegisterValueChangedCallback(_ => RefreshSuggestions()); // Tab and the arrows can't be polled from Keyboard.current the way Enter and // Escape are (below): UI Toolkit would still insert a tab character and move // the focus ring before Update runs. Intercept them on the field in the // trickle-down phase, before the text engine and the focus ring see them. commandInput.RegisterCallback(OnConsoleKeyDown, TrickleDown.TrickleDown); commandInput.RegisterCallback(OnConsoleNavigationMove, TrickleDown.TrickleDown); suggestionList = new VisualElement(); suggestionList.pickingMode = PickingMode.Ignore; suggestionList.style.flexDirection = FlexDirection.Column; suggestionList.style.marginTop = 4; suggestionList.style.display = DisplayStyle.None; consoleContainer.Add(commandInput); consoleContainer.Add(suggestionList); uiRoot.Add(consoleContainer); } // Mirrors HUDController.StyleChatInputDark — dark background, white text, // a thin border, and inner padding so ascenders/descenders don't clip. // Green border (vs. chat's neutral gray) as a quick visual "this is the // debug console, not chat" cue. private static void StyleConsoleInput(TextField field) { var darkBg = new Color(0.05f, 0.05f, 0.05f, 1f); var borderClr = new Color(0.35f, 0.75f, 0.35f); field.style.backgroundColor = darkBg; field.style.color = Color.white; field.style.borderTopWidth = field.style.borderBottomWidth = field.style.borderLeftWidth = field.style.borderRightWidth = 1; field.style.borderTopColor = field.style.borderBottomColor = field.style.borderLeftColor = field.style.borderRightColor = borderClr; var inner = field.Q("unity-text-input"); if (inner != null) { inner.style.backgroundColor = darkBg; inner.style.color = Color.white; inner.style.paddingTop = 4; inner.style.paddingBottom = 4; inner.style.paddingLeft = 6; inner.style.paddingRight = 6; } } // One autocomplete row: the candidate token plus an optional dim hint. Rows are // pickingMode Ignore and keyboard-driven only — nothing here is clickable, which // avoids the recursive picking-mode problem documented in HUDController's chat // panel (clicks falling through to the game world via a ScrollView's internal // wrapper element). private static VisualElement BuildSuggestionRow(string text, string hint, bool selected) { var row = new VisualElement(); row.pickingMode = PickingMode.Ignore; row.style.flexDirection = FlexDirection.Row; row.style.minHeight = 20; row.style.paddingLeft = 6; row.style.paddingRight = 6; row.style.backgroundColor = selected ? new Color(0.16f, 0.38f, 0.16f, 1f) // matches the green console cue : new Color(0.05f, 0.05f, 0.05f, 1f); var label = new Label(text); label.pickingMode = PickingMode.Ignore; label.style.color = Color.white; label.style.unityFontStyleAndWeight = selected ? FontStyle.Bold : FontStyle.Normal; row.Add(label); if (!string.IsNullOrEmpty(hint)) { var hintLabel = new Label(hint); hintLabel.pickingMode = PickingMode.Ignore; hintLabel.style.color = new Color(0.6f, 0.6f, 0.6f); hintLabel.style.marginLeft = 12; row.Add(hintLabel); } return row; } private void Update() { var kb = Keyboard.current; if (kb == null) return; // no keyboard connected (e.g. headless server) if (Time.frameCount <= toggleSuppressFrame) return; if (toggleKey != Key.None && kb[toggleKey].wasPressedThisFrame) { if (consoleOpen) CloseConsole(); else OpenConsole(); return; } if (!consoleOpen) return; bool enterDown = kb.enterKey.wasPressedThisFrame || kb.numpadEnterKey.wasPressedThisFrame; bool escDown = kb.escapeKey.wasPressedThisFrame; if (enterDown) SubmitCommand(); else if (escDown) CloseConsole(); } private void OnConsoleKeyDown(KeyDownEvent evt) { if (!consoleOpen) return; switch (evt.keyCode) { case KeyCode.Tab: AcceptSelectedSuggestion(); Swallow(evt); return; case KeyCode.DownArrow: MoveSelection(1); Swallow(evt); return; case KeyCode.UpArrow: MoveSelection(-1); Swallow(evt); return; } // Tab reaches the field twice — once carrying keyCode, once carrying only the // '\t' character. Swallow the second one too or a literal tab lands in the text. if (evt.character == '\t') Swallow(evt); } // UI Toolkit also translates Tab into a focus-ring navigation event, separately // from the key event above. Block it so Tab can't move focus off the console. private void OnConsoleNavigationMove(NavigationMoveEvent evt) { if (!consoleOpen) return; Swallow(evt); } // Consume an event so neither the text engine nor the focus ring acts on it. // EventBase.PreventDefault is obsolete in Unity 6; FocusController.IgnoreEvent is // its replacement for the focus-navigation half. private void Swallow(EventBase evt) { evt.StopImmediatePropagation(); commandInput?.panel?.focusController?.IgnoreEvent(evt); } private void OpenConsole() { if (commandInput == null) return; consoleContainer.style.display = DisplayStyle.Flex; consoleContainer.pickingMode = PickingMode.Position; commandInput.SetValueWithoutNotify(string.Empty); RefreshSuggestions(); // Suppress the toggle key and Enter for this frame and the next so the // keypress that opened the console doesn't also get typed into the field. // Focus is deferred a frame for the same reason — UI Toolkit would // otherwise route the open keypress to the freshly-focused TextField. toggleSuppressFrame = Time.frameCount + 1; consoleOpen = true; StartCoroutine(FocusNextFrame()); } private IEnumerator FocusNextFrame() { yield return null; if (consoleOpen && commandInput != null) { commandInput.Focus(); commandInput.SelectAll(); } } private void CloseConsole() { if (commandInput != null) { commandInput.SetValueWithoutNotify(string.Empty); commandInput.Blur(); } if (consoleContainer != null) { consoleContainer.style.display = DisplayStyle.None; consoleContainer.pickingMode = PickingMode.Ignore; } suggestions.Clear(); selectedIndex = 0; if (suggestionList != null) { suggestionList.Clear(); suggestionList.style.display = DisplayStyle.None; } consoleOpen = false; toggleSuppressFrame = Time.frameCount + 1; SuppressHudChatOpen(); } // The Enter that submitted/accepted in this console also gets polled by // HUDController's chat this same frame — suppress its "Enter opens chat" branch // (HUDController.HandleChatInput, which gates only on this frame counter) so // using the debug console never pops chat open too. private static void SuppressHudChatOpen() { HUDController.SuppressChatOpenUntilFrame = Time.frameCount + 1; } private void SubmitCommand() { string text = commandInput?.value ?? string.Empty; // Enter doubles as "accept the highlighted suggestion" while the typed // command isn't runnable yet, so you can drive the whole thing with Enter // and only ever hit Tab when you want to skip past the highlighted row. if (!string.IsNullOrWhiteSpace(text) && suggestions.Count > 0 && !IsComplete(text)) { AcceptSelectedSuggestion(); SuppressHudChatOpen(); return; } CloseConsole(); if (string.IsNullOrWhiteSpace(text)) return; ExecuteCommand(text.Trim()); } // ---------------------------------------------------------------- grammar /// /// One token level of the command grammar. Literal levels list their /// ; a level that takes a free-form final argument sets /// to enumerate the valid values for completion. /// private sealed class CommandNode { public string Token; // literal token; null for the root public string Hint; // dim one-liner shown beside the token public Func> Values; // non-null => this level takes a final value public bool Executable; // a complete command can end here public Type OptionType; // "get": the DraftOption subclass to match public readonly List Children = new List(); } private const string UsageHint = "Try: get "; // Maps the type token in "get " to the DraftOption subclass it // should match against. Add an entry here whenever a new DraftOption subclass // should be gettable from the console — the command tree and its autocomplete // list are both built from this. private static readonly Dictionary DraftTypeAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "spell", typeof(BuilderSpellDraftOption) }, { "tower", typeof(NewTowerDraftOption) }, { "buff", typeof(BuilderEffectDraftOption) }, { "effect", typeof(BuilderEffectDraftOption) }, }; private CommandNode commandTree; private CommandNode CommandTree => commandTree ?? (commandTree = BuildCommandTree()); private static CommandNode BuildCommandTree() { var root = new CommandNode(); var get = new CommandNode { Token = "get", Hint = "grant a draft option to the local player", }; root.Children.Add(get); foreach (var pair in DraftTypeAliases) { var optionType = pair.Value; get.Children.Add(new CommandNode { Token = pair.Key, Hint = "", OptionType = optionType, Executable = true, Values = () => DraftOptionNames(optionType), }); } return root; } // Display names of every pooled DraftOption of the given subclass, alphabetically. // Recomputed per keystroke rather than cached: DraftPool is a scene singleton that // may not exist yet when the console is built, and the pool is only a handful of // entries. Uses DraftPool's existing Count/Get surface — the same one ExecuteGet // walks — so completion can never offer something the command can't resolve. private static List DraftOptionNames(Type optionType) { var names = new List(); var pool = DraftPool.Instance; if (pool == null) return names; for (int i = 0; i < pool.Count; i++) { var option = pool.Get(i); if (option == null || !optionType.IsInstanceOfType(option)) continue; string name = option.DisplayName; if (string.IsNullOrWhiteSpace(name)) continue; if (names.Contains(name)) continue; names.Add(name); } names.Sort(StringComparer.OrdinalIgnoreCase); return names; } // Valid literal tokens at a level, for error messages ("Expected one of: ..."). private static string ChildTokens(CommandNode node) { var tokens = new List(); for (int i = 0; i < node.Children.Count; i++) tokens.Add(node.Children[i].Token); tokens.Sort(StringComparer.OrdinalIgnoreCase); return string.Join(", ", tokens); } private static CommandNode FindChild(CommandNode node, string token) { for (int i = 0; i < node.Children.Count; i++) if (string.Equals(node.Children[i].Token, token, StringComparison.OrdinalIgnoreCase)) return node.Children[i]; return null; } // Whitespace tokenizer that also reports where each token starts, so completion // knows which span of the raw input a candidate would replace. private static void Tokenize(string text, List tokens, List starts) { tokens.Clear(); starts.Clear(); int i = 0; while (i < text.Length) { while (i < text.Length && char.IsWhiteSpace(text[i])) i++; if (i >= text.Length) break; int start = i; while (i < text.Length && !char.IsWhiteSpace(text[i])) i++; tokens.Add(text.Substring(start, i - start)); starts.Add(start); } } // ------------------------------------------------------------- completion private struct Suggestion { public string Text; // what replaces the current fragment public string Hint; // dim trailing text, display only public bool AppendSpace; // true when more tokens follow this one } private readonly List walkTokens = new List(); private readonly List walkStarts = new List(); /// /// Candidates for the token currently being typed, plus (via /// ) the index in where /// accepting one starts overwriting. /// private List ComputeSuggestions(string text, out int fragStart) { text = text ?? string.Empty; Tokenize(text, walkTokens, walkStarts); bool endsWithSpace = text.Length > 0 && char.IsWhiteSpace(text[text.Length - 1]); var node = CommandTree; int i = 0; while (true) { // Free-form final argument: everything from here to the end of the line is // one value, so multi-word names ("Slow Area") complete as a single unit — // matching how ExecuteCommand rejoins the trailing tokens. if (node.Values != null) { fragStart = i < walkTokens.Count ? walkStarts[i] : text.Length; string valueFragment = i < walkTokens.Count ? text.Substring(fragStart).Trim() : string.Empty; return FilterValues(node.Values(), valueFragment); } // Nothing typed at this level yet — offer everything it accepts. if (i >= walkTokens.Count) { fragStart = text.Length; return FilterChildren(node, string.Empty); } // Last token with no trailing space is still being typed: complete it // rather than trying to descend through it. if (i == walkTokens.Count - 1 && !endsWithSpace) { fragStart = walkStarts[i]; return FilterChildren(node, walkTokens[i]); } var child = FindChild(node, walkTokens[i]); if (child == null) { // A finished token that matches nothing — the rest of the line is // unreachable, so there's nothing useful to suggest. fragStart = walkStarts[i]; return new List(); } node = child; i++; } } private static List FilterChildren(CommandNode node, string fragment) { var result = new List(); for (int i = 0; i < node.Children.Count; i++) { var child = node.Children[i]; if (fragment.Length > 0 && !child.Token.StartsWith(fragment, StringComparison.OrdinalIgnoreCase)) continue; result.Add(new Suggestion { Text = child.Token, Hint = child.Hint, AppendSpace = child.Children.Count > 0 || child.Values != null, }); } result.Sort((a, b) => string.Compare(a.Text, b.Text, StringComparison.OrdinalIgnoreCase)); return result; } // Values are matched on the same normalized form execution uses, so anything the // list offers for "slow_ar" is something ExecuteGet would also accept. private static List FilterValues(List values, string fragment) { var result = new List(); string normalizedFragment = Normalize(fragment); for (int i = 0; i < values.Count; i++) { if (normalizedFragment.Length > 0 && !Normalize(values[i]).StartsWith(normalizedFragment, StringComparison.Ordinal)) continue; result.Add(new Suggestion { Text = values[i], AppendSpace = false }); } return result; } /// True when is a runnable command as typed. private bool IsComplete(string text) { Tokenize(text ?? string.Empty, walkTokens, walkStarts); var node = CommandTree; int i = 0; while (i < walkTokens.Count) { if (node.Values != null) break; var child = FindChild(node, walkTokens[i]); if (child == null) return false; node = child; i++; } if (!node.Executable) return false; if (node.Values != null) { if (i >= walkTokens.Count) return false; // value level, nothing typed for it string normalized = Normalize(string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i))); var values = node.Values(); for (int v = 0; v < values.Count; v++) if (Normalize(values[v]) == normalized) return true; return false; } return true; } // --------------------------------------------------------- suggestion UI private void RefreshSuggestions() { if (commandInput == null) return; // Keep the highlight on the same candidate when it survives the new filter, // so typing another character doesn't silently move what Enter would accept. string previouslySelected = selectedIndex >= 0 && selectedIndex < suggestions.Count ? suggestions[selectedIndex].Text : null; suggestions = ComputeSuggestions(commandInput.value, out fragmentStart); selectedIndex = 0; if (previouslySelected != null) { for (int i = 0; i < suggestions.Count; i++) { if (!string.Equals(suggestions[i].Text, previouslySelected, StringComparison.Ordinal)) continue; selectedIndex = i; break; } } RebuildSuggestionRows(); } private void RebuildSuggestionRows() { if (suggestionList == null) return; suggestionList.Clear(); if (suggestions.Count == 0) { suggestionList.style.display = DisplayStyle.None; return; } suggestionList.style.display = DisplayStyle.Flex; // Window the list around the highlight so arrowing past row 12 keeps the // selected row on screen instead of scrolling it out of the truncated view. int start = suggestions.Count > MaxVisibleSuggestions ? Mathf.Clamp(selectedIndex - MaxVisibleSuggestions / 2, 0, suggestions.Count - MaxVisibleSuggestions) : 0; int end = Mathf.Min(start + MaxVisibleSuggestions, suggestions.Count); for (int i = start; i < end; i++) suggestionList.Add(BuildSuggestionRow(suggestions[i].Text, suggestions[i].Hint, i == selectedIndex)); int hidden = suggestions.Count - (end - start); if (hidden > 0) suggestionList.Add(BuildSuggestionRow($"+{hidden} more", null, false)); } private void MoveSelection(int delta) { if (suggestions.Count == 0) return; selectedIndex = (selectedIndex + delta) % suggestions.Count; if (selectedIndex < 0) selectedIndex += suggestions.Count; RebuildSuggestionRows(); } private bool AcceptSelectedSuggestion() { if (commandInput == null) return false; if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false; var suggestion = suggestions[selectedIndex]; string text = commandInput.value ?? string.Empty; int start = Mathf.Clamp(fragmentStart, 0, text.Length); string completed = text.Substring(0, start) + suggestion.Text + (suggestion.AppendSpace ? " " : string.Empty); // SetValueWithoutNotify + an explicit refresh instead of assigning value: // one deterministic recompute, and the caret placement below can't race the // change callback. commandInput.SetValueWithoutNotify(completed); commandInput.SelectRange(completed.Length, completed.Length); RefreshSuggestions(); return true; } // ------------------------------------------------------------- execution private void ExecuteCommand(string text) { Tokenize(text, walkTokens, walkStarts); var node = CommandTree; int i = 0; while (i < walkTokens.Count && node.Values == null) { var child = FindChild(node, walkTokens[i]); if (child == null) { Debug.Log($"[DebugConsole] Unrecognized token \"{walkTokens[i]}\" in " + $"\"{text}\". Expected one of: {ChildTokens(node)}. {UsageHint}"); return; } node = child; i++; } if (node.Values == null || !node.Executable || i >= walkTokens.Count) { Debug.Log($"[DebugConsole] Incomplete command: \"{text}\". {UsageHint}"); return; } string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i)); ExecuteGet(node.Token, node.OptionType, nameToken); } // "get ": grants any DraftOption of the given type whose DisplayName // matches , via the local player's DebugCommandRelay. Reuses DraftOption's // existing type-agnostic ServerApply dispatch instead of hardcoding a grant path // per option type. private void ExecuteGet(string typeToken, Type optionType, string nameToken) { var pool = DraftPool.Instance; if (pool == null) { Debug.LogWarning("[DebugConsole] No DraftPool in this scene."); return; } string normalizedTarget = Normalize(nameToken); for (int i = 0; i < pool.Count; i++) { var option = pool.Get(i); if (option == null || !optionType.IsInstanceOfType(option)) continue; if (Normalize(option.DisplayName) != normalizedTarget) continue; var relay = DebugCommandRelay.Local; if (relay == null) { Debug.LogWarning("[DebugConsole] No local DebugCommandRelay found — " + "is it on the Player prefab?"); return; } relay.DebugGrantDraftOptionRpc(i); return; } Debug.LogWarning($"[DebugConsole] No {typeToken} option named \"{nameToken}\" found."); } // DisplayName is player-facing draft card text (its spacing/casing is chosen for // presentation, not for typing), so command matching strips everything but letters // and digits before comparing — "Slow Area", "slow_area", and "SlowArea" all match // the same option. private static string Normalize(string s) { if (string.IsNullOrEmpty(s)) return string.Empty; var sb = new StringBuilder(s.Length); foreach (char c in s) if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c)); return sb.ToString(); } private void OnDestroy() { if (Instance == this) Instance = null; } } }