From dc9184075b30b71e8cdc45af6f095129ac308232 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 4 Aug 2026 20:40:32 -0700 Subject: [PATCH 1/5] grant enemy abilities in debug console --- .../_Project/Scripts/Dev/DebugCommandRelay.cs | 39 +++ Assets/_Project/Scripts/Dev/DebugConsole.cs | 299 +++++++----------- Assets/_Project/Scripts/Dev/DebugConsoleUI.cs | 185 +++++++++++ .../Scripts/Dev/DebugConsoleUI.cs.meta | 2 + 4 files changed, 347 insertions(+), 178 deletions(-) create mode 100644 Assets/_Project/Scripts/Dev/DebugConsoleUI.cs create mode 100644 Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta diff --git a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs index 3af3076..360b2a3 100644 --- a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs +++ b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Gameplay.Draft; +using TD.Gameplay.EnemyUpgrades; +using TD.Gameplay.Waves; namespace TD.Dev { @@ -74,5 +76,42 @@ namespace TD.Dev Debug.LogWarning($"[DebugCommandRelay] '{option.DisplayName}' could not be applied " + $"to client {OwnerClientId}."); } + + /// + /// Force-apply the at (its + /// index) to the run's current wave slot — the same + /// recording WaveVote.ServerResolve does for a vote winner, skipping the vote + /// entirely. Not player-scoped: the effect lands on the run's current wave slot rather + /// than the caller, so any connected client's relay can trigger it. + /// + /// + /// Takes effect the next time this wave slot's enemies spawn, not retroactively on + /// enemies already alive — see 's remarks on why + /// applying is "record on the slot," not a direct mutation. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void DebugGrantEnemyUpgradeRpc(int optionId) + { + var pool = EnemyUpgradePool.Instance; + var option = pool != null ? pool.Get(optionId) : null; + if (option == null) + { + Debug.LogWarning($"[DebugCommandRelay] Grant requested for unknown enemy upgrade id {optionId}."); + return; + } + + var run = RunState.Instance; + if (run == null) + { + Debug.LogWarning("[DebugCommandRelay] No RunState in this scene."); + return; + } + + int slot = run.CurrentUpgradeSlot; + run.ServerAddUpgrade(slot, optionId); + option.ServerOnApplied(run, slot); + + Debug.Log($"[DebugCommandRelay] Granted '{option.DisplayName}' to wave slot {slot}."); + } } } diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs b/Assets/_Project/Scripts/Dev/DebugConsole.cs index 03a7474..718a038 100644 --- a/Assets/_Project/Scripts/Dev/DebugConsole.cs +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs @@ -8,6 +8,7 @@ using UnityEngine.InputSystem; using UnityEngine.UIElements; using TD.UI; using TD.Gameplay.Draft; +using TD.Gameplay.EnemyUpgrades; namespace TD.Dev { @@ -49,13 +50,7 @@ namespace TD.Dev "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 DebugConsoleUI ui; private bool consoleOpen; // Frame on which the console was opened or closed. The toggle key and Enter @@ -98,123 +93,23 @@ namespace TD.Dev 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; - // 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(); - commandInput.style.minHeight = 28; - commandInput.style.width = Length.Percent(100); - commandInput.maxLength = 200; - commandInput.isDelayed = false; - StyleConsoleInput(commandInput); + ui = new DebugConsoleUI(root); // 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); + ui.CommandInput.RegisterCallback(_ => HUDController.IsTextInputActive = true); + ui.CommandInput.RegisterCallback(_ => HUDController.IsTextInputActive = false); - commandInput.RegisterValueChangedCallback(_ => RefreshSuggestions()); + ui.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); - } - - // 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, 1f); - 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; - } - } - - // 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; + ui.CommandInput.RegisterCallback(OnConsoleKeyDown, TrickleDown.TrickleDown); + ui.CommandInput.RegisterCallback(OnConsoleNavigationMove, + TrickleDown.TrickleDown); } private void Update() @@ -279,15 +174,14 @@ namespace TD.Dev private void Swallow(EventBase evt) { evt.StopImmediatePropagation(); - commandInput?.panel?.focusController?.IgnoreEvent(evt); + ui.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); + if (ui.CommandInput == null) return; + ui.Show(); + ui.CommandInput.SetValueWithoutNotify(string.Empty); RefreshSuggestions(); // Suppress the toggle key and Enter for this frame and the next so the @@ -302,33 +196,25 @@ namespace TD.Dev private IEnumerator FocusNextFrame() { yield return null; - if (consoleOpen && commandInput != null) + if (consoleOpen && ui.CommandInput != null) { - commandInput.Focus(); - commandInput.SelectAll(); + ui.CommandInput.Focus(); + ui.CommandInput.SelectAll(); } } private void CloseConsole() { - if (commandInput != null) + if (ui.CommandInput != null) { - commandInput.SetValueWithoutNotify(string.Empty); - commandInput.Blur(); - } - if (consoleContainer != null) - { - consoleContainer.style.display = DisplayStyle.None; - consoleContainer.pickingMode = PickingMode.Ignore; + ui.CommandInput.SetValueWithoutNotify(string.Empty); + ui.CommandInput.Blur(); } + ui.Hide(); suggestions.Clear(); selectedIndex = 0; - if (suggestionList != null) - { - suggestionList.Clear(); - suggestionList.style.display = DisplayStyle.None; - } + ui.ClearSuggestions(); consoleOpen = false; toggleSuppressFrame = Time.frameCount + 1; @@ -346,7 +232,7 @@ namespace TD.Dev private void SubmitCommand() { - string text = commandInput?.value ?? string.Empty; + string text = ui.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 @@ -377,11 +263,11 @@ namespace TD.Dev 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 Action Execute; // "Values" level: run the completed command public readonly List Children = new List(); } - private const string UsageHint = "Try: get "; + private const string UsageHint = "Try: get , grant enemy "; // Maps the type token in "get " to the DraftOption subclass it // should match against. Add an entry here whenever a new DraftOption subclass @@ -412,17 +298,34 @@ namespace TD.Dev foreach (var pair in DraftTypeAliases) { + string typeToken = pair.Key; var optionType = pair.Value; get.Children.Add(new CommandNode { - Token = pair.Key, + Token = typeToken, Hint = "", - OptionType = optionType, Executable = true, Values = () => DraftOptionNames(optionType), + Execute = name => ExecuteGet(typeToken, optionType, name), }); } + var grant = new CommandNode + { + Token = "grant", + Hint = "grant a buff to the enemies", + }; + root.Children.Add(grant); + + grant.Children.Add(new CommandNode + { + Token = "enemy", + Hint = "", + Executable = true, + Values = EnemyUpgradeNames, + Execute = ExecuteGrantEnemyAbility, + }); + return root; } @@ -454,6 +357,34 @@ namespace TD.Dev return names; } + // Display names of every pooled AbilityEnemyUpgradeOption, alphabetically. Mirrors + // DraftOptionNames above — EnemyUpgradePool is the enemy-side equivalent of DraftPool, + // and this is the same Count/Get surface ExecuteGrantEnemyAbility walks. Non-ability + // cards (e.g. the vote-meta "Double Dip") aren't an AbilityEnemyUpgradeOption, so they + // don't show up here even though they're in the pool. + private static List EnemyUpgradeNames() + { + var names = new List(); + + var pool = EnemyUpgradePool.Instance; + if (pool == null) return names; + + for (int i = 0; i < pool.Count; i++) + { + var option = pool.Get(i) as AbilityEnemyUpgradeOption; + if (option == null) 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) { @@ -640,7 +571,7 @@ namespace TD.Dev private void RefreshSuggestions() { - if (commandInput == null) return; + if (ui.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. @@ -648,7 +579,7 @@ namespace TD.Dev ? suggestions[selectedIndex].Text : null; - suggestions = ComputeSuggestions(commandInput.value, out fragmentStart); + suggestions = ComputeSuggestions(ui.CommandInput.value, out fragmentStart); selectedIndex = 0; if (previouslySelected != null) @@ -662,38 +593,15 @@ namespace TD.Dev } } - RebuildSuggestionRows(); + ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex); } - private void RebuildSuggestionRows() + private static List ToSuggestionRows(List suggestions) { - if (suggestionList == null) return; - - suggestionList.Clear(); - - if (suggestions.Count == 0) - { - suggestionList.style.display = DisplayStyle.None; - return; - } - - 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)); + var rows = new List(suggestions.Count); + for (int i = 0; i < suggestions.Count; i++) + rows.Add(new DebugConsoleUI.SuggestionRow(suggestions[i].Text, suggestions[i].Hint)); + return rows; } private void MoveSelection(int delta) @@ -703,16 +611,16 @@ namespace TD.Dev selectedIndex = (selectedIndex + delta) % suggestions.Count; if (selectedIndex < 0) selectedIndex += suggestions.Count; - RebuildSuggestionRows(); + ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex); } private bool AcceptSelectedSuggestion() { - if (commandInput == null) return false; + if (ui.CommandInput == null) return false; if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false; var suggestion = suggestions[selectedIndex]; - string text = commandInput.value ?? string.Empty; + string text = ui.CommandInput.value ?? string.Empty; int start = Mathf.Clamp(fragmentStart, 0, text.Length); string completed = text.Substring(0, start) + suggestion.Text + @@ -721,8 +629,8 @@ namespace TD.Dev // 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); + ui.CommandInput.SetValueWithoutNotify(completed); + ui.CommandInput.SelectRange(completed.Length, completed.Length); RefreshSuggestions(); return true; } @@ -757,14 +665,14 @@ namespace TD.Dev } string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i)); - ExecuteGet(node.Token, node.OptionType, nameToken); + node.Execute(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, Type optionType, string nameToken) + private static void ExecuteGet(string typeToken, Type optionType, string nameToken) { var pool = DraftPool.Instance; if (pool == null) @@ -795,6 +703,41 @@ namespace TD.Dev Debug.LogWarning($"[DebugConsole] No {typeToken} option named \"{nameToken}\" found."); } + // "grant enemy ": the same effect as the post-wave vote picking that ability + // card for the current wave slot — records it via RunState.ServerAddUpgrade and runs + // its ServerOnApplied, mirroring WaveVote.ServerResolve exactly (see DebugCommandRelay. + // DebugGrantEnemyUpgradeRpc). Takes effect the next time this wave slot's enemies + // spawn, not retroactively on enemies already alive. + private static void ExecuteGrantEnemyAbility(string nameToken) + { + var pool = EnemyUpgradePool.Instance; + if (pool == null) + { + Debug.LogWarning("[DebugConsole] No EnemyUpgradePool in this scene."); + return; + } + + string normalizedTarget = Normalize(nameToken); + for (int i = 0; i < pool.Count; i++) + { + var option = pool.Get(i) as AbilityEnemyUpgradeOption; + if (option == null || 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.DebugGrantEnemyUpgradeRpc(i); + return; + } + + Debug.LogWarning($"[DebugConsole] No enemy ability 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 diff --git a/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs b/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs new file mode 100644 index 0000000..808dd29 --- /dev/null +++ b/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs @@ -0,0 +1,185 @@ +// Assets/_Project/Scripts/Dev/DebugConsoleUI.cs +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UIElements; + +namespace TD.Dev +{ + /// + /// Builds and draws 's VisualElement tree: the input bar and + /// its autocomplete list. Owns presentation only — element construction, styling, and + /// row rendering — no command grammar, keybinding, or suggestion-selection logic; that + /// stays in , which drives this class through its public + /// members and reads/writes directly for focus and value. + /// + internal sealed class DebugConsoleUI + { + // 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; + + /// One row of the autocomplete list: a candidate token plus an optional dim hint. + public readonly struct SuggestionRow + { + public readonly string Text; + public readonly string Hint; + + public SuggestionRow(string text, string hint) + { + Text = text; + Hint = hint; + } + } + + public TextField CommandInput { get; } + + private readonly VisualElement consoleContainer; + private readonly VisualElement suggestionList; + + public DebugConsoleUI(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; + // 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(); + CommandInput.style.minHeight = 28; + CommandInput.style.width = Length.Percent(100); + CommandInput.maxLength = 200; + CommandInput.isDelayed = false; + StyleConsoleInput(CommandInput); + + 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); + } + + // 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, 1f); + 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; + } + } + + // 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; + } + + public void Show() + { + consoleContainer.style.display = DisplayStyle.Flex; + consoleContainer.pickingMode = PickingMode.Position; + } + + public void Hide() + { + consoleContainer.style.display = DisplayStyle.None; + consoleContainer.pickingMode = PickingMode.Ignore; + } + + // Windows the list around selectedIndex so arrowing past MaxVisibleSuggestions + // keeps the selected row on screen instead of scrolling it out of the truncated view. + public void RenderSuggestions(List rows, int selectedIndex) + { + suggestionList.Clear(); + + if (rows == null || rows.Count == 0) + { + suggestionList.style.display = DisplayStyle.None; + return; + } + + suggestionList.style.display = DisplayStyle.Flex; + + int start = rows.Count > MaxVisibleSuggestions + ? Mathf.Clamp(selectedIndex - MaxVisibleSuggestions / 2, 0, + rows.Count - MaxVisibleSuggestions) + : 0; + int end = Mathf.Min(start + MaxVisibleSuggestions, rows.Count); + + for (int i = start; i < end; i++) + suggestionList.Add(BuildSuggestionRow(rows[i].Text, rows[i].Hint, i == selectedIndex)); + + int hidden = rows.Count - (end - start); + if (hidden > 0) + suggestionList.Add(BuildSuggestionRow($"+{hidden} more", null, false)); + } + + public void ClearSuggestions() + { + suggestionList.Clear(); + suggestionList.style.display = DisplayStyle.None; + } + } +} diff --git a/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta b/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta new file mode 100644 index 0000000..66e439e --- /dev/null +++ b/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fce39b2f68ef0f3f9bec299aa46d353a \ No newline at end of file From f4a0dc57c3f380420df7fadf0b727c6552a551e2 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 4 Aug 2026 21:03:54 -0700 Subject: [PATCH 2/5] blink moves forward a short number of meters, not along waypoint boundaries --- .../EnemyAbilities/BlinkAbility.asset | 2 +- .../EnemyAbilities/BlinkAbilityDefinition.cs | 9 ++- .../Scripts/Gameplay/EnemyMovement.cs | 63 ++++++++++++++----- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset index 6cc7cf7..cba6d55 100644 --- a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset @@ -15,5 +15,5 @@ MonoBehaviour: DisplayName: Blink Description: Enemies occasionally teleport a short distance ahead. IntervalSeconds: 4 - BlinkWaypoints: 2 + BlinkDistanceMeters: 4 StartJitterSeconds: 1.5 diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs index 236c8b7..14f76f6 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs @@ -27,10 +27,9 @@ namespace TD.Gameplay.EnemyAbilities [Min(0.1f)] public float IntervalSeconds = 4f; - [Tooltip("How many path waypoints to skip per blink. Note that path smoothing can make " + - "one waypoint span several tiles, so this is a coarser dial than it looks.")] - [Min(1)] - public int BlinkWaypoints = 2; + [Tooltip("World units to advance along the path per blink.")] + [Min(0.1f)] + public float BlinkDistanceMeters = 4f; [Tooltip("Random spread (seconds) added to each enemy's first blink so a wave doesn't " + "blink in one synchronized block. Applied once, at spawn.")] @@ -54,7 +53,7 @@ namespace TD.Gameplay.EnemyAbilities if (timer < IntervalSeconds) return; timer = 0f; - instance.GetComponent()?.ServerBlinkForward(BlinkWaypoints); + instance.GetComponent()?.ServerBlinkForward(BlinkDistanceMeters); } } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index 147eaf9..6815ff2 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -286,39 +286,70 @@ namespace TD.Gameplay /// /// Server-only: teleport this enemy forward along its existing path by up to - /// waypoints. Used by the Blink wave buff. + /// world units. Used by the Blink wave buff. /// /// /// Follows the path rather than the straight line to the goal. Jumping toward the /// goal as the crow flies would let a grounded enemy blink through walls, which is the - /// flying buff's job, not this one. Skipping waypoints keeps the enemy inside the maze — + /// flying buff's job, not this one. Walking the path keeps the enemy inside the maze — /// it cuts corners, it doesn't ignore them. /// - /// Zone tracking still runs on the destination tile, so an enemy that blinks out of - /// its origin zone still credits its owner a leak. Skipping that would make the buff a way - /// to launder leaks past the no-leak bonus. + /// Distance is measured along the path (waypoint to waypoint), consuming the + /// budget one segment at a time, so the landing spot can fall mid-segment rather than + /// snapping to a waypoint. This keeps the blink distance predictable regardless of how + /// far apart the path's waypoints happen to be — unlike skipping a fixed waypoint count, + /// where one blink could span a few tiles or the length of the whole map depending on + /// path smoothing. /// - /// Blinking onto the final waypoint resolves as a normal goal arrival, despawn and - /// life cost included. + /// Zone tracking runs once, on the final landing tile, so an enemy that blinks out + /// of its origin zone still credits its owner a leak. Skipping that would make the buff a + /// way to launder leaks past the no-leak bonus. + /// + /// Blinking onto (or past) the final waypoint resolves as a normal goal arrival, + /// despawn and life cost included. /// - public void ServerBlinkForward(int tiles) + public void ServerBlinkForward(float meters) { - if (!IsServer || tiles <= 0) return; + if (!IsServer || meters <= 0f) return; if (hasReachedGoal || remainingPath.Count == 0) return; if (health != null && health.IsHeld) return; - int skip = Mathf.Min(tiles, remainingPath.Count); - Vector2Int destination = remainingPath[skip - 1]; - remainingPath.RemoveRange(0, skip); + float remaining = meters; + Vector3 position = transform.position; + + while (remaining > 0f && remainingPath.Count > 0) + { + Vector3 targetWorld = GridCoordinates.GridToWorld(remainingPath[0]); + Vector3 toTarget = targetWorld - position; + toTarget.y = 0f; + float distance = toTarget.magnitude; + + if (distance <= remaining) + { + // Reach this waypoint fully and keep walking the leftover budget + // into the next segment. + remaining -= distance; + position = new Vector3(targetWorld.x, position.y, targetWorld.z); + remainingPath.RemoveAt(0); + } + else + { + // Budget runs out partway along this segment. + position += toTarget.normalized * remaining; + remaining = 0f; + } + } // Preserve Y so flyers stay at altitude and grounded pivots don't sink. - Vector3 world = GridCoordinates.GridToWorld(destination); - transform.position = new Vector3(world.x, transform.position.y, world.z); - - CheckZoneTransition(destination); + transform.position = position; if (remainingPath.Count == 0) + { HandleGoalReached(); + return; + } + + CheckZoneTransition(GridCoordinates.WorldToGrid(position)); } // ----- Path invalidation ---------------------------------------------- From ce56b6d829291817feea3940fc75031e027e24d1 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 4 Aug 2026 21:47:08 -0700 Subject: [PATCH 3/5] smear out blink times so enemies don't blink in clusters --- .../EnemyAbilities/BlinkAbility.asset | 3 +-- .../EnemyAbilities/BlinkAbilityDefinition.cs | 17 +++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset index cba6d55..ec1b7b5 100644 --- a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset @@ -14,6 +14,5 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.BlinkAbilityDefinition DisplayName: Blink Description: Enemies occasionally teleport a short distance ahead. - IntervalSeconds: 4 + IntervalSeconds: 8 BlinkDistanceMeters: 4 - StartJitterSeconds: 1.5 diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs index 14f76f6..c00904c 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs @@ -25,25 +25,22 @@ namespace TD.Gameplay.EnemyAbilities [Header("Blink")] [Tooltip("Seconds between blinks.")] [Min(0.1f)] - public float IntervalSeconds = 4f; + public float IntervalSeconds = 8f; [Tooltip("World units to advance along the path per blink.")] [Min(0.1f)] public float BlinkDistanceMeters = 4f; - [Tooltip("Random spread (seconds) added to each enemy's first blink so a wave doesn't " + - "blink in one synchronized block. Applied once, at spawn.")] - [Min(0f)] - public float StartJitterSeconds = 1.5f; - public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex) { - // Seed each enemy's cooldown with a different negative offset so the wave's first - // blink is staggered. Done once, here, rather than on the first tick — a tick-time + // Seed each enemy's cooldown with a random point within the full interval, as if it + // spawned already partway through a cooldown. A small jitter window near zero would + // still let a burst of enemies spawned together sync up and blink as a visible cluster + // every cycle; spreading across the whole interval avoids that regardless of how + // IntervalSeconds is tuned. Done once, here, rather than on the first tick — a tick-time // check would have to distinguish "never started" from "just blinked", and both // states read as a zero timer. - if (StartJitterSeconds > 0f) - instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds); + instance.TimerFor(abilityIndex) = -Random.Range(0f, IntervalSeconds); } public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt) From ef5fca2441a30dcd1cd1c175e103ddd9911e88c5 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 4 Aug 2026 22:13:49 -0700 Subject: [PATCH 4/5] add enemy identity definitions to fix split on death --- Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab | 4 ++-- .../_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab | 2 +- Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab index d912bec..4f54da4 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab @@ -53,7 +53,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 3037368120 + GlobalObjectIdHash: 3715663997 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: edc686813aef43743bb15d356b511dff, type: 2} deathAnimator: {fileID: 8642767442976997525} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab index bd7ac68..fb8210c 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: fe31ccfc7a429b24fb8f57279ba96c95, type: 2} deathAnimator: {fileID: 7317567842465160949} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab index d721ee2..e11218f 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab @@ -126,7 +126,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: 21c69bd4b2c55cd4fa9cd9b551c6e3fe, type: 2} deathAnimator: {fileID: 6910253555362952583} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab index 89ac836..d943a11 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: 52d9af0c680bca64a86d66fce2a73668, type: 2} deathAnimator: {fileID: 5079596453572633521} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab index f5ace60..06a8723 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: 4b981d8b44098894197507508f655072, type: 2} deathAnimator: {fileID: 162562342023078655} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab index 60b1291..829e743 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: bed87a08deeb7e048ba06c8268b0f959, type: 2} deathAnimator: {fileID: 6938810086909213800} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab index 2ab5755..b37096d 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: 77f4545654c5b7b41b2760a580da6068, type: 2} deathAnimator: {fileID: 2830498994423139172} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab index bca0e4a..063f5d2 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: 097d7ba78591cd443a06d637e0822a46, type: 2} deathAnimator: {fileID: 471366370285235598} deathAnimationDuration: 1.5 sinkDuration: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab index ef51e96..7994b3a 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 0} + definition: {fileID: 11400000, guid: 1d9809612c00ed049848a955613a4619, type: 2} deathAnimator: {fileID: 3345626123719039665} deathAnimationDuration: 1.5 sinkDuration: 1.5 From 6be9e2e39ab5417d69e003881ff71dff7aa521b1 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 4 Aug 2026 22:44:56 -0700 Subject: [PATCH 5/5] get double dip accessible via debug console --- Assets/_Project/Scripts/Dev/DebugConsole.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs b/Assets/_Project/Scripts/Dev/DebugConsole.cs index 718a038..6ffbf2b 100644 --- a/Assets/_Project/Scripts/Dev/DebugConsole.cs +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs @@ -357,11 +357,12 @@ namespace TD.Dev return names; } - // Display names of every pooled AbilityEnemyUpgradeOption, alphabetically. Mirrors + // Display names of every pooled EnemyUpgradeOption, alphabetically. Mirrors // DraftOptionNames above — EnemyUpgradePool is the enemy-side equivalent of DraftPool, - // and this is the same Count/Get surface ExecuteGrantEnemyAbility walks. Non-ability - // cards (e.g. the vote-meta "Double Dip") aren't an AbilityEnemyUpgradeOption, so they - // don't show up here even though they're in the pool. + // and this is the same Count/Get surface ExecuteGrantEnemyAbility walks. Includes + // non-ability cards too (e.g. the vote-meta "Double Dip") — DebugGrantEnemyUpgradeRpc + // resolves through EnemyUpgradeOption.ServerOnApplied regardless of subclass, so + // there's nothing this command can't actually grant. private static List EnemyUpgradeNames() { var names = new List(); @@ -371,7 +372,7 @@ namespace TD.Dev for (int i = 0; i < pool.Count; i++) { - var option = pool.Get(i) as AbilityEnemyUpgradeOption; + var option = pool.Get(i); if (option == null) continue; string name = option.DisplayName; @@ -720,7 +721,7 @@ namespace TD.Dev string normalizedTarget = Normalize(nameToken); for (int i = 0; i < pool.Count; i++) { - var option = pool.Get(i) as AbilityEnemyUpgradeOption; + var option = pool.Get(i); if (option == null || Normalize(option.DisplayName) != normalizedTarget) continue; var relay = DebugCommandRelay.Local;