diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs b/Assets/_Project/Scripts/Dev/DebugConsole.cs index 869be74..03a7474 100644 --- a/Assets/_Project/Scripts/Dev/DebugConsole.cs +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs @@ -16,26 +16,46 @@ namespace TD.Dev /// input, type a command, Enter to submit, Escape to cancel. /// /// - /// Deliberately decoupled from — own - /// GameObject, own UIDocument/PanelSettings — so it can be dropped out of - /// a build entirely by not including that GameObject, the same way - /// is kept separate from shipping systems. + /// 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 @@ -44,6 +64,12 @@ namespace TD.Dev // 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) @@ -89,7 +115,9 @@ namespace TD.Dev consoleContainer.style.paddingBottom = 8; consoleContainer.style.paddingLeft = 12; consoleContainer.style.paddingRight = 12; - consoleContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.75f); + // 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(); @@ -105,7 +133,24 @@ namespace TD.Dev 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); } @@ -115,7 +160,7 @@ namespace TD.Dev // debug console, not chat" cue. private static void StyleConsoleInput(TextField field) { - var darkBg = new Color(0.05f, 0.05f, 0.05f, 0.95f); + 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; @@ -137,6 +182,41 @@ namespace TD.Dev } } + // 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; @@ -160,12 +240,55 @@ namespace TD.Dev 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. @@ -199,27 +322,71 @@ namespace TD.Dev 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/cancelled this console also gets polled by - // HUDController's chat this same frame — suppress its "Enter opens chat" - // branch so closing the debug console doesn't pop chat open too. + // 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. + // 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) { @@ -229,32 +396,376 @@ namespace TD.Dev { "effect", typeof(BuilderEffectDraftOption) }, }; - private void ExecuteCommand(string text) + private CommandNode commandTree; + private CommandNode CommandTree => commandTree ?? (commandTree = BuildCommandTree()); + + private static CommandNode BuildCommandTree() { - string[] tokens = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length < 3 || !string.Equals(tokens[0], "get", StringComparison.OrdinalIgnoreCase)) + var root = new CommandNode(); + + var get = new CommandNode { - Debug.Log($"[DebugConsole] Unrecognized command: \"{text}\". Try: get "); + 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; } - string nameToken = string.Join(" ", tokens, 2, tokens.Length - 2); - ExecuteGet(tokens[1], nameToken); + 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, string nameToken) + private void ExecuteGet(string typeToken, Type optionType, string nameToken) { - if (!DraftTypeAliases.TryGetValue(typeToken, out var optionType)) - { - Debug.LogWarning($"[DebugConsole] Unknown draft option type \"{typeToken}\". " + - $"Valid types: {string.Join(", ", DraftTypeAliases.Keys)}"); - return; - } - var pool = DraftPool.Instance; if (pool == null) { diff --git a/Assets/_Project/Scripts/Dev/DevWaveControls.cs b/Assets/_Project/Scripts/Dev/DevWaveControls.cs index 2dfd6ff..2b941b1 100644 --- a/Assets/_Project/Scripts/Dev/DevWaveControls.cs +++ b/Assets/_Project/Scripts/Dev/DevWaveControls.cs @@ -52,6 +52,11 @@ namespace TD.Dev if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer) return; + // IMGUI draws after every runtime UI Toolkit panel, so this box would cover the + // debug console's autocomplete list no matter what sorting order the console's + // PanelSettings uses. Yield to the console while it's open. + if (DebugConsole.IsOpen) return; + // Anchored below the top HUD bar so it doesn't overlap gold/wave/lives. const float topOffset = 90f; GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls");