diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset index ec1b7b5..6cc7cf7 100644 --- a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset @@ -14,5 +14,6 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.BlinkAbilityDefinition DisplayName: Blink Description: Enemies occasionally teleport a short distance ahead. - IntervalSeconds: 8 - BlinkDistanceMeters: 4 + IntervalSeconds: 4 + BlinkWaypoints: 2 + StartJitterSeconds: 1.5 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab index 4f54da4..d912bec 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: 3715663997 + GlobalObjectIdHash: 3037368120 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -125,7 +125,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth ShowTopMostFoldoutHeaderGroup: 1 - definition: {fileID: 11400000, guid: edc686813aef43743bb15d356b511dff, type: 2} + definition: {fileID: 0} 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 fb8210c..bd7ac68 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: 11400000, guid: fe31ccfc7a429b24fb8f57279ba96c95, type: 2} + definition: {fileID: 0} 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 e11218f..d721ee2 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: 11400000, guid: 21c69bd4b2c55cd4fa9cd9b551c6e3fe, type: 2} + definition: {fileID: 0} 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 d943a11..89ac836 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: 11400000, guid: 52d9af0c680bca64a86d66fce2a73668, type: 2} + definition: {fileID: 0} 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 06a8723..f5ace60 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: 11400000, guid: 4b981d8b44098894197507508f655072, type: 2} + definition: {fileID: 0} 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 829e743..60b1291 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: 11400000, guid: bed87a08deeb7e048ba06c8268b0f959, type: 2} + definition: {fileID: 0} 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 b37096d..2ab5755 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: 11400000, guid: 77f4545654c5b7b41b2760a580da6068, type: 2} + definition: {fileID: 0} 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 063f5d2..bca0e4a 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: 11400000, guid: 097d7ba78591cd443a06d637e0822a46, type: 2} + definition: {fileID: 0} 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 7994b3a..ef51e96 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: 11400000, guid: 1d9809612c00ed049848a955613a4619, type: 2} + definition: {fileID: 0} 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 360b2a3..3af3076 100644 --- a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs +++ b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Gameplay.Draft; -using TD.Gameplay.EnemyUpgrades; -using TD.Gameplay.Waves; namespace TD.Dev { @@ -76,42 +74,5 @@ 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 6ffbf2b..03a7474 100644 --- a/Assets/_Project/Scripts/Dev/DebugConsole.cs +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs @@ -8,7 +8,6 @@ using UnityEngine.InputSystem; using UnityEngine.UIElements; using TD.UI; using TD.Gameplay.Draft; -using TD.Gameplay.EnemyUpgrades; namespace TD.Dev { @@ -50,7 +49,13 @@ namespace TD.Dev "System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")] [SerializeField] private Key toggleKey = Key.Backquote; - private DebugConsoleUI ui; + // 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 bool consoleOpen; // Frame on which the console was opened or closed. The toggle key and Enter @@ -93,23 +98,123 @@ namespace TD.Dev return; } - ui = new DebugConsoleUI(root); + 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); // 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. - ui.CommandInput.RegisterCallback(_ => HUDController.IsTextInputActive = true); - ui.CommandInput.RegisterCallback(_ => HUDController.IsTextInputActive = false); + commandInput.RegisterCallback(_ => HUDController.IsTextInputActive = true); + commandInput.RegisterCallback(_ => HUDController.IsTextInputActive = false); - ui.CommandInput.RegisterValueChangedCallback(_ => RefreshSuggestions()); + 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. - ui.CommandInput.RegisterCallback(OnConsoleKeyDown, TrickleDown.TrickleDown); - ui.CommandInput.RegisterCallback(OnConsoleNavigationMove, - TrickleDown.TrickleDown); + 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; } private void Update() @@ -174,14 +279,15 @@ namespace TD.Dev private void Swallow(EventBase evt) { evt.StopImmediatePropagation(); - ui.CommandInput?.panel?.focusController?.IgnoreEvent(evt); + commandInput?.panel?.focusController?.IgnoreEvent(evt); } private void OpenConsole() { - if (ui.CommandInput == null) return; - ui.Show(); - ui.CommandInput.SetValueWithoutNotify(string.Empty); + if (commandInput == null) return; + consoleContainer.style.display = DisplayStyle.Flex; + consoleContainer.pickingMode = PickingMode.Position; + commandInput.SetValueWithoutNotify(string.Empty); RefreshSuggestions(); // Suppress the toggle key and Enter for this frame and the next so the @@ -196,25 +302,33 @@ namespace TD.Dev private IEnumerator FocusNextFrame() { yield return null; - if (consoleOpen && ui.CommandInput != null) + if (consoleOpen && commandInput != null) { - ui.CommandInput.Focus(); - ui.CommandInput.SelectAll(); + commandInput.Focus(); + commandInput.SelectAll(); } } private void CloseConsole() { - if (ui.CommandInput != null) + if (commandInput != null) { - ui.CommandInput.SetValueWithoutNotify(string.Empty); - ui.CommandInput.Blur(); + commandInput.SetValueWithoutNotify(string.Empty); + commandInput.Blur(); + } + if (consoleContainer != null) + { + consoleContainer.style.display = DisplayStyle.None; + consoleContainer.pickingMode = PickingMode.Ignore; } - ui.Hide(); suggestions.Clear(); selectedIndex = 0; - ui.ClearSuggestions(); + if (suggestionList != null) + { + suggestionList.Clear(); + suggestionList.style.display = DisplayStyle.None; + } consoleOpen = false; toggleSuppressFrame = Time.frameCount + 1; @@ -232,7 +346,7 @@ namespace TD.Dev private void SubmitCommand() { - string text = ui.CommandInput?.value ?? string.Empty; + string text = 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 @@ -263,11 +377,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 Action Execute; // "Values" level: run the completed command + public Type OptionType; // "get": the DraftOption subclass to match public readonly List Children = new List(); } - private const string UsageHint = "Try: get , grant enemy "; + private const string UsageHint = "Try: get "; // Maps the type token in "get " to the DraftOption subclass it // should match against. Add an entry here whenever a new DraftOption subclass @@ -298,34 +412,17 @@ namespace TD.Dev foreach (var pair in DraftTypeAliases) { - string typeToken = pair.Key; var optionType = pair.Value; get.Children.Add(new CommandNode { - Token = typeToken, + Token = pair.Key, 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; } @@ -357,35 +454,6 @@ 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) { @@ -572,7 +640,7 @@ namespace TD.Dev private void RefreshSuggestions() { - if (ui.CommandInput == null) return; + if (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. @@ -580,7 +648,7 @@ namespace TD.Dev ? suggestions[selectedIndex].Text : null; - suggestions = ComputeSuggestions(ui.CommandInput.value, out fragmentStart); + suggestions = ComputeSuggestions(commandInput.value, out fragmentStart); selectedIndex = 0; if (previouslySelected != null) @@ -594,15 +662,38 @@ namespace TD.Dev } } - ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex); + RebuildSuggestionRows(); } - private static List ToSuggestionRows(List suggestions) + private void RebuildSuggestionRows() { - 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; + 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)); } private void MoveSelection(int delta) @@ -612,16 +703,16 @@ namespace TD.Dev selectedIndex = (selectedIndex + delta) % suggestions.Count; if (selectedIndex < 0) selectedIndex += suggestions.Count; - ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex); + RebuildSuggestionRows(); } private bool AcceptSelectedSuggestion() { - if (ui.CommandInput == null) return false; + if (commandInput == null) return false; if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false; var suggestion = suggestions[selectedIndex]; - string text = ui.CommandInput.value ?? string.Empty; + string text = commandInput.value ?? string.Empty; int start = Mathf.Clamp(fragmentStart, 0, text.Length); string completed = text.Substring(0, start) + suggestion.Text + @@ -630,8 +721,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. - ui.CommandInput.SetValueWithoutNotify(completed); - ui.CommandInput.SelectRange(completed.Length, completed.Length); + commandInput.SetValueWithoutNotify(completed); + commandInput.SelectRange(completed.Length, completed.Length); RefreshSuggestions(); return true; } @@ -666,14 +757,14 @@ namespace TD.Dev } string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i)); - node.Execute(nameToken); + ExecuteGet(node.Token, node.OptionType, 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 static void ExecuteGet(string typeToken, Type optionType, string nameToken) + private void ExecuteGet(string typeToken, Type optionType, string nameToken) { var pool = DraftPool.Instance; if (pool == null) @@ -704,41 +795,6 @@ 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 deleted file mode 100644 index 808dd29..0000000 --- a/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs +++ /dev/null @@ -1,185 +0,0 @@ -// 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 deleted file mode 100644 index 66e439e..0000000 --- a/Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -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 c00904c..236c8b7 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs @@ -25,22 +25,26 @@ namespace TD.Gameplay.EnemyAbilities [Header("Blink")] [Tooltip("Seconds between blinks.")] [Min(0.1f)] - public float IntervalSeconds = 8f; + public float IntervalSeconds = 4f; - [Tooltip("World units to advance along the path per blink.")] - [Min(0.1f)] - public float BlinkDistanceMeters = 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("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 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 + // 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 // check would have to distinguish "never started" from "just blinked", and both // states read as a zero timer. - instance.TimerFor(abilityIndex) = -Random.Range(0f, IntervalSeconds); + if (StartJitterSeconds > 0f) + instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds); } public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt) @@ -50,7 +54,7 @@ namespace TD.Gameplay.EnemyAbilities if (timer < IntervalSeconds) return; timer = 0f; - instance.GetComponent()?.ServerBlinkForward(BlinkDistanceMeters); + instance.GetComponent()?.ServerBlinkForward(BlinkWaypoints); } } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index 6815ff2..147eaf9 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -286,70 +286,39 @@ namespace TD.Gameplay /// /// Server-only: teleport this enemy forward along its existing path by up to - /// world units. Used by the Blink wave buff. + /// waypoints. 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. Walking the path keeps the enemy inside the maze — + /// flying buff's job, not this one. Skipping waypoints keeps the enemy inside the maze — /// it cuts corners, it doesn't ignore them. /// - /// 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. + /// 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. /// - /// 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. + /// Blinking onto the final waypoint resolves as a normal goal arrival, despawn and + /// life cost included. /// - public void ServerBlinkForward(float meters) + public void ServerBlinkForward(int tiles) { - if (!IsServer || meters <= 0f) return; + if (!IsServer || tiles <= 0) return; if (hasReachedGoal || remainingPath.Count == 0) return; if (health != null && health.IsHeld) return; - 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; - } - } + int skip = Mathf.Min(tiles, remainingPath.Count); + Vector2Int destination = remainingPath[skip - 1]; + remainingPath.RemoveRange(0, skip); // Preserve Y so flyers stay at altitude and grounded pivots don't sink. - transform.position = position; + Vector3 world = GridCoordinates.GridToWorld(destination); + transform.position = new Vector3(world.x, transform.position.y, world.z); + + CheckZoneTransition(destination); if (remainingPath.Count == 0) - { HandleGoalReached(); - return; - } - - CheckZoneTransition(GridCoordinates.WorldToGrid(position)); } // ----- Path invalidation ----------------------------------------------