// 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;
}
}
}