Compare commits

...
Sign in to create a new pull request.

5 commits

16 changed files with 418 additions and 222 deletions

View file

@ -14,6 +14,5 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.BlinkAbilityDefinition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.BlinkAbilityDefinition
DisplayName: Blink DisplayName: Blink
Description: Enemies occasionally teleport a short distance ahead. Description: Enemies occasionally teleport a short distance ahead.
IntervalSeconds: 4 IntervalSeconds: 8
BlinkWaypoints: 2 BlinkDistanceMeters: 4
StartJitterSeconds: 1.5

View file

@ -53,7 +53,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 3037368120 GlobalObjectIdHash: 3715663997
InScenePlacedSourceGlobalObjectIdHash: 0 InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0 DeferredDespawnTick: 0
Ownership: 1 Ownership: 1
@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: edc686813aef43743bb15d356b511dff, type: 2}
deathAnimator: {fileID: 8642767442976997525} deathAnimator: {fileID: 8642767442976997525}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: fe31ccfc7a429b24fb8f57279ba96c95, type: 2}
deathAnimator: {fileID: 7317567842465160949} deathAnimator: {fileID: 7317567842465160949}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -126,7 +126,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: 21c69bd4b2c55cd4fa9cd9b551c6e3fe, type: 2}
deathAnimator: {fileID: 6910253555362952583} deathAnimator: {fileID: 6910253555362952583}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: 52d9af0c680bca64a86d66fce2a73668, type: 2}
deathAnimator: {fileID: 5079596453572633521} deathAnimator: {fileID: 5079596453572633521}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: 4b981d8b44098894197507508f655072, type: 2}
deathAnimator: {fileID: 162562342023078655} deathAnimator: {fileID: 162562342023078655}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: bed87a08deeb7e048ba06c8268b0f959, type: 2}
deathAnimator: {fileID: 6938810086909213800} deathAnimator: {fileID: 6938810086909213800}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: 77f4545654c5b7b41b2760a580da6068, type: 2}
deathAnimator: {fileID: 2830498994423139172} deathAnimator: {fileID: 2830498994423139172}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: 097d7ba78591cd443a06d637e0822a46, type: 2}
deathAnimator: {fileID: 471366370285235598} deathAnimator: {fileID: 471366370285235598}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -125,7 +125,7 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
definition: {fileID: 0} definition: {fileID: 11400000, guid: 1d9809612c00ed049848a955613a4619, type: 2}
deathAnimator: {fileID: 3345626123719039665} deathAnimator: {fileID: 3345626123719039665}
deathAnimationDuration: 1.5 deathAnimationDuration: 1.5
sinkDuration: 1.5 sinkDuration: 1.5

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
using TD.Gameplay.Draft; using TD.Gameplay.Draft;
using TD.Gameplay.EnemyUpgrades;
using TD.Gameplay.Waves;
namespace TD.Dev namespace TD.Dev
{ {
@ -74,5 +76,42 @@ namespace TD.Dev
Debug.LogWarning($"[DebugCommandRelay] '{option.DisplayName}' could not be applied " + Debug.LogWarning($"[DebugCommandRelay] '{option.DisplayName}' could not be applied " +
$"to client {OwnerClientId}."); $"to client {OwnerClientId}.");
} }
/// <summary>
/// Force-apply the <see cref="EnemyUpgradeOption"/> at <paramref name="optionId"/> (its
/// <see cref="EnemyUpgradePool"/> index) to the run's current wave slot — the same
/// recording <c>WaveVote.ServerResolve</c> 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.
/// </summary>
/// <remarks>
/// Takes effect the next time this wave slot's enemies spawn, not retroactively on
/// enemies already alive — see <see cref="EnemyUpgradeOption"/>'s remarks on why
/// applying is "record on the slot," not a direct mutation.
/// </remarks>
[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}.");
}
} }
} }

View file

@ -8,6 +8,7 @@ using UnityEngine.InputSystem;
using UnityEngine.UIElements; using UnityEngine.UIElements;
using TD.UI; using TD.UI;
using TD.Gameplay.Draft; using TD.Gameplay.Draft;
using TD.Gameplay.EnemyUpgrades;
namespace TD.Dev namespace TD.Dev
{ {
@ -49,13 +50,7 @@ namespace TD.Dev
"System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")] "System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")]
[SerializeField] private Key toggleKey = Key.Backquote; [SerializeField] private Key toggleKey = Key.Backquote;
// Beyond this many candidates the list is truncated with a "+N more" row so a private DebugConsoleUI ui;
// 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 bool consoleOpen; private bool consoleOpen;
// Frame on which the console was opened or closed. The toggle key and Enter // Frame on which the console was opened or closed. The toggle key and Enter
@ -98,123 +93,23 @@ namespace TD.Dev
return; return;
} }
BuildConsoleUi(root); ui = new DebugConsoleUI(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);
// Focus tracking — contributes to the same flag HUDController's chat // Focus tracking — contributes to the same flag HUDController's chat
// input sets, so camera/builder/spell input all stay gated correctly // input sets, so camera/builder/spell input all stay gated correctly
// regardless of which text field currently has focus. // regardless of which text field currently has focus.
commandInput.RegisterCallback<FocusInEvent>(_ => HUDController.IsTextInputActive = true); ui.CommandInput.RegisterCallback<FocusInEvent>(_ => HUDController.IsTextInputActive = true);
commandInput.RegisterCallback<FocusOutEvent>(_ => HUDController.IsTextInputActive = false); ui.CommandInput.RegisterCallback<FocusOutEvent>(_ => 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 // 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 // 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 // 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. // trickle-down phase, before the text engine and the focus ring see them.
commandInput.RegisterCallback<KeyDownEvent>(OnConsoleKeyDown, TrickleDown.TrickleDown); ui.CommandInput.RegisterCallback<KeyDownEvent>(OnConsoleKeyDown, TrickleDown.TrickleDown);
commandInput.RegisterCallback<NavigationMoveEvent>(OnConsoleNavigationMove, ui.CommandInput.RegisterCallback<NavigationMoveEvent>(OnConsoleNavigationMove,
TrickleDown.TrickleDown); 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;
} }
private void Update() private void Update()
@ -279,15 +174,14 @@ namespace TD.Dev
private void Swallow(EventBase evt) private void Swallow(EventBase evt)
{ {
evt.StopImmediatePropagation(); evt.StopImmediatePropagation();
commandInput?.panel?.focusController?.IgnoreEvent(evt); ui.CommandInput?.panel?.focusController?.IgnoreEvent(evt);
} }
private void OpenConsole() private void OpenConsole()
{ {
if (commandInput == null) return; if (ui.CommandInput == null) return;
consoleContainer.style.display = DisplayStyle.Flex; ui.Show();
consoleContainer.pickingMode = PickingMode.Position; ui.CommandInput.SetValueWithoutNotify(string.Empty);
commandInput.SetValueWithoutNotify(string.Empty);
RefreshSuggestions(); RefreshSuggestions();
// Suppress the toggle key and Enter for this frame and the next so the // Suppress the toggle key and Enter for this frame and the next so the
@ -302,33 +196,25 @@ namespace TD.Dev
private IEnumerator FocusNextFrame() private IEnumerator FocusNextFrame()
{ {
yield return null; yield return null;
if (consoleOpen && commandInput != null) if (consoleOpen && ui.CommandInput != null)
{ {
commandInput.Focus(); ui.CommandInput.Focus();
commandInput.SelectAll(); ui.CommandInput.SelectAll();
} }
} }
private void CloseConsole() private void CloseConsole()
{ {
if (commandInput != null) if (ui.CommandInput != null)
{ {
commandInput.SetValueWithoutNotify(string.Empty); ui.CommandInput.SetValueWithoutNotify(string.Empty);
commandInput.Blur(); ui.CommandInput.Blur();
}
if (consoleContainer != null)
{
consoleContainer.style.display = DisplayStyle.None;
consoleContainer.pickingMode = PickingMode.Ignore;
} }
ui.Hide();
suggestions.Clear(); suggestions.Clear();
selectedIndex = 0; selectedIndex = 0;
if (suggestionList != null) ui.ClearSuggestions();
{
suggestionList.Clear();
suggestionList.style.display = DisplayStyle.None;
}
consoleOpen = false; consoleOpen = false;
toggleSuppressFrame = Time.frameCount + 1; toggleSuppressFrame = Time.frameCount + 1;
@ -346,7 +232,7 @@ namespace TD.Dev
private void SubmitCommand() 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 // Enter doubles as "accept the highlighted suggestion" while the typed
// command isn't runnable yet, so you can drive the whole thing with Enter // 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 string Hint; // dim one-liner shown beside the token
public Func<List<string>> Values; // non-null => this level takes a final value public Func<List<string>> Values; // non-null => this level takes a final value
public bool Executable; // a complete command can end here public bool Executable; // a complete command can end here
public Type OptionType; // "get": the DraftOption subclass to match public Action<string> Execute; // "Values" level: run the completed command
public readonly List<CommandNode> Children = new List<CommandNode>(); public readonly List<CommandNode> Children = new List<CommandNode>();
} }
private const string UsageHint = "Try: get <type> <name>"; private const string UsageHint = "Try: get <type> <name>, grant enemy <ability>";
// Maps the type token in "get <type> <name>" to the DraftOption subclass it // Maps the type token in "get <type> <name>" to the DraftOption subclass it
// should match against. Add an entry here whenever a new DraftOption subclass // should match against. Add an entry here whenever a new DraftOption subclass
@ -412,17 +298,34 @@ namespace TD.Dev
foreach (var pair in DraftTypeAliases) foreach (var pair in DraftTypeAliases)
{ {
string typeToken = pair.Key;
var optionType = pair.Value; var optionType = pair.Value;
get.Children.Add(new CommandNode get.Children.Add(new CommandNode
{ {
Token = pair.Key, Token = typeToken,
Hint = "<name>", Hint = "<name>",
OptionType = optionType,
Executable = true, Executable = true,
Values = () => DraftOptionNames(optionType), 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 = "<ability>",
Executable = true,
Values = EnemyUpgradeNames,
Execute = ExecuteGrantEnemyAbility,
});
return root; return root;
} }
@ -454,6 +357,35 @@ namespace TD.Dev
return names; 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<string> EnemyUpgradeNames()
{
var names = new List<string>();
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: ..."). // Valid literal tokens at a level, for error messages ("Expected one of: ...").
private static string ChildTokens(CommandNode node) private static string ChildTokens(CommandNode node)
{ {
@ -640,7 +572,7 @@ namespace TD.Dev
private void RefreshSuggestions() 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, // 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. // so typing another character doesn't silently move what Enter would accept.
@ -648,7 +580,7 @@ namespace TD.Dev
? suggestions[selectedIndex].Text ? suggestions[selectedIndex].Text
: null; : null;
suggestions = ComputeSuggestions(commandInput.value, out fragmentStart); suggestions = ComputeSuggestions(ui.CommandInput.value, out fragmentStart);
selectedIndex = 0; selectedIndex = 0;
if (previouslySelected != null) if (previouslySelected != null)
@ -662,38 +594,15 @@ namespace TD.Dev
} }
} }
RebuildSuggestionRows(); ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex);
} }
private void RebuildSuggestionRows() private static List<DebugConsoleUI.SuggestionRow> ToSuggestionRows(List<Suggestion> suggestions)
{ {
if (suggestionList == null) return; var rows = new List<DebugConsoleUI.SuggestionRow>(suggestions.Count);
for (int i = 0; i < suggestions.Count; i++)
suggestionList.Clear(); rows.Add(new DebugConsoleUI.SuggestionRow(suggestions[i].Text, suggestions[i].Hint));
return rows;
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));
} }
private void MoveSelection(int delta) private void MoveSelection(int delta)
@ -703,16 +612,16 @@ namespace TD.Dev
selectedIndex = (selectedIndex + delta) % suggestions.Count; selectedIndex = (selectedIndex + delta) % suggestions.Count;
if (selectedIndex < 0) selectedIndex += suggestions.Count; if (selectedIndex < 0) selectedIndex += suggestions.Count;
RebuildSuggestionRows(); ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex);
} }
private bool AcceptSelectedSuggestion() private bool AcceptSelectedSuggestion()
{ {
if (commandInput == null) return false; if (ui.CommandInput == null) return false;
if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false; if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false;
var suggestion = suggestions[selectedIndex]; 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); int start = Mathf.Clamp(fragmentStart, 0, text.Length);
string completed = text.Substring(0, start) + suggestion.Text + string completed = text.Substring(0, start) + suggestion.Text +
@ -721,8 +630,8 @@ namespace TD.Dev
// SetValueWithoutNotify + an explicit refresh instead of assigning value: // SetValueWithoutNotify + an explicit refresh instead of assigning value:
// one deterministic recompute, and the caret placement below can't race the // one deterministic recompute, and the caret placement below can't race the
// change callback. // change callback.
commandInput.SetValueWithoutNotify(completed); ui.CommandInput.SetValueWithoutNotify(completed);
commandInput.SelectRange(completed.Length, completed.Length); ui.CommandInput.SelectRange(completed.Length, completed.Length);
RefreshSuggestions(); RefreshSuggestions();
return true; return true;
} }
@ -757,14 +666,14 @@ namespace TD.Dev
} }
string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i)); string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i));
ExecuteGet(node.Token, node.OptionType, nameToken); node.Execute(nameToken);
} }
// "get <type> <name>": grants any DraftOption of the given type whose DisplayName // "get <type> <name>": grants any DraftOption of the given type whose DisplayName
// matches <name>, via the local player's DebugCommandRelay. Reuses DraftOption's // matches <name>, via the local player's DebugCommandRelay. Reuses DraftOption's
// existing type-agnostic ServerApply dispatch instead of hardcoding a grant path // existing type-agnostic ServerApply dispatch instead of hardcoding a grant path
// per option type. // 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; var pool = DraftPool.Instance;
if (pool == null) if (pool == null)
@ -795,6 +704,41 @@ namespace TD.Dev
Debug.LogWarning($"[DebugConsole] No {typeToken} option named \"{nameToken}\" found."); Debug.LogWarning($"[DebugConsole] No {typeToken} option named \"{nameToken}\" found.");
} }
// "grant enemy <ability>": 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 // DisplayName is player-facing draft card text (its spacing/casing is chosen for
// presentation, not for typing), so command matching strips everything but letters // presentation, not for typing), so command matching strips everything but letters
// and digits before comparing — "Slow Area", "slow_area", and "SlowArea" all match // and digits before comparing — "Slow Area", "slow_area", and "SlowArea" all match

View file

@ -0,0 +1,185 @@
// Assets/_Project/Scripts/Dev/DebugConsoleUI.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace TD.Dev
{
/// <summary>
/// Builds and draws <see cref="DebugConsole"/>'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 <see cref="DebugConsole"/>, which drives this class through its public
/// members and reads/writes <see cref="CommandInput"/> directly for focus and value.
/// </summary>
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;
/// <summary>One row of the autocomplete list: a candidate token plus an optional dim hint.</summary>
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<SuggestionRow> 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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fce39b2f68ef0f3f9bec299aa46d353a

View file

@ -25,26 +25,22 @@ namespace TD.Gameplay.EnemyAbilities
[Header("Blink")] [Header("Blink")]
[Tooltip("Seconds between blinks.")] [Tooltip("Seconds between blinks.")]
[Min(0.1f)] [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 " + [Tooltip("World units to advance along the path per blink.")]
"one waypoint span several tiles, so this is a coarser dial than it looks.")] [Min(0.1f)]
[Min(1)] public float BlinkDistanceMeters = 4f;
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;
public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex) public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex)
{ {
// Seed each enemy's cooldown with a different negative offset so the wave's first // Seed each enemy's cooldown with a random point within the full interval, as if it
// blink is staggered. Done once, here, rather than on the first tick — a tick-time // 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 // check would have to distinguish "never started" from "just blinked", and both
// states read as a zero timer. // states read as a zero timer.
if (StartJitterSeconds > 0f) instance.TimerFor(abilityIndex) = -Random.Range(0f, IntervalSeconds);
instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds);
} }
public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt) public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt)
@ -54,7 +50,7 @@ namespace TD.Gameplay.EnemyAbilities
if (timer < IntervalSeconds) return; if (timer < IntervalSeconds) return;
timer = 0f; timer = 0f;
instance.GetComponent<EnemyMovement>()?.ServerBlinkForward(BlinkWaypoints); instance.GetComponent<EnemyMovement>()?.ServerBlinkForward(BlinkDistanceMeters);
} }
} }
} }

View file

@ -286,39 +286,70 @@ namespace TD.Gameplay
/// <summary> /// <summary>
/// Server-only: teleport this enemy forward along its existing path by up to /// Server-only: teleport this enemy forward along its existing path by up to
/// <paramref name="tiles"/> waypoints. Used by the Blink wave buff. /// <paramref name="meters"/> world units. Used by the Blink wave buff.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <b>Follows the path rather than the straight line to the goal.</b> Jumping toward the /// <b>Follows the path rather than the straight line to the goal.</b> Jumping toward the
/// goal as the crow flies would let a grounded enemy blink through walls, which is 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. /// it cuts corners, it doesn't ignore them.
/// ///
/// <para>Zone tracking still runs on the destination tile, so an enemy that blinks out of /// <para>Distance is measured along the path (waypoint to waypoint), consuming the
/// its origin zone still credits its owner a leak. Skipping that would make the buff a way /// budget one segment at a time, so the landing spot can fall mid-segment rather than
/// to launder leaks past the no-leak bonus.</para> /// 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.</para>
/// ///
/// <para>Blinking onto the final waypoint resolves as a normal goal arrival, despawn and /// <para>Zone tracking runs once, on the final landing tile, so an enemy that blinks out
/// life cost included.</para> /// 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.</para>
///
/// <para>Blinking onto (or past) the final waypoint resolves as a normal goal arrival,
/// despawn and life cost included.</para>
/// </remarks> /// </remarks>
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 (hasReachedGoal || remainingPath.Count == 0) return;
if (health != null && health.IsHeld) return; if (health != null && health.IsHeld) return;
int skip = Mathf.Min(tiles, remainingPath.Count); float remaining = meters;
Vector2Int destination = remainingPath[skip - 1]; Vector3 position = transform.position;
remainingPath.RemoveRange(0, skip);
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. // Preserve Y so flyers stay at altitude and grounded pivots don't sink.
Vector3 world = GridCoordinates.GridToWorld(destination); transform.position = position;
transform.position = new Vector3(world.x, transform.position.y, world.z);
CheckZoneTransition(destination);
if (remainingPath.Count == 0) if (remainingPath.Count == 0)
{
HandleGoalReached(); HandleGoalReached();
return;
}
CheckZoneTransition(GridCoordinates.WorldToGrid(position));
} }
// ----- Path invalidation ---------------------------------------------- // ----- Path invalidation ----------------------------------------------