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");