diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 08614f3..6e06dfd 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -18,6 +18,7 @@ GameObject: - component: {fileID: 2806524246861401801} - component: {fileID: 5683786710272852339} - component: {fileID: 6856236869205671849} + - component: {fileID: -4438158474568445526} m_Layer: 0 m_Name: Player m_TagString: Untagged @@ -173,3 +174,16 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerSpellLoadout ShowTopMostFoldoutHeaderGroup: 1 +--- !u!114 &-4438158474568445526 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3493329038866903420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 65825b3189ae873948659b37b219f5e6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Dev.DebugCommandRelay + ShowTopMostFoldoutHeaderGroup: 1 diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index 0fcac59..8c21309 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -13757,6 +13757,75 @@ Mesh: - serializedVersion: 1 m_IndexStart: 0 m_IndexCount: 0 +--- !u!1 &954434158 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 954434161} + - component: {fileID: 954434160} + - component: {fileID: 954434159} + m_Layer: 0 + m_Name: DebugConsole + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &954434159 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 954434158} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7c6a808e8291552d0b64e36a210879c4, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Dev.DebugConsole + toggleKey: 4 +--- !u!114 &954434160 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 954434158} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0} + m_Name: + m_EditorClassIdentifier: UnityEngine.dll::UnityEngine.UIElements.UIDocument + m_PanelSettings: {fileID: 11400000, guid: 6aa0af71585acea4db4995c3931dc946, type: 2} + m_ParentUI: {fileID: 0} + sourceAsset: {fileID: 0} + m_SortingOrder: 1 + m_Position: 0 + m_WorldSpaceSizeMode: 1 + m_WorldSpaceWidth: 1920 + m_WorldSpaceHeight: 1080 + m_PivotReferenceSize: 0 + m_Pivot: 0 + m_WorldSpaceCollider: {fileID: 0} +--- !u!4 &954434161 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 954434158} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -52.51635, y: 0, z: -27.6311} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &955672358 GameObject: m_ObjectHideFlags: 0 @@ -29118,3 +29187,4 @@ SceneRoots: - {fileID: 914832726} - {fileID: 993870445} - {fileID: 759470735} + - {fileID: 954434161} diff --git a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs new file mode 100644 index 0000000..3af3076 --- /dev/null +++ b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs @@ -0,0 +1,78 @@ +// Assets/_Project/Scripts/Dev/DebugCommandRelay.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; +using TD.Gameplay.Draft; + +namespace TD.Dev +{ + /// + /// Debug-only network relay: exposes one RPC that force-applies a + /// by its index, bypassing PlayerDraft's "must currently be + /// offered" check. + /// + /// + /// Lives entirely separate from PlayerDraft/DraftOption/DraftPool — none of those files + /// know this component exists. Removing this component (and TD.Dev.DebugConsole, which + /// calls it) leaves the shipping draft system completely untouched. + /// + /// Added to the Player prefab alongside PlayerDraft/PlayerGoldManager/etc. so it spawns + /// with a per-player OwnerClientId. Static per-client registry mirrors PlayerDraft's + /// GetForClient/Local pattern. + /// + public class DebugCommandRelay : NetworkBehaviour + { + private static readonly Dictionary s_byClientId + = new Dictionary(); + + public static DebugCommandRelay GetForClient(ulong clientId) + { + s_byClientId.TryGetValue(clientId, out var relay); + return relay; + } + + public static DebugCommandRelay Local + { + get + { + var nm = NetworkManager.Singleton; + if (nm == null || !nm.IsClient) return null; + return GetForClient(nm.LocalClientId); + } + } + + public override void OnNetworkSpawn() + { + s_byClientId[OwnerClientId] = this; + } + + public override void OnNetworkDespawn() + { + if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) + s_byClientId.Remove(OwnerClientId); + } + + /// + /// Owning client: force-apply the DraftOption at (its + /// DraftPool index) immediately, skipping the normal "must be currently offered" gate. + /// Debug/testing only — any connected client can grant themselves anything via this + /// RPC, same tradeoff DevWaveControls already accepts for its hotkeys. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void DebugGrantDraftOptionRpc(int optionId) + { + var option = DraftPool.Instance != null ? DraftPool.Instance.Get(optionId) : null; + if (option == null) + { + Debug.LogWarning($"[DebugCommandRelay] Grant requested for unknown option id {optionId}."); + return; + } + + if (option.ServerApply(OwnerClientId)) + Debug.Log($"[DebugCommandRelay] Granted '{option.DisplayName}' to client {OwnerClientId}."); + else + Debug.LogWarning($"[DebugCommandRelay] '{option.DisplayName}' could not be applied " + + $"to client {OwnerClientId}."); + } + } +} diff --git a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs.meta b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs.meta new file mode 100644 index 0000000..d7a2598 --- /dev/null +++ b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65825b3189ae873948659b37b219f5e6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs b/Assets/_Project/Scripts/Dev/DebugConsole.cs new file mode 100644 index 0000000..869be74 --- /dev/null +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs @@ -0,0 +1,305 @@ +// 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; + } + } +} diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs.meta b/Assets/_Project/Scripts/Dev/DebugConsole.cs.meta new file mode 100644 index 0000000..c9a3292 --- /dev/null +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c6a808e8291552d0b64e36a210879c4 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 03cb3b0..badd2b8 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -157,8 +157,19 @@ namespace TD.UI // Set true whenever the chat input or any other text field on the HUD has // keyboard focus. Camera, builder input, and hotkey handlers all gate on - // this to keep typing from driving gameplay. - public static bool IsTextInputActive { get; private set; } + // this to keep typing from driving gameplay. Setter is internal (not + // private) so other standalone text-input surfaces — e.g. TD.Dev.DebugConsole, + // which is not part of HUDController — can contribute to the same flag + // instead of every gameplay script needing to know about every input widget. + public static bool IsTextInputActive { get; internal set; } + + // Frame until which chat's "Enter opens chat" behavior is suppressed. Other + // standalone text-input surfaces (e.g. TD.Dev.DebugConsole) set this when their + // own Enter-driven submit/close already consumed the keypress, so the same + // Enter press doesn't also get interpreted as "open chat" this frame. Without + // this, submitting a debug console command with Enter would immediately pop + // chat open too, since both systems poll the raw key independently. + internal static int SuppressChatOpenUntilFrame = -1; // ----- State ------------------------------------------------------ @@ -2017,7 +2028,7 @@ namespace TD.UI if (!chatInputOpen) { - if (enterDown) OpenChatInput(); + if (enterDown && Time.frameCount > SuppressChatOpenUntilFrame) OpenChatInput(); return; }