diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset
index 6cc7cf7..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
- BlinkWaypoints: 2
- StartJitterSeconds: 1.5
+ IntervalSeconds: 8
+ BlinkDistanceMeters: 4
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
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..6ffbf2b 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,35 @@ namespace TD.Dev
return names;
}
+ // 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. 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();
+
+ var pool = EnemyUpgradePool.Instance;
+ if (pool == null) return names;
+
+ for (int i = 0; i < pool.Count; i++)
+ {
+ var option = pool.Get(i);
+ 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 +572,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 +580,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 +594,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 +612,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 +630,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 +666,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 +704,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);
+ 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
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
index 236c8b7..c00904c 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
@@ -25,26 +25,22 @@ namespace TD.Gameplay.EnemyAbilities
[Header("Blink")]
[Tooltip("Seconds between blinks.")]
[Min(0.1f)]
- public float IntervalSeconds = 4f;
+ public float IntervalSeconds = 8f;
- [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("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;
+ [Tooltip("World units to advance along the path per blink.")]
+ [Min(0.1f)]
+ public float BlinkDistanceMeters = 4f;
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)
@@ -54,7 +50,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 ----------------------------------------------