Merge branch 'main' of https://git.marlboro-bc.duckdns.org/yeahweregames/UnityTowerDefense
This commit is contained in:
commit
c88d3fc625
2 changed files with 540 additions and 24 deletions
|
|
@ -16,26 +16,46 @@ namespace TD.Dev
|
||||||
/// input, type a command, Enter to submit, Escape to cancel.
|
/// input, type a command, Enter to submit, Escape to cancel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Deliberately decoupled from <see cref="HUDController"/> — own
|
/// Deliberately decoupled from <see cref="HUDController"/> — own GameObject,
|
||||||
/// GameObject, own UIDocument/PanelSettings — so it can be dropped out of
|
/// own UIDocument (it shares HUDPanelSettings, drawn above the HUD via a
|
||||||
/// a build entirely by not including that GameObject, the same way
|
/// higher sorting order) — so it can be dropped out of a build entirely by
|
||||||
/// <see cref="DevWaveControls"/> is kept separate from shipping systems.
|
/// not including that GameObject, the same way <see cref="DevWaveControls"/>
|
||||||
|
/// is kept separate from shipping systems.
|
||||||
///
|
///
|
||||||
|
/// The grammar lives in a <see cref="CommandNode"/> tree (see
|
||||||
|
/// <see cref="BuildCommandTree"/>) 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
|
/// Supports one command so far: "get <type> <name>" grants a
|
||||||
/// <see cref="TD.Gameplay.Draft.DraftOption"/> to the local player via
|
/// <see cref="TD.Gameplay.Draft.DraftOption"/> to the local player via
|
||||||
/// <see cref="DebugCommandRelay"/>.
|
/// <see cref="DebugCommandRelay"/>.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(UIDocument))]
|
[RequireComponent(typeof(UIDocument))]
|
||||||
public class DebugConsole : MonoBehaviour
|
public class DebugConsole : MonoBehaviour
|
||||||
{
|
{
|
||||||
public static DebugConsole Instance { get; private set; }
|
public static DebugConsole Instance { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True while the console overlay is showing. Legacy IMGUI (<c>OnGUI</c>) 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.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsOpen => Instance != null && Instance.consoleOpen;
|
||||||
|
|
||||||
[Tooltip("Keyboard shortcut that opens and closes the console. Uses the new Input " +
|
[Tooltip("Keyboard shortcut that opens and closes the console. Uses the new Input " +
|
||||||
"System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")]
|
"System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")]
|
||||||
[SerializeField] private Key toggleKey = Key.Backquote;
|
[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 VisualElement consoleContainer;
|
||||||
private TextField commandInput;
|
private TextField commandInput;
|
||||||
|
private VisualElement suggestionList;
|
||||||
private bool consoleOpen;
|
private bool consoleOpen;
|
||||||
|
|
||||||
// Frame on which the console was opened or closed. The toggle key and Enter
|
// 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.
|
// Mirrors HUDController's chatToggleSuppressFrame.
|
||||||
private int toggleSuppressFrame = -1;
|
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<Suggestion> suggestions = new List<Suggestion>();
|
||||||
|
private int selectedIndex;
|
||||||
|
private int fragmentStart;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
if (Instance != null && Instance != this)
|
if (Instance != null && Instance != this)
|
||||||
|
|
@ -89,7 +115,9 @@ namespace TD.Dev
|
||||||
consoleContainer.style.paddingBottom = 8;
|
consoleContainer.style.paddingBottom = 8;
|
||||||
consoleContainer.style.paddingLeft = 12;
|
consoleContainer.style.paddingLeft = 12;
|
||||||
consoleContainer.style.paddingRight = 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;
|
consoleContainer.style.display = DisplayStyle.None;
|
||||||
|
|
||||||
commandInput = new TextField();
|
commandInput = new TextField();
|
||||||
|
|
@ -105,7 +133,24 @@ namespace TD.Dev
|
||||||
commandInput.RegisterCallback<FocusInEvent>(_ => HUDController.IsTextInputActive = true);
|
commandInput.RegisterCallback<FocusInEvent>(_ => HUDController.IsTextInputActive = true);
|
||||||
commandInput.RegisterCallback<FocusOutEvent>(_ => HUDController.IsTextInputActive = false);
|
commandInput.RegisterCallback<FocusOutEvent>(_ => 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<KeyDownEvent>(OnConsoleKeyDown, TrickleDown.TrickleDown);
|
||||||
|
commandInput.RegisterCallback<NavigationMoveEvent>(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(commandInput);
|
||||||
|
consoleContainer.Add(suggestionList);
|
||||||
uiRoot.Add(consoleContainer);
|
uiRoot.Add(consoleContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +160,7 @@ namespace TD.Dev
|
||||||
// debug console, not chat" cue.
|
// debug console, not chat" cue.
|
||||||
private static void StyleConsoleInput(TextField field)
|
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);
|
var borderClr = new Color(0.35f, 0.75f, 0.35f);
|
||||||
|
|
||||||
field.style.backgroundColor = darkBg;
|
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()
|
private void Update()
|
||||||
{
|
{
|
||||||
var kb = Keyboard.current;
|
var kb = Keyboard.current;
|
||||||
|
|
@ -160,12 +240,55 @@ namespace TD.Dev
|
||||||
else if (escDown) CloseConsole();
|
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()
|
private void OpenConsole()
|
||||||
{
|
{
|
||||||
if (commandInput == null) return;
|
if (commandInput == null) return;
|
||||||
consoleContainer.style.display = DisplayStyle.Flex;
|
consoleContainer.style.display = DisplayStyle.Flex;
|
||||||
consoleContainer.pickingMode = PickingMode.Position;
|
consoleContainer.pickingMode = PickingMode.Position;
|
||||||
commandInput.SetValueWithoutNotify(string.Empty);
|
commandInput.SetValueWithoutNotify(string.Empty);
|
||||||
|
RefreshSuggestions();
|
||||||
|
|
||||||
// Suppress the toggle key and Enter for this frame and the next so the
|
// 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.
|
// keypress that opened the console doesn't also get typed into the field.
|
||||||
|
|
@ -199,27 +322,71 @@ namespace TD.Dev
|
||||||
consoleContainer.pickingMode = PickingMode.Ignore;
|
consoleContainer.pickingMode = PickingMode.Ignore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suggestions.Clear();
|
||||||
|
selectedIndex = 0;
|
||||||
|
if (suggestionList != null)
|
||||||
|
{
|
||||||
|
suggestionList.Clear();
|
||||||
|
suggestionList.style.display = DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
consoleOpen = false;
|
consoleOpen = false;
|
||||||
toggleSuppressFrame = Time.frameCount + 1;
|
toggleSuppressFrame = Time.frameCount + 1;
|
||||||
|
SuppressHudChatOpen();
|
||||||
|
}
|
||||||
|
|
||||||
// The Enter that submitted/cancelled this console also gets polled by
|
// The Enter that submitted/accepted in this console also gets polled by
|
||||||
// HUDController's chat this same frame — suppress its "Enter opens chat"
|
// HUDController's chat this same frame — suppress its "Enter opens chat" branch
|
||||||
// branch so closing the debug console doesn't pop chat open too.
|
// (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;
|
HUDController.SuppressChatOpenUntilFrame = Time.frameCount + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SubmitCommand()
|
private void SubmitCommand()
|
||||||
{
|
{
|
||||||
string text = commandInput?.value ?? string.Empty;
|
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();
|
CloseConsole();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(text)) return;
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
ExecuteCommand(text.Trim());
|
ExecuteCommand(text.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- grammar
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One token level of the command grammar. Literal levels list their
|
||||||
|
/// <see cref="Children"/>; a level that takes a free-form final argument sets
|
||||||
|
/// <see cref="Values"/> to enumerate the valid values for completion.
|
||||||
|
/// </summary>
|
||||||
|
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<List<string>> 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<CommandNode> Children = new List<CommandNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string UsageHint = "Try: get <type> <name>";
|
||||||
|
|
||||||
// Maps the type token in "get <type> <name>" to the DraftOption subclass it
|
// Maps the type token in "get <type> <name>" to the DraftOption subclass it
|
||||||
// should match against. Add an entry here whenever a new DraftOption subclass
|
// 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<string, Type> DraftTypeAliases =
|
private static readonly Dictionary<string, Type> DraftTypeAliases =
|
||||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
|
|
@ -229,32 +396,376 @@ namespace TD.Dev
|
||||||
{ "effect", typeof(BuilderEffectDraftOption) },
|
{ "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);
|
var root = new CommandNode();
|
||||||
if (tokens.Length < 3 || !string.Equals(tokens[0], "get", StringComparison.OrdinalIgnoreCase))
|
|
||||||
|
var get = new CommandNode
|
||||||
{
|
{
|
||||||
Debug.Log($"[DebugConsole] Unrecognized command: \"{text}\". Try: get <type> <name>");
|
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 = "<name>",
|
||||||
|
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<string> DraftOptionNames(Type optionType)
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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<string> tokens, List<int> 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<string> walkTokens = new List<string>();
|
||||||
|
private readonly List<int> walkStarts = new List<int>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Candidates for the token currently being typed, plus (via
|
||||||
|
/// <paramref name="fragStart"/>) the index in <paramref name="text"/> where
|
||||||
|
/// accepting one starts overwriting.
|
||||||
|
/// </summary>
|
||||||
|
private List<Suggestion> 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<Suggestion>();
|
||||||
|
}
|
||||||
|
|
||||||
|
node = child;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Suggestion> FilterChildren(CommandNode node, string fragment)
|
||||||
|
{
|
||||||
|
var result = new List<Suggestion>();
|
||||||
|
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<Suggestion> FilterValues(List<string> values, string fragment)
|
||||||
|
{
|
||||||
|
var result = new List<Suggestion>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="text"/> is a runnable command as typed.</summary>
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string nameToken = string.Join(" ", tokens, 2, tokens.Length - 2);
|
suggestionList.style.display = DisplayStyle.Flex;
|
||||||
ExecuteGet(tokens[1], nameToken);
|
|
||||||
|
// 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 <type> <name>": grants any DraftOption of the given type whose DisplayName
|
// "get <type> <name>": grants any DraftOption of the given type whose DisplayName
|
||||||
// matches <name>, via the local player's DebugCommandRelay. Reuses DraftOption's
|
// matches <name>, via the local player's DebugCommandRelay. Reuses DraftOption's
|
||||||
// existing type-agnostic ServerApply dispatch instead of hardcoding a grant path
|
// existing type-agnostic ServerApply dispatch instead of hardcoding a grant path
|
||||||
// per option type.
|
// 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;
|
var pool = DraftPool.Instance;
|
||||||
if (pool == null)
|
if (pool == null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ namespace TD.Dev
|
||||||
if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer)
|
if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer)
|
||||||
return;
|
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.
|
// Anchored below the top HUD bar so it doesn't overlap gold/wave/lives.
|
||||||
const float topOffset = 90f;
|
const float topOffset = 90f;
|
||||||
GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls");
|
GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue