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