// 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/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. /// /// Supports one command so far: "get <type> <name>" grants a /// to the local player via /// . /// [RequireComponent(typeof(UIDocument))] public class DebugConsole : MonoBehaviour { public static DebugConsole Instance { get; private set; } [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; private VisualElement consoleContainer; private TextField commandInput; 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; 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; consoleContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.75f); 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); consoleContainer.Add(commandInput); 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, 0.95f); 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; } } 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 OpenConsole() { if (commandInput == null) return; consoleContainer.style.display = DisplayStyle.Flex; consoleContainer.pickingMode = PickingMode.Position; commandInput.SetValueWithoutNotify(string.Empty); // 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; } consoleOpen = false; toggleSuppressFrame = Time.frameCount + 1; // 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. HUDController.SuppressChatOpenUntilFrame = Time.frameCount + 1; } private void SubmitCommand() { string text = commandInput?.value ?? string.Empty; CloseConsole(); if (string.IsNullOrWhiteSpace(text)) return; ExecuteCommand(text.Trim()); } // 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. private static readonly Dictionary DraftTypeAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "spell", typeof(BuilderSpellDraftOption) }, { "tower", typeof(NewTowerDraftOption) }, { "buff", typeof(BuilderEffectDraftOption) }, { "effect", typeof(BuilderEffectDraftOption) }, }; private void ExecuteCommand(string text) { string[] tokens = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); if (tokens.Length < 3 || !string.Equals(tokens[0], "get", StringComparison.OrdinalIgnoreCase)) { Debug.Log($"[DebugConsole] Unrecognized command: \"{text}\". Try: get "); return; } string nameToken = string.Join(" ", tokens, 2, tokens.Length - 2); ExecuteGet(tokens[1], 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) { 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) { 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; } } }