From 726af6d379abf3c041cd5f22093bc0cd899b3383 Mon Sep 17 00:00:00 2001 From: Ben Calegari Date: Tue, 28 Jul 2026 20:47:06 -0700 Subject: [PATCH 01/14] Fix retry after defeat --- Assets/_Project/Scripts/UI/HUDController.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 450e0f9..9ff9ca4 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -2169,10 +2169,22 @@ namespace TD.UI // Fallback: LobbyService isn't spawned (e.g. testing the gameplay // scene standalone without the lobby flow). Hard-reload the scene. - Debug.LogWarning("[HUDController] LobbyService not found — falling back to scene reload."); + // + // Do NOT reload via NetworkManager.SceneManager here. Reloading the + // currently-active scene through NGO's networked scene manager in + // Single mode deadlocks: SceneEventProgress unloads the active scene + // then waits on a load-complete ack for the same scene handle it is + // mid-transition on, which never arrives — the main thread hangs + // (beachball). LoadSceneAsHost is safe only because it loads a + // *different* scene (Lobby). With no LobbyService there is no real + // multiplayer session worth preserving, so shut the host down and do + // a plain, non-networked Unity scene reload instead. + Debug.LogWarning("[HUDController] LobbyService not found — falling back to non-networked scene reload."); + string activeScene = SceneManager.GetActiveScene().name; var nm = NetworkManager.Singleton; - if (nm != null && nm.IsServer && nm.SceneManager != null) - nm.SceneManager.LoadScene(SceneManager.GetActiveScene().name, LoadSceneMode.Single); + if (nm != null && nm.IsListening) + nm.Shutdown(); + SceneManager.LoadScene(activeScene, LoadSceneMode.Single); } // Return to Main Menu: disconnect only this player. SessionFlow's From 8714885efa1c1a8af7f5dc2bbe2ba3de8e58682c Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 28 Jul 2026 20:59:43 -0700 Subject: [PATCH 02/14] first pass at enemy upgrades --- .../_Project/Scripts/Core/EnemyAbilityKind.cs | 16 +++ .../Scripts/Core/EnemyAbilityKind.cs.meta | 2 + .../Scripts/Gameplay/EnemyAbilities.meta | 8 ++ .../EnemyAbilities/EnemyAbilityDefinition.cs | 55 ++++++++++ .../EnemyAbilityDefinition.cs.meta | 2 + .../EnemyAbilities/EnemyAbilityPool.cs | 102 ++++++++++++++++++ .../EnemyAbilities/EnemyAbilityPool.cs.meta | 2 + .../SplitOnDeathAbilityDefinition.cs | 40 +++++++ .../SplitOnDeathAbilityDefinition.cs.meta | 2 + .../_Project/Scripts/Gameplay/EnemyAbility.cs | 84 +++++++++++++++ .../Scripts/Gameplay/EnemyAbility.cs.meta | 2 + .../Scripts/Gameplay/EnemyMovement.cs | 5 + .../_Project/Scripts/Gameplay/WaveManager.cs | 31 ++++++ 13 files changed, 351 insertions(+) create mode 100644 Assets/_Project/Scripts/Core/EnemyAbilityKind.cs create mode 100644 Assets/_Project/Scripts/Core/EnemyAbilityKind.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbility.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbility.cs.meta diff --git a/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs new file mode 100644 index 0000000..3bc7bd1 --- /dev/null +++ b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs @@ -0,0 +1,16 @@ +namespace TD.Core +{ + /// + /// Identifies which enemy ability a + /// represents. Mirrors + /// — a stable, enum-indexed identifier used by + /// instead of an asset reference. + /// + public enum EnemyAbilityKind : byte + { + /// No ability rolled. Never a real pool entry — only the sentinel value + /// reports before/absent a roll. + None = 0, + SplitOnDeath = 1, + } +} diff --git a/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs.meta b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs.meta new file mode 100644 index 0000000..18dd995 --- /dev/null +++ b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ec52a6b404fed65dfa48a63b5535373d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities.meta new file mode 100644 index 0000000..099a1ab --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1c918164b61faf1d5af9f782c5237a60 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs new file mode 100644 index 0000000..349b0ee --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs @@ -0,0 +1,55 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Rolled + /// at random for each spawned enemy by and applied + /// via . + /// + /// + /// One asset per kind. is fixed per subclass, same as + /// . uses + /// it to build a fixed-size, enum-indexed lookup table. + /// + /// Server-only hooks. All three hooks below only ever run on the server — + /// only calls them when IsServer is true. and are no-ops for abilities that don't + /// need spawn-time setup or a per-frame timer (e.g. Split on Death uses neither); they exist + /// so a future cooldown-driven ability (disable towers, teleport) doesn't need a base-class + /// change. + /// + public abstract class EnemyAbilityDefinition : ScriptableObject + { + /// Which enemy ability this asset's data belongs to. + public abstract EnemyAbilityKind Kind { get; } + + [Header("Presentation")] + [Tooltip("Name shown in debug logs and future enemy-info UI.")] + public string DisplayName; + + [Tooltip("Short description shown in future enemy-info UI.")] + [TextArea(2, 4)] + public string Description; + + [Header("Selection")] + [Tooltip("Relative weight of this ability being rolled, vs. the pool's other abilities " + + "and its no-ability chance. Same semantics as DraftOption.Weight.")] + [Min(0f)] + public float Weight = 1f; + + /// Server-only: called once, right after this ability is assigned (before the + /// enemy's NetworkObject is spawned to clients). Default no-op. + public virtual void ServerOnSpawn(EnemyAbility instance) { } + + /// Server-only: called every frame this ability is active on a live enemy. + /// Default no-op. + public virtual void ServerTick(EnemyAbility instance, float dt) { } + + /// Server-only: called the instant the enemy's HP reaches zero, before the + /// death animation/despawn sequence plays. Default no-op. + public virtual void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs.meta new file mode 100644 index 0000000..b94f8dd --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 855f7f0848d3086868816d25295fcdd6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs new file mode 100644 index 0000000..cc9270e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs @@ -0,0 +1,102 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs +using System; +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// Scene singleton holding every available this match, + /// authored as a flat array in the inspector for convenience. Internally it builds a + /// fixed-size table indexed by so lookups are a single array + /// access, not a scan. Mirrors exactly. + /// + /// + /// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync. + /// Only the server calls (at enemy spawn time, in + /// WaveManager.SpawnEnemy). + /// + public class EnemyAbilityPool : MonoBehaviour + { + public static EnemyAbilityPool Instance { get; private set; } + + [Tooltip("Every EnemyAbilityDefinition asset available this match. One entry per " + + "EnemyAbilityKind — order doesn't matter, Kind on the asset itself decides " + + "its slot.")] + [SerializeField] private EnemyAbilityDefinition[] abilities; + + [Tooltip("Relative weight of an enemy rolling no ability at all, vs. the combined " + + "weight of every entry in 'abilities'. Higher = abilities are rarer.")] + [Min(0f)] + [SerializeField] private float noAbilityWeight = 20f; + + // Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count, + // not authored count, so an out-of-range Kind is a compile-time impossibility rather + // than a bounds check we'd otherwise need on every Get(). + private EnemyAbilityDefinition[] byKind; + + private void Awake() + { + if (Instance != null && Instance != this) + { + Debug.LogError("[EnemyAbilityPool] Multiple instances detected. Only one per scene."); + return; + } + Instance = this; + + int kindCount = Enum.GetValues(typeof(EnemyAbilityKind)).Length; + byKind = new EnemyAbilityDefinition[kindCount]; + if (abilities == null) return; + + for (int i = 0; i < abilities.Length; i++) + { + var def = abilities[i]; + if (def == null) continue; + byKind[(int)def.Kind] = def; + } + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } + + /// Returns the ability asset for , or null if none is + /// authored in this pool. + public EnemyAbilityDefinition Get(EnemyAbilityKind kind) + { + int i = (int)kind; + return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null; + } + + /// + /// Server-only: weighted random draw across every authored ability plus the + /// no-ability bucket. Returns null when "no ability" is drawn, or when the pool has + /// nothing authored. + /// + public EnemyAbilityDefinition RollRandom() + { + if (abilities == null || abilities.Length == 0) return null; + + float total = noAbilityWeight; + for (int i = 0; i < abilities.Length; i++) + if (abilities[i] != null) total += abilities[i].Weight; + + if (total <= 0f) return null; + + float roll = UnityEngine.Random.Range(0f, total); + if (roll < noAbilityWeight) return null; + roll -= noAbilityWeight; + + for (int i = 0; i < abilities.Length; i++) + { + var def = abilities[i]; + if (def == null) continue; + if (roll < def.Weight) return def; + roll -= def.Weight; + } + + return null; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs.meta new file mode 100644 index 0000000..a951ada --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4409eb25a8be555de8dc3a73d4ed95db \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs new file mode 100644 index 0000000..364e16b --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs @@ -0,0 +1,40 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// On death, spawns copies of at the corpse's + /// position, continuing on toward the goal instead of restarting from the wave's spawner. + /// + [CreateAssetMenu(fileName = "SplitOnDeathAbility", menuName = "TD/Enemy Abilities/Split On Death")] + public class SplitOnDeathAbilityDefinition : EnemyAbilityDefinition + { + public override EnemyAbilityKind Kind => EnemyAbilityKind.SplitOnDeath; + + [Header("Split")] + [Tooltip("Smaller enemy type spawned when this enemy dies.")] + public EnemyDefinition SplitInto; + + [Tooltip("How many copies of SplitInto spawn on death.")] + [Min(1)] + public int SplitCount = 2; + + [Tooltip("Random scatter radius (world units) applied to each split spawn so they don't " + + "spawn exactly on top of each other.")] + [Min(0f)] + public float ScatterRadius = 0.5f; + + public override void ServerOnDeath(EnemyAbility instance, EnemyHealth health) + { + if (SplitInto == null) return; + + var movement = instance.GetComponent(); + var atTile = GridCoordinates.WorldToGrid(instance.transform.position); + var ownerSlot = movement != null ? movement.OriginZone : PlayerSlot.None; + + WaveManager.Instance?.ServerSpawnSplitEnemies(SplitInto, SplitCount, atTile, ownerSlot, ScatterRadius); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs.meta new file mode 100644 index 0000000..8c2a4f3 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 21543cb1b54f0b16e9c80584f68988b3 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs new file mode 100644 index 0000000..34c7d79 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs @@ -0,0 +1,84 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbility.cs +using Unity.Netcode; +using UnityEngine; +using TD.Core; +using TD.Gameplay.EnemyAbilities; + +namespace TD.Gameplay +{ + /// + /// Per-enemy ability slot. Holds the rolled for this + /// instance (if any) and drives its server-only hooks. + /// + /// + /// Initialization: Call on the server immediately after + /// Instantiate and before NetworkObject.Spawn(), following the same pattern as + /// EnemyHealth.InitializeServer / EnemyMovement.InitializeServer. + /// + /// Optional component: Not required by EnemyHealth or EnemyMovement — + /// WaveManager.SpawnEnemy only rolls and assigns an ability when this component is + /// present on the prefab, so existing enemy prefabs are unaffected until it's added to them. + /// + /// On-death hook is not event-driven: WaveManager.HandleEnemyKilled calls + /// directly (via ) + /// rather than this component subscribing to EnemyHealth.OnDied itself — that keeps a + /// split-spawn's activeEnemyCount increment ordered before the triggering kill's + /// decrement, regardless of NetworkBehaviour subscription order. + /// + [RequireComponent(typeof(NetworkObject))] + public class EnemyAbility : NetworkBehaviour + { + private readonly NetworkVariable kind = new NetworkVariable( + (byte)EnemyAbilityKind.None, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + // ----- Pre-spawn init (server-local) ---------------------------------- + + private EnemyAbilityDefinition pendingDefinition; + private bool hasPendingInit; + + // ----- Public state ----------------------------------------------------- + + /// The ability rolled for this enemy, or null if none was rolled. + public EnemyAbilityDefinition Definition { get; private set; } + + /// Replicated kind of the rolled ability. if + /// none was rolled. + public EnemyAbilityKind Kind => (EnemyAbilityKind)kind.Value; + + // ----- Server-only pre-spawn init ------------------------------------- + + /// + /// Called by WaveManager on the server after Instantiate and before + /// NetworkObject.Spawn(). may be null, meaning no + /// ability was rolled for this enemy. + /// + public void InitializeServer(EnemyAbilityDefinition definition) + { + pendingDefinition = definition; + hasPendingInit = true; + } + + // ----- NGO lifecycle -------------------------------------------------- + + public override void OnNetworkSpawn() + { + if (!IsServer || !hasPendingInit) return; + + Definition = pendingDefinition; + kind.Value = (byte)(Definition != null ? Definition.Kind : EnemyAbilityKind.None); + hasPendingInit = false; + + Definition?.ServerOnSpawn(this); + } + + // ----- Server tick ------------------------------------------------ + + private void Update() + { + if (!IsServer || Definition == null) return; + Definition.ServerTick(this, Time.deltaTime); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs.meta new file mode 100644 index 0000000..3562455 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d1a26a32e8969cd29bce6010126a5f6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index 96a6e30..7eec0d1 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -73,6 +73,11 @@ namespace TD.Gameplay // the goal). Without this, every zone crossing was counted as a leak; only // the originating player should be credited a leak per the design. private PlayerSlot originZone = PlayerSlot.None; + + /// The zone (player) this enemy was originally spawned for — see + /// . Used by on-death abilities (e.g. Split on Death) so + /// spawned children preserve the same leak attribution as their parent. + public PlayerSlot OriginZone => originZone; // Latches once the enemy has crossed its origin zone's leak volume, so we // never double-count a leak if the enemy re-enters its origin (rare but // possible if pathing is dynamic). diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index d1d889b..67beb6c 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -5,6 +5,7 @@ using UnityEngine; using TD.Core; using TD.Gameplay.BuilderEffects; using TD.Gameplay.Draft; +using TD.Gameplay.EnemyAbilities; using TD.Levels; using TD.UI; @@ -503,6 +504,13 @@ namespace TD.Gameplay health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held); movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying); + + // Optional — only prefabs with an EnemyAbility component roll an ability. See + // EnemyAbility's remarks for why on-death abilities aren't event-subscription driven. + var ability = go.GetComponent(); + if (ability != null) + ability.InitializeServer(EnemyAbilityPool.Instance?.RollRandom()); + if (held) heldEnemies.Add(health); health.OnDied += HandleEnemyKilled; @@ -514,6 +522,22 @@ namespace TD.Gameplay go.GetComponent().Spawn(); } + /// + /// Server-only: spawns copies of at + /// , continuing that tile's A* path onward rather than + /// restarting from a wave spawner. Used by on-death abilities (e.g. + /// SplitOnDeathAbilityDefinition) to spawn smaller enemies from a corpse's + /// position. Reuses so split children get the same + /// activeEnemyCount/event-wiring bookkeeping as any other spawn. + /// + public void ServerSpawnSplitEnemies(EnemyDefinition def, int count, Vector2Int atTile, + PlayerSlot ownerSlot, float scatterRadius) + { + if (!IsServer) return; + for (int i = 0; i < count; i++) + SpawnEnemy(def, atTile, ownerSlot, scatterRadius, scatterRadius); + } + // ----- Enemy event handlers (server-only) ------------------------- private void HandleEnemyKilled(EnemyHealth health) @@ -551,6 +575,13 @@ namespace TD.Gameplay if (totalReward > 0) ShowGoldRewardClientRpc(health.transform.position, totalReward); + // Resolve any on-death ability BEFORE unsubscribing/decrementing. A split-spawn's + // activeEnemyCount++ (inside ServerSpawnSplitEnemies -> SpawnEnemy) must land before + // this kill's activeEnemyCount-- below, or a split on a wave's last enemy could let + // CheckWaveComplete see activeEnemyCount hit 0 and advance the wave prematurely. + var ability = health.GetComponent(); + ability?.Definition?.ServerOnDeath(ability, health); + UnsubscribeEnemy(health); DecrementAndCheckComplete(); } From 650cef7bfd63ed35761ccda9c3bfb16c47920b31 Mon Sep 17 00:00:00 2001 From: Ben Calegari Date: Tue, 28 Jul 2026 21:26:37 -0700 Subject: [PATCH 03/14] Add autocomplete to dev console --- Assets/_Project/Scripts/Dev/DebugConsole.cs | 559 +++++++++++++++++- .../_Project/Scripts/Dev/DevWaveControls.cs | 5 + 2 files changed, 540 insertions(+), 24 deletions(-) diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs b/Assets/_Project/Scripts/Dev/DebugConsole.cs index 869be74..03a7474 100644 --- a/Assets/_Project/Scripts/Dev/DebugConsole.cs +++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs @@ -16,26 +16,46 @@ namespace TD.Dev /// input, type a command, Enter to submit, Escape to cancel. /// /// - /// Deliberately decoupled from — own - /// GameObject, own UIDocument/PanelSettings — so it can be dropped out of - /// a build entirely by not including that GameObject, the same way - /// is kept separate from shipping systems. + /// Deliberately decoupled from — own GameObject, + /// own UIDocument (it shares HUDPanelSettings, drawn above the HUD via a + /// higher sorting order) — so it can be dropped out of a build entirely by + /// not including that GameObject, the same way + /// is kept separate from shipping systems. /// + /// The grammar lives in a tree (see + /// ) rather than in hardcoded string compares, + /// so the same definition drives both execution and the autocomplete list. /// Supports one command so far: "get <type> <name>" grants a /// to the local player via /// . + /// + /// Keys while open: Up/Down move the suggestion highlight, Tab accepts it, + /// Enter accepts it while the command is still incomplete and submits once + /// it is complete, Escape closes without submitting. /// [RequireComponent(typeof(UIDocument))] public class DebugConsole : MonoBehaviour { public static DebugConsole Instance { get; private set; } + /// + /// True while the console overlay is showing. Legacy IMGUI (OnGUI) dev tools + /// draw after every runtime UI Toolkit panel regardless of sorting order, so they + /// have to check this and skip drawing rather than rely on layering. + /// + public static bool IsOpen => Instance != null && Instance.consoleOpen; + [Tooltip("Keyboard shortcut that opens and closes the console. Uses the new Input " + "System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")] [SerializeField] private Key toggleKey = Key.Backquote; + // 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 @@ -44,6 +64,12 @@ namespace TD.Dev // Mirrors HUDController's chatToggleSuppressFrame. private int toggleSuppressFrame = -1; + // Current completion candidates for what's typed, the highlighted one, and the + // index in the input text where accepting a candidate starts overwriting. + private List suggestions = new List(); + private int selectedIndex; + private int fragmentStart; + private void Awake() { if (Instance != null && Instance != this) @@ -89,7 +115,9 @@ namespace TD.Dev consoleContainer.style.paddingBottom = 8; consoleContainer.style.paddingLeft = 12; consoleContainer.style.paddingRight = 12; - consoleContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.75f); + // 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(); @@ -105,7 +133,24 @@ namespace TD.Dev commandInput.RegisterCallback(_ => HUDController.IsTextInputActive = true); commandInput.RegisterCallback(_ => HUDController.IsTextInputActive = false); + 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); } @@ -115,7 +160,7 @@ namespace TD.Dev // debug console, not chat" cue. private static void StyleConsoleInput(TextField field) { - var darkBg = new Color(0.05f, 0.05f, 0.05f, 0.95f); + 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; @@ -137,6 +182,41 @@ namespace TD.Dev } } + // 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() { var kb = Keyboard.current; @@ -160,12 +240,55 @@ namespace TD.Dev else if (escDown) CloseConsole(); } + private void OnConsoleKeyDown(KeyDownEvent evt) + { + if (!consoleOpen) return; + + switch (evt.keyCode) + { + case KeyCode.Tab: + AcceptSelectedSuggestion(); + Swallow(evt); + return; + case KeyCode.DownArrow: + MoveSelection(1); + Swallow(evt); + return; + case KeyCode.UpArrow: + MoveSelection(-1); + Swallow(evt); + return; + } + + // Tab reaches the field twice — once carrying keyCode, once carrying only the + // '\t' character. Swallow the second one too or a literal tab lands in the text. + if (evt.character == '\t') Swallow(evt); + } + + // UI Toolkit also translates Tab into a focus-ring navigation event, separately + // from the key event above. Block it so Tab can't move focus off the console. + private void OnConsoleNavigationMove(NavigationMoveEvent evt) + { + if (!consoleOpen) return; + Swallow(evt); + } + + // Consume an event so neither the text engine nor the focus ring acts on it. + // EventBase.PreventDefault is obsolete in Unity 6; FocusController.IgnoreEvent is + // its replacement for the focus-navigation half. + private void Swallow(EventBase evt) + { + evt.StopImmediatePropagation(); + 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); + RefreshSuggestions(); // Suppress the toggle key and Enter for this frame and the next so the // keypress that opened the console doesn't also get typed into the field. @@ -199,27 +322,71 @@ namespace TD.Dev consoleContainer.pickingMode = PickingMode.Ignore; } + suggestions.Clear(); + selectedIndex = 0; + if (suggestionList != null) + { + suggestionList.Clear(); + suggestionList.style.display = DisplayStyle.None; + } + consoleOpen = false; toggleSuppressFrame = Time.frameCount + 1; + SuppressHudChatOpen(); + } - // The Enter that submitted/cancelled this console also gets polled by - // HUDController's chat this same frame — suppress its "Enter opens chat" - // branch so closing the debug console doesn't pop chat open too. + // The Enter that submitted/accepted in this console also gets polled by + // HUDController's chat this same frame — suppress its "Enter opens chat" branch + // (HUDController.HandleChatInput, which gates only on this frame counter) so + // using the debug console never pops chat open too. + private static void SuppressHudChatOpen() + { HUDController.SuppressChatOpenUntilFrame = Time.frameCount + 1; } private void SubmitCommand() { 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 + // and only ever hit Tab when you want to skip past the highlighted row. + if (!string.IsNullOrWhiteSpace(text) && suggestions.Count > 0 && !IsComplete(text)) + { + AcceptSelectedSuggestion(); + SuppressHudChatOpen(); + return; + } + CloseConsole(); if (string.IsNullOrWhiteSpace(text)) return; ExecuteCommand(text.Trim()); } + // ---------------------------------------------------------------- grammar + + /// + /// One token level of the command grammar. Literal levels list their + /// ; a level that takes a free-form final argument sets + /// to enumerate the valid values for completion. + /// + private sealed class CommandNode + { + public string Token; // literal token; null for the root + 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 readonly List Children = new List(); + } + + 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 - // should be gettable from the console. + // should be gettable from the console — the command tree and its autocomplete + // list are both built from this. private static readonly Dictionary DraftTypeAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -229,32 +396,376 @@ namespace TD.Dev { "effect", typeof(BuilderEffectDraftOption) }, }; - private void ExecuteCommand(string text) + private CommandNode commandTree; + private CommandNode CommandTree => commandTree ?? (commandTree = BuildCommandTree()); + + private static CommandNode BuildCommandTree() { - string[] tokens = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length < 3 || !string.Equals(tokens[0], "get", StringComparison.OrdinalIgnoreCase)) + var root = new CommandNode(); + + var get = new CommandNode { - Debug.Log($"[DebugConsole] Unrecognized command: \"{text}\". Try: get "); + Token = "get", + Hint = "grant a draft option to the local player", + }; + root.Children.Add(get); + + foreach (var pair in DraftTypeAliases) + { + var optionType = pair.Value; + get.Children.Add(new CommandNode + { + Token = pair.Key, + Hint = "", + OptionType = optionType, + Executable = true, + Values = () => DraftOptionNames(optionType), + }); + } + + return root; + } + + // Display names of every pooled DraftOption of the given subclass, alphabetically. + // Recomputed per keystroke rather than cached: DraftPool is a scene singleton that + // may not exist yet when the console is built, and the pool is only a handful of + // entries. Uses DraftPool's existing Count/Get surface — the same one ExecuteGet + // walks — so completion can never offer something the command can't resolve. + private static List DraftOptionNames(Type optionType) + { + var names = new List(); + + var pool = DraftPool.Instance; + if (pool == null) return names; + + for (int i = 0; i < pool.Count; i++) + { + var option = pool.Get(i); + if (option == null || !optionType.IsInstanceOfType(option)) 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) + { + var tokens = new List(); + for (int i = 0; i < node.Children.Count; i++) tokens.Add(node.Children[i].Token); + tokens.Sort(StringComparer.OrdinalIgnoreCase); + return string.Join(", ", tokens); + } + + private static CommandNode FindChild(CommandNode node, string token) + { + for (int i = 0; i < node.Children.Count; i++) + if (string.Equals(node.Children[i].Token, token, StringComparison.OrdinalIgnoreCase)) + return node.Children[i]; + return null; + } + + // Whitespace tokenizer that also reports where each token starts, so completion + // knows which span of the raw input a candidate would replace. + private static void Tokenize(string text, List tokens, List starts) + { + tokens.Clear(); + starts.Clear(); + + int i = 0; + while (i < text.Length) + { + while (i < text.Length && char.IsWhiteSpace(text[i])) i++; + if (i >= text.Length) break; + + int start = i; + while (i < text.Length && !char.IsWhiteSpace(text[i])) i++; + tokens.Add(text.Substring(start, i - start)); + starts.Add(start); + } + } + + // ------------------------------------------------------------- completion + + private struct Suggestion + { + public string Text; // what replaces the current fragment + public string Hint; // dim trailing text, display only + public bool AppendSpace; // true when more tokens follow this one + } + + private readonly List walkTokens = new List(); + private readonly List walkStarts = new List(); + + /// + /// Candidates for the token currently being typed, plus (via + /// ) the index in where + /// accepting one starts overwriting. + /// + private List ComputeSuggestions(string text, out int fragStart) + { + text = text ?? string.Empty; + Tokenize(text, walkTokens, walkStarts); + + bool endsWithSpace = text.Length > 0 && char.IsWhiteSpace(text[text.Length - 1]); + + var node = CommandTree; + int i = 0; + + while (true) + { + // Free-form final argument: everything from here to the end of the line is + // one value, so multi-word names ("Slow Area") complete as a single unit — + // matching how ExecuteCommand rejoins the trailing tokens. + if (node.Values != null) + { + fragStart = i < walkTokens.Count ? walkStarts[i] : text.Length; + string valueFragment = i < walkTokens.Count + ? text.Substring(fragStart).Trim() + : string.Empty; + return FilterValues(node.Values(), valueFragment); + } + + // Nothing typed at this level yet — offer everything it accepts. + if (i >= walkTokens.Count) + { + fragStart = text.Length; + return FilterChildren(node, string.Empty); + } + + // Last token with no trailing space is still being typed: complete it + // rather than trying to descend through it. + if (i == walkTokens.Count - 1 && !endsWithSpace) + { + fragStart = walkStarts[i]; + return FilterChildren(node, walkTokens[i]); + } + + var child = FindChild(node, walkTokens[i]); + if (child == null) + { + // A finished token that matches nothing — the rest of the line is + // unreachable, so there's nothing useful to suggest. + fragStart = walkStarts[i]; + return new List(); + } + + node = child; + i++; + } + } + + private static List FilterChildren(CommandNode node, string fragment) + { + var result = new List(); + for (int i = 0; i < node.Children.Count; i++) + { + var child = node.Children[i]; + if (fragment.Length > 0 && + !child.Token.StartsWith(fragment, StringComparison.OrdinalIgnoreCase)) + continue; + + result.Add(new Suggestion + { + Text = child.Token, + Hint = child.Hint, + AppendSpace = child.Children.Count > 0 || child.Values != null, + }); + } + result.Sort((a, b) => string.Compare(a.Text, b.Text, StringComparison.OrdinalIgnoreCase)); + return result; + } + + // Values are matched on the same normalized form execution uses, so anything the + // list offers for "slow_ar" is something ExecuteGet would also accept. + private static List FilterValues(List values, string fragment) + { + var result = new List(); + string normalizedFragment = Normalize(fragment); + + for (int i = 0; i < values.Count; i++) + { + if (normalizedFragment.Length > 0 && + !Normalize(values[i]).StartsWith(normalizedFragment, StringComparison.Ordinal)) + continue; + + result.Add(new Suggestion { Text = values[i], AppendSpace = false }); + } + return result; + } + + /// True when is a runnable command as typed. + private bool IsComplete(string text) + { + Tokenize(text ?? string.Empty, walkTokens, walkStarts); + + var node = CommandTree; + int i = 0; + + while (i < walkTokens.Count) + { + if (node.Values != null) break; + + var child = FindChild(node, walkTokens[i]); + if (child == null) return false; + + node = child; + i++; + } + + if (!node.Executable) return false; + + if (node.Values != null) + { + if (i >= walkTokens.Count) return false; // value level, nothing typed for it + + string normalized = Normalize(string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i))); + var values = node.Values(); + for (int v = 0; v < values.Count; v++) + if (Normalize(values[v]) == normalized) return true; + + return false; + } + + return true; + } + + // --------------------------------------------------------- suggestion UI + + private void RefreshSuggestions() + { + 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. + string previouslySelected = selectedIndex >= 0 && selectedIndex < suggestions.Count + ? suggestions[selectedIndex].Text + : null; + + suggestions = ComputeSuggestions(commandInput.value, out fragmentStart); + + selectedIndex = 0; + if (previouslySelected != null) + { + for (int i = 0; i < suggestions.Count; i++) + { + if (!string.Equals(suggestions[i].Text, previouslySelected, StringComparison.Ordinal)) + continue; + selectedIndex = i; + break; + } + } + + RebuildSuggestionRows(); + } + + private void RebuildSuggestionRows() + { + if (suggestionList == null) return; + + suggestionList.Clear(); + + if (suggestions.Count == 0) + { + suggestionList.style.display = DisplayStyle.None; return; } - string nameToken = string.Join(" ", tokens, 2, tokens.Length - 2); - ExecuteGet(tokens[1], nameToken); + 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) + { + if (suggestions.Count == 0) return; + + selectedIndex = (selectedIndex + delta) % suggestions.Count; + if (selectedIndex < 0) selectedIndex += suggestions.Count; + + RebuildSuggestionRows(); + } + + private bool AcceptSelectedSuggestion() + { + if (commandInput == null) return false; + if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false; + + var suggestion = suggestions[selectedIndex]; + string text = commandInput.value ?? string.Empty; + int start = Mathf.Clamp(fragmentStart, 0, text.Length); + + string completed = text.Substring(0, start) + suggestion.Text + + (suggestion.AppendSpace ? " " : string.Empty); + + // 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); + RefreshSuggestions(); + return true; + } + + // ------------------------------------------------------------- execution + + private void ExecuteCommand(string text) + { + Tokenize(text, walkTokens, walkStarts); + + var node = CommandTree; + int i = 0; + + while (i < walkTokens.Count && node.Values == null) + { + var child = FindChild(node, walkTokens[i]); + if (child == null) + { + Debug.Log($"[DebugConsole] Unrecognized token \"{walkTokens[i]}\" in " + + $"\"{text}\". Expected one of: {ChildTokens(node)}. {UsageHint}"); + return; + } + + node = child; + i++; + } + + if (node.Values == null || !node.Executable || i >= walkTokens.Count) + { + Debug.Log($"[DebugConsole] Incomplete command: \"{text}\". {UsageHint}"); + return; + } + + string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i)); + 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 void ExecuteGet(string typeToken, string nameToken) + private void ExecuteGet(string typeToken, Type optionType, string nameToken) { - if (!DraftTypeAliases.TryGetValue(typeToken, out var optionType)) - { - Debug.LogWarning($"[DebugConsole] Unknown draft option type \"{typeToken}\". " + - $"Valid types: {string.Join(", ", DraftTypeAliases.Keys)}"); - return; - } - var pool = DraftPool.Instance; if (pool == null) { diff --git a/Assets/_Project/Scripts/Dev/DevWaveControls.cs b/Assets/_Project/Scripts/Dev/DevWaveControls.cs index 2dfd6ff..2b941b1 100644 --- a/Assets/_Project/Scripts/Dev/DevWaveControls.cs +++ b/Assets/_Project/Scripts/Dev/DevWaveControls.cs @@ -52,6 +52,11 @@ namespace TD.Dev if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer) return; + // IMGUI draws after every runtime UI Toolkit panel, so this box would cover the + // debug console's autocomplete list no matter what sorting order the console's + // PanelSettings uses. Yield to the console while it's open. + if (DebugConsole.IsOpen) return; + // Anchored below the top HUD bar so it doesn't overlap gold/wave/lives. const float topOffset = 90f; GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls"); From 11ad19992d29badc7b47a5826b75f35711a2e303 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 28 Jul 2026 21:40:59 -0700 Subject: [PATCH 04/14] enemies split, but it needs a bit of work --- .../BuilderSpells/FireballSpell.asset | 2 +- .../_Project/Definitions/EnemyAbilities.meta | 8 ++++ .../EnemyAbilities/SplitOnDeathAbility.asset | 20 ++++++++ .../SplitOnDeathAbility.asset.meta | 8 ++++ .../Enemies/Enemy_CrystalGolem_Blue.prefab | 16 ++++++- Assets/_Project/Scenes/Levels/9Player.unity | 48 +++++++++++++++++++ .../EnemyAbilities/EnemyAbilityPool.cs | 2 +- .../SplitOnDeathAbilityDefinition.cs | 5 ++ .../_Project/Scripts/Gameplay/EnemyAbility.cs | 3 ++ .../_Project/Scripts/Gameplay/WaveManager.cs | 2 + 10 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 Assets/_Project/Definitions/EnemyAbilities.meta create mode 100644 Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset create mode 100644 Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset.meta diff --git a/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset index b63d958..e713412 100644 --- a/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset +++ b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset @@ -15,7 +15,7 @@ MonoBehaviour: DisplayName: Fireball Description: Shoot a fireball Icon: {fileID: 21300000, guid: eff45e5e3dab24f438c054a095d34578, type: 3} - Cooldown: 1 + Cooldown: 15 TargetType: 0 Radius: 5 enemyLayerMask: diff --git a/Assets/_Project/Definitions/EnemyAbilities.meta b/Assets/_Project/Definitions/EnemyAbilities.meta new file mode 100644 index 0000000..d927cf3 --- /dev/null +++ b/Assets/_Project/Definitions/EnemyAbilities.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 37cbc8b63ce7ae2e4b4abcfd7274bee7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset b/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset new file mode 100644 index 0000000..6ca1f9e --- /dev/null +++ b/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset @@ -0,0 +1,20 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 21543cb1b54f0b16e9c80584f68988b3, type: 3} + m_Name: SplitOnDeathAbility + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.SplitOnDeathAbilityDefinition + DisplayName: Split on Death + Description: The enemy splits when it dies. + Weight: 100 + SplitInto: {fileID: 11400000, guid: a05af37e2256f164d80a66cc2a582466, type: 2} + SplitCount: 2 + ScatterRadius: 0.5 diff --git a/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset.meta b/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset.meta new file mode 100644 index 0000000..fae7041 --- /dev/null +++ b/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0305468bc8f912d429a8b20bf23f947c +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Blue.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Blue.prefab index 546fd71..cef9c0c 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Blue.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Blue.prefab @@ -17,6 +17,7 @@ GameObject: - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} - component: {fileID: -1575784752119152190} + - component: {fileID: 154042129325690845} m_Layer: 10 m_Name: Enemy_CrystalGolem_Blue m_TagString: Untagged @@ -53,7 +54,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 2814637749 + GlobalObjectIdHash: 1115713368 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -224,6 +225,19 @@ MonoBehaviour: volume: 0.75 minPitch: 0.95 maxPitch: 1.05 +--- !u!114 &154042129325690845 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index 9a4d028..dd7bef0 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -2276,6 +2276,53 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &181002639 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 181002641} + - component: {fileID: 181002640} + m_Layer: 0 + m_Name: EnemyAbilityPool + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &181002640 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 181002639} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4409eb25a8be555de8dc3a73d4ed95db, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.EnemyAbilityPool + abilities: + - {fileID: 11400000, guid: 0305468bc8f912d429a8b20bf23f947c, type: 2} + noAbilityWeight: 100 +--- !u!4 &181002641 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 181002639} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 15.94346, y: 0, z: 194.22044} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!43 &203092944 Mesh: m_ObjectHideFlags: 0 @@ -29189,3 +29236,4 @@ SceneRoots: - {fileID: 993870445} - {fileID: 759470735} - {fileID: 954434161} + - {fileID: 181002641} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs index cc9270e..30fe26c 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs @@ -28,7 +28,7 @@ namespace TD.Gameplay.EnemyAbilities [Tooltip("Relative weight of an enemy rolling no ability at all, vs. the combined " + "weight of every entry in 'abilities'. Higher = abilities are rarer.")] [Min(0f)] - [SerializeField] private float noAbilityWeight = 20f; + [SerializeField] private float noAbilityWeight = 100f; // Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count, // not authored count, so an out-of-range Kind is a compile-time impossibility rather diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs index 364e16b..687e676 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs @@ -28,12 +28,17 @@ namespace TD.Gameplay.EnemyAbilities public override void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { + Debug.Log($"[SplitOnDeathAbility] ServerOnDeath fired for {instance.name}. " + + $"SplitInto={(SplitInto != null ? SplitInto.name : "null")}, " + + $"WaveManager.Instance={(WaveManager.Instance != null)}"); + if (SplitInto == null) return; var movement = instance.GetComponent(); var atTile = GridCoordinates.WorldToGrid(instance.transform.position); var ownerSlot = movement != null ? movement.OriginZone : PlayerSlot.None; + Debug.Log($"[SplitOnDeathAbility] Spawning {SplitCount}x {SplitInto.name} at tile {atTile}."); WaveManager.Instance?.ServerSpawnSplitEnemies(SplitInto, SplitCount, atTile, ownerSlot, ScatterRadius); } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs index 34c7d79..93a577c 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs @@ -70,6 +70,9 @@ namespace TD.Gameplay kind.Value = (byte)(Definition != null ? Definition.Kind : EnemyAbilityKind.None); hasPendingInit = false; + if (Definition != null) + Debug.Log($"[EnemyAbility] {name} rolled {Definition.Kind}."); + Definition?.ServerOnSpawn(this); } diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index 67beb6c..c4af7e3 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -580,6 +580,8 @@ namespace TD.Gameplay // this kill's activeEnemyCount-- below, or a split on a wave's last enemy could let // CheckWaveComplete see activeEnemyCount hit 0 and advance the wave prematurely. var ability = health.GetComponent(); + Debug.Log($"[WaveManager] HandleEnemyKilled: ability={(ability != null)}, " + + $"definition={(ability != null ? ability.Definition?.name ?? "null" : "n/a")}"); ability?.Definition?.ServerOnDeath(ability, health); UnsubscribeEnemy(health); From 0498e7e7860b634ba05e37b78a3d586db6a32548 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 28 Jul 2026 21:58:57 -0700 Subject: [PATCH 05/14] Updating slow upgrade icon --- .../Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset b/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset index 0465991..90d9579 100644 --- a/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.SlowAreaUpgradedSpellDefinition DisplayName: Slow Area II Description: Upgraded Slow Area. Slows for 50% longer. - Icon: {fileID: 0} + Icon: {fileID: 21300000, guid: 5f5072d8c9f626c4690a7b231c7488f9, type: 3} Cooldown: 10 TargetType: 1 Radius: 5 From 0b7bc936ef623fd293d642fa45a6bfc25cc3316b Mon Sep 17 00:00:00 2001 From: Ben Calegari Date: Tue, 28 Jul 2026 22:01:28 -0700 Subject: [PATCH 06/14] Add settings menu to control audio and return to main menu --- Assets/_Project/Scripts/Audio/AudioManager.cs | 18 +- .../Scripts/Audio/AudioVolumeSettings.cs | 97 +++++ .../Scripts/Audio/AudioVolumeSettings.cs.meta | 2 + Assets/_Project/Scripts/Audio/MusicPlayer.cs | 40 +- .../Gameplay/BuilderInputController.cs | 9 +- .../Gameplay/BuilderSpellCastController.cs | 2 +- .../Scripts/Gameplay/CameraController.cs | 7 +- .../Scripts/Gameplay/TowerPaintController.cs | 5 +- Assets/_Project/Scripts/UI/GameMenuView.cs | 407 ++++++++++++++++++ .../_Project/Scripts/UI/GameMenuView.cs.meta | 2 + Assets/_Project/Scripts/UI/HUDController.cs | 103 ++++- Assets/_Project/UI/HUD.uss | 24 ++ Assets/_Project/UI/HUD.uxml | 3 + 13 files changed, 697 insertions(+), 22 deletions(-) create mode 100644 Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs create mode 100644 Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta create mode 100644 Assets/_Project/Scripts/UI/GameMenuView.cs create mode 100644 Assets/_Project/Scripts/UI/GameMenuView.cs.meta diff --git a/Assets/_Project/Scripts/Audio/AudioManager.cs b/Assets/_Project/Scripts/Audio/AudioManager.cs index 21099bb..5fbc61b 100644 --- a/Assets/_Project/Scripts/Audio/AudioManager.cs +++ b/Assets/_Project/Scripts/Audio/AudioManager.cs @@ -11,6 +11,12 @@ namespace TD.Audio { public AudioCategory category; public int maxVoices; + + // NOTE: currently inert. It seeds each pooled AudioSource at init, but every + // Play() call overwrites src.volume with (per-sound volume × SFX slider), so + // this value never reaches the mix. Left as-is rather than "fixed" because + // multiplying it in would halve every existing sound (authored scenes use 0.5). + // Wire it up only alongside a pass to re-level the per-sound volumes. [Range(0f, 1f)] public float volume; } @@ -58,6 +64,16 @@ namespace TD.Audio } } + /// + /// Plays a one-shot on the given category's voice pool. is + /// the per-sound authored level; it's scaled by the player's SFX slider + /// () on the way in. + /// + /// + /// The scalar is sampled at play time, so a sound already in flight keeps the volume + /// it started with when the slider moves. Every SFX here is short, so that's + /// imperceptible and avoids having to track live voices. + /// public void Play(AudioClip clip, AudioCategory category, float pitch = 1f, float volume = 1f) { if (clip == null) return; @@ -66,7 +82,7 @@ namespace TD.Audio int idx = indices[category]; var src = pool[idx % pool.Length]; src.pitch = pitch; - src.volume = volume; + src.volume = volume * AudioVolumeSettings.SfxScalar; src.clip = clip; src.Play(); indices[category] = idx + 1; diff --git a/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs new file mode 100644 index 0000000..5df40a6 --- /dev/null +++ b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs @@ -0,0 +1,97 @@ +// Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs +using UnityEngine; + +namespace TD.Audio +{ + /// + /// Player-facing volume mix, persisted in and shared by + /// every audio consumer in the game. Two buses: + /// + /// Music — the looping scene track played by . + /// SFX — everything routed through + /// (spells, tower fire, build/land, enemy death, sell, UI clicks). + /// + /// + /// + /// Why not named AudioSettings. UnityEngine.AudioSettings already + /// exists, so a TD.Audio.AudioSettings would be an ambiguous reference in any + /// file that has both using UnityEngine; and using TD.Audio;. + /// + /// Units. Public values are ints 0..100 — the numbers the settings slider + /// shows. Consumers multiply by / , + /// which apply a squared taper so the slider's lower half isn't perceptually dead + /// (linear amplitude drops off much faster than perceived loudness). At 100 the + /// scalar is exactly 1, so the default mix is byte-identical to the pre-settings + /// behaviour. + /// + /// Live updates. fires on every mutation. + /// Continuous sources (music) subscribe and re-apply; one-shots read the scalar at + /// play time, so a mid-flight SFX keeps the volume it started with. That's fine — + /// they're all short. + /// + public static class AudioVolumeSettings + { + public const int MinVolume = 0; + public const int MaxVolume = 100; + + private const int DefaultVolume = 100; + private const string MusicKey = "td.audio.music"; + private const string SfxKey = "td.audio.sfx"; + + /// Raised whenever music or SFX volume changes. + public static event System.Action OnChanged; + + // -1 = not yet read from PlayerPrefs. Statics are cleared on domain reload + // (entering play mode in the editor), so the lazy load re-runs each session. + private static int music = -1; + private static int sfx = -1; + + public static int MusicVolume + { + get + { + if (music < 0) music = PlayerPrefs.GetInt(MusicKey, DefaultVolume); + return music; + } + set => Set(ref music, MusicKey, value); + } + + public static int SfxVolume + { + get + { + if (sfx < 0) sfx = PlayerPrefs.GetInt(SfxKey, DefaultVolume); + return sfx; + } + set => Set(ref sfx, SfxKey, value); + } + + /// Amplitude multiplier (0..1) for the music bus. + public static float MusicScalar => ToScalar(MusicVolume); + + /// Amplitude multiplier (0..1) for the sound-effects bus. + public static float SfxScalar => ToScalar(SfxVolume); + + /// + /// Flushes PlayerPrefs to disk. Setters only write the in-memory pref (cheap + /// enough to call on every frame of a slider drag); call this once when the + /// settings UI closes so the choice survives a crash. + /// + public static void Flush() => PlayerPrefs.Save(); + + private static void Set(ref int field, string key, int value) + { + int clamped = Mathf.Clamp(value, MinVolume, MaxVolume); + if (field == clamped) return; + field = clamped; + PlayerPrefs.SetInt(key, clamped); + OnChanged?.Invoke(); + } + + private static float ToScalar(int volume) + { + float linear = Mathf.Clamp01(volume / (float)MaxVolume); + return linear * linear; + } + } +} diff --git a/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta new file mode 100644 index 0000000..2b1f334 --- /dev/null +++ b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d2f94f5f8df24e8aa1f47d9f0e3ecf4 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Audio/MusicPlayer.cs b/Assets/_Project/Scripts/Audio/MusicPlayer.cs index 325b15c..781e36e 100644 --- a/Assets/_Project/Scripts/Audio/MusicPlayer.cs +++ b/Assets/_Project/Scripts/Audio/MusicPlayer.cs @@ -7,6 +7,12 @@ namespace TD.Audio /// Plays a single looping music track for the scene it lives in. /// Add to a GameObject in the scene, assign a clip, and it starts on load. /// + /// + /// Volume is the authored scaled by the player's music slider + /// (). Unlike one-shot SFX, the track is + /// continuous, so this subscribes to and + /// re-applies live while the slider is dragged. + /// [RequireComponent(typeof(AudioSource))] public class MusicPlayer : MonoBehaviour { @@ -14,14 +20,36 @@ namespace TD.Audio [Range(0f, 1f)] [SerializeField] private float volume = 1f; + private AudioSource source; + + private void Awake() + { + source = GetComponent(); + } + + private void OnEnable() + { + AudioVolumeSettings.OnChanged += ApplyVolume; + } + + private void OnDisable() + { + AudioVolumeSettings.OnChanged -= ApplyVolume; + } + private void Start() { - var src = GetComponent(); - src.clip = clip; - src.volume = volume; - src.loop = true; - src.playOnAwake = false; - src.Play(); + source.clip = clip; + source.loop = true; + source.playOnAwake = false; + ApplyVolume(); + source.Play(); + } + + private void ApplyVolume() + { + if (source == null) return; + source.volume = volume * AudioVolumeSettings.MusicScalar; } } } diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs index 41eeee6..955f301 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs @@ -139,10 +139,11 @@ namespace TD.Gameplay // Escape: clear selection. Allowed during placement mode too — Escape never // means anything else here, and clearing selection during placement is fine. - // Suppressed while chat (or any HUD text field) has focus, since Escape there - // means "cancel typing" and should not also clear the unit selection. + // Suppressed while chat (or any HUD text field) has focus or the gear menu is + // open, since Escape there means "cancel typing" / "back out of the menu" and + // should not also clear the unit selection. if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame - && !HUDController.IsTextInputActive) + && !HUDController.IsUiCapturingInput) { SelectionState.Instance?.Clear(); } @@ -153,7 +154,7 @@ namespace TD.Gameplay // Tab: select the builder, or (if already selected) recenter the camera on it. if (keyboard != null && keyboard.tabKey.wasPressedThisFrame - && !HUDController.IsTextInputActive) + && !HUDController.IsUiCapturingInput) { var selection = SelectionState.Instance; if (selection != null && selection.IsSelected(builder)) diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs index 745ebe8..557fe3d 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs @@ -95,7 +95,7 @@ namespace TD.Gameplay private void Update() { - if (!HUDController.IsTextInputActive) + if (!HUDController.IsUiCapturingInput) ScanHotkeys(); if (activeSlot < 0) return; // idle — nothing to aim diff --git a/Assets/_Project/Scripts/Gameplay/CameraController.cs b/Assets/_Project/Scripts/Gameplay/CameraController.cs index bd071d6..4a2009a 100644 --- a/Assets/_Project/Scripts/Gameplay/CameraController.cs +++ b/Assets/_Project/Scripts/Gameplay/CameraController.cs @@ -229,9 +229,10 @@ namespace TD.Gameplay // Keyboard: arrow keys only. WASD is reserved for tower-build hotkeys (the command // grid) — camera panning is arrow keys plus the mouse edge-pan below. Suppressed - // entirely while the player is typing so arrow keys in chat navigate text instead of - // panning. (Edge-pan below stays active since it's mouse-driven.) - var kb = HUDController.IsTextInputActive ? null : Keyboard.current; + // entirely while the player is typing (so arrow keys in chat navigate text instead + // of panning) or while the gear menu is open. Edge-pan below is mouse-driven and + // gated separately by the pointer-over-HUD check, which the menu overlay satisfies. + var kb = HUDController.IsUiCapturingInput ? null : Keyboard.current; if (kb != null) { if (kb.leftArrowKey.isPressed) dir.x -= 1f; diff --git a/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs index e6c52e0..05a1613 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs @@ -73,10 +73,11 @@ namespace TD.Gameplay var keyboard = Keyboard.current; // Right-click or Escape exits paint mode. (Escape is ignored while a HUD text - // field has focus so it means "cancel typing" there, matching other systems.) + // field has focus or the gear menu is open, so it means "cancel typing" / + // "back out of the menu" there, matching other systems.) bool escape = keyboard != null && keyboard.escapeKey.wasPressedThisFrame - && !HUDController.IsTextInputActive; + && !HUDController.IsUiCapturingInput; if (mouse.rightButton.wasPressedThisFrame || escape) { CancelPaint(); diff --git a/Assets/_Project/Scripts/UI/GameMenuView.cs b/Assets/_Project/Scripts/UI/GameMenuView.cs new file mode 100644 index 0000000..7db20bd --- /dev/null +++ b/Assets/_Project/Scripts/UI/GameMenuView.cs @@ -0,0 +1,407 @@ +// Assets/_Project/Scripts/UI/GameMenuView.cs +using UnityEngine; +using UnityEngine.UIElements; +using TD.Audio; + +namespace TD.UI +{ + /// + /// The in-match menu: a gear button in the top-right of the HUD top bar plus a modal + /// overlay with two options — Return to Title Screen and Audio Settings. + /// + /// + /// Ownership. A plain class (not a MonoBehaviour), constructed by + /// and given the HUD's root element — same pattern as + /// MinimapView. Everything is built programmatically and appended last, so it + /// z-orders above the rest of the HUD with no extra UIDocument or PanelSettings. + /// + /// Modality. The overlay is full-screen with + /// , which both dims the match and makes + /// true everywhere — so clicks + /// can't reach towers or the ground underneath. HUDController also sets + /// while it's open, which gates keyboard-driven + /// gameplay (camera pan, hotkeys, Escape handlers). The match does not pause: + /// this is a multiplayer game and waves keep running. + /// + /// Pages. One panel, three swapped bodies: the option list, the audio mixer, + /// and a confirm step for leaving the match. Escape backs out one page at a time and + /// closes from the root page. + /// + public class GameMenuView + { + private enum Page { Root, Audio, ConfirmLeave } + + // ----- Wiring ----------------------------------------------------- + + private readonly System.Action onReturnToTitle; + + private readonly VisualElement overlay; + private readonly Label header; + private readonly VisualElement rootPage; + private readonly VisualElement audioPage; + private readonly VisualElement confirmPage; + + private readonly SliderInt musicSlider; + private readonly Label musicValue; + private readonly SliderInt sfxSlider; + private readonly Label sfxValue; + + private Page page = Page.Root; + + // ----- Public API ------------------------------------------------- + + public bool IsOpen { get; private set; } + + /// The HUD's rootVisualElement. The overlay is appended to it. + /// The top-bar "menu-button" from HUD.uxml. May be null + /// (Escape still works); the gear glyph is painted into it here. + /// Optional sprite for the gear. When null, the icon is drawn + /// procedurally so the button never depends on an art asset existing. + /// Invoked once the player confirms leaving the match. + public GameMenuView(VisualElement root, Button gearButton, Sprite gearIcon, + System.Action onReturnToTitle) + { + this.onReturnToTitle = onReturnToTitle; + + if (gearButton != null) + { + gearButton.text = string.Empty; + gearButton.Add(CreateGearIcon(gearIcon)); + gearButton.clicked += Toggle; + gearButton.tooltip = "Menu (Esc)"; + } + + // ----- Overlay + panel shell ---------------------------------- + + overlay = new VisualElement(); + overlay.style.position = Position.Absolute; + overlay.style.left = 0; + overlay.style.right = 0; + overlay.style.top = 0; + overlay.style.bottom = 0; + overlay.style.alignItems = Align.Center; + overlay.style.justifyContent = Justify.Center; + overlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.55f); + overlay.style.display = DisplayStyle.None; + overlay.pickingMode = PickingMode.Position; + + var panel = new VisualElement(); + panel.style.minWidth = 360; + panel.style.paddingTop = panel.style.paddingBottom = 24; + panel.style.paddingLeft = panel.style.paddingRight = 32; + panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.97f); + panel.style.borderTopWidth = panel.style.borderBottomWidth = + panel.style.borderLeftWidth = panel.style.borderRightWidth = 2; + var border = new Color(0.4f, 0.4f, 0.45f); + panel.style.borderTopColor = panel.style.borderBottomColor = + panel.style.borderLeftColor = panel.style.borderRightColor = border; + panel.style.alignItems = Align.Center; + overlay.Add(panel); + + header = new Label("Menu"); + header.style.fontSize = 24; + header.style.color = Color.white; + header.style.marginBottom = 18; + header.style.unityFontStyleAndWeight = FontStyle.Bold; + panel.Add(header); + + // ----- Page 1: option list ------------------------------------ + + rootPage = new VisualElement(); + rootPage.style.alignItems = Align.Center; + rootPage.Add(MakeMenuButton("Return to Title Screen", () => GoTo(Page.ConfirmLeave))); + rootPage.Add(MakeMenuButton("Audio Settings", () => GoTo(Page.Audio))); + rootPage.Add(MakeMenuButton("Resume", Close)); + panel.Add(rootPage); + + // ----- Page 2: audio mixer ------------------------------------ + + audioPage = new VisualElement(); + audioPage.style.alignItems = Align.Stretch; + audioPage.style.display = DisplayStyle.None; + + audioPage.Add(BuildVolumeRow("Music", AudioVolumeSettings.MusicVolume, + v => AudioVolumeSettings.MusicVolume = v, + out musicSlider, out musicValue)); + audioPage.Add(BuildVolumeRow("Sound Effects", AudioVolumeSettings.SfxVolume, + v => AudioVolumeSettings.SfxVolume = v, + out sfxSlider, out sfxValue)); + + var audioNote = new Label("Sound effects covers spells, towers, building, and enemies."); + audioNote.style.fontSize = 11; + audioNote.style.color = new Color(0.6f, 0.6f, 0.65f); + audioNote.style.marginTop = 4; + audioNote.style.marginBottom = 12; + audioNote.style.whiteSpace = WhiteSpace.Normal; + audioPage.Add(audioNote); + + var audioBack = MakeMenuButton("Back", () => GoTo(Page.Root)); + audioBack.style.alignSelf = Align.Center; + audioPage.Add(audioBack); + panel.Add(audioPage); + + // ----- Page 3: leave confirmation ----------------------------- + + confirmPage = new VisualElement(); + confirmPage.style.alignItems = Align.Center; + confirmPage.style.display = DisplayStyle.None; + + var confirmText = new Label("Leave the match and return to the title screen?"); + confirmText.style.color = new Color(0.88f, 0.88f, 0.9f); + confirmText.style.whiteSpace = WhiteSpace.Normal; + confirmText.style.marginBottom = 16; + confirmText.style.unityTextAlign = TextAnchor.MiddleCenter; + confirmPage.Add(confirmText); + + var confirmRow = new VisualElement(); + confirmRow.style.flexDirection = FlexDirection.Row; + confirmPage.Add(confirmRow); + + var leaveBtn = MakeMenuButton("Leave Match", ConfirmLeave); + leaveBtn.style.marginRight = 12; + leaveBtn.style.minWidth = 150; + confirmRow.Add(leaveBtn); + + var cancelBtn = MakeMenuButton("Cancel", () => GoTo(Page.Root)); + cancelBtn.style.minWidth = 150; + confirmRow.Add(cancelBtn); + panel.Add(confirmPage); + + root.Add(overlay); + } + + public void Open() + { + if (IsOpen) return; + IsOpen = true; + GoTo(Page.Root); + overlay.style.display = DisplayStyle.Flex; + } + + public void Close() + { + if (!IsOpen) return; + IsOpen = false; + overlay.style.display = DisplayStyle.None; + // Volume setters only touch the in-memory pref so a slider drag stays cheap; + // write it out now that the player is done adjusting. + AudioVolumeSettings.Flush(); + } + + public void Toggle() + { + if (IsOpen) Close(); + else Open(); + } + + /// + /// Escape while the menu is open: back out one page, or close from the root page. + /// + public void Back() + { + if (page == Page.Root) Close(); + else GoTo(Page.Root); + } + + // ----- Pages ------------------------------------------------------ + + private void GoTo(Page next) + { + page = next; + + rootPage.style.display = next == Page.Root ? DisplayStyle.Flex : DisplayStyle.None; + audioPage.style.display = next == Page.Audio ? DisplayStyle.Flex : DisplayStyle.None; + confirmPage.style.display = next == Page.ConfirmLeave ? DisplayStyle.Flex : DisplayStyle.None; + + header.text = next switch + { + Page.Audio => "Audio Settings", + Page.ConfirmLeave => "Leave Match", + _ => "Menu", + }; + + // Another surface could have changed the volumes since this page was last + // shown (a future title-screen mixer, a debug command). Re-sync on entry so + // the sliders never display a stale value. + if (next == Page.Audio) SyncSlidersFromSettings(); + } + + private void SyncSlidersFromSettings() + { + // SetValueWithoutNotify: writing the settings value back through the change + // callback would be a no-op today (the setter early-outs on an unchanged value) + // but it's the wrong direction of data flow, so don't. + musicSlider.SetValueWithoutNotify(AudioVolumeSettings.MusicVolume); + musicValue.text = AudioVolumeSettings.MusicVolume.ToString(); + sfxSlider.SetValueWithoutNotify(AudioVolumeSettings.SfxVolume); + sfxValue.text = AudioVolumeSettings.SfxVolume.ToString(); + } + + private void ConfirmLeave() + { + AudioVolumeSettings.Flush(); + IsOpen = false; + overlay.style.display = DisplayStyle.None; + onReturnToTitle?.Invoke(); + } + + // ----- Element builders ------------------------------------------- + + private static Button MakeMenuButton(string text, System.Action onClick) + { + var btn = new Button(() => onClick?.Invoke()) { text = text }; + btn.style.minWidth = 260; + btn.style.height = 40; + btn.style.fontSize = 16; + btn.style.marginBottom = 8; + return btn; + } + + // One mixer row: name, 0..100 slider, live numeric readout. + private static VisualElement BuildVolumeRow(string label, int initial, + System.Action onChange, + out SliderInt slider, out Label valueLabel) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + // FlexStart, not Center: the labels are aligned to the slider's track by + // AlignLabelsToTrack below, which needs every child's box to start at the same + // top edge. See that method for why centering the boxes isn't enough. + row.style.alignItems = Align.FlexStart; + row.style.marginBottom = 10; + + var name = new Label(label); + name.style.color = new Color(0.88f, 0.88f, 0.9f); + name.style.fontSize = 14; + name.style.width = 110; + name.style.flexShrink = 0; + name.style.unityTextAlign = TextAnchor.MiddleLeft; + row.Add(name); + + slider = new SliderInt(AudioVolumeSettings.MinVolume, AudioVolumeSettings.MaxVolume); + slider.style.width = 200; + slider.style.flexShrink = 0; + slider.SetValueWithoutNotify(initial); + row.Add(slider); + + // Fixed width + right-align so the slider doesn't shift as the number goes + // from 1 to 3 digits while dragging. + var value = new Label(initial.ToString()); + value.style.color = Color.white; + value.style.fontSize = 14; + value.style.width = 40; + value.style.flexShrink = 0; + value.style.unityTextAlign = TextAnchor.MiddleRight; + row.Add(value); + + AlignLabelsToTrack(row, slider, name, value); + + var captured = value; + slider.RegisterValueChangedCallback(evt => + { + captured.text = evt.newValue.ToString(); + onChange(evt.newValue); + }); + + valueLabel = value; + return row; + } + + // Vertically lines the row's text up with the groove the player actually sees. + // + // The default runtime theme gives a horizontal slider a box that's taller than its + // track and does NOT center the track inside it, so `align-items: center` on the row + // centers the *boxes* and leaves the text sitting well below the groove. The exact + // offset is a theme detail, so it's measured rather than hardcoded: with the row + // aligned FlexStart every child's box starts at the same top edge, so giving a label + // a height of twice the track's offset from that edge puts its vertically-centered + // text exactly on the track's center line. + // + // Runs on GeometryChangedEvent (not once at build time) because layout hasn't + // happened yet during construction, and because it needs to re-solve when the panel + // is rescaled or the menu is reopened at a different resolution. + private static void AlignLabelsToTrack(VisualElement row, SliderInt slider, + params Label[] labels) + { + // Align to the *tracker* — the thin groove. Not the drag-container: that's the + // full hit area, tall enough to hold the dragger handle (which overhangs the + // groove on both sides), so its center sits below the groove. Fall back through + // the container to the slider itself if the theme's internal names ever change; + // worst case is the old box-centered look, not a broken layout. + VisualElement track = slider.Q(className: "unity-base-slider__tracker") + ?? slider.Q(className: "unity-base-slider__drag-container") + ?? slider; + + void Align(GeometryChangedEvent _) + { + float rowTop = row.worldBound.y; + float trackCenter = track.worldBound.center.y; + float half = trackCenter - rowTop; + if (half <= 0f || float.IsNaN(half)) return; // pre-layout; a later event fixes it + + foreach (var label in labels) + label.style.height = half * 2f; + } + + row.RegisterCallback(Align); + track.RegisterCallback(Align); + } + + // Gear glyph. Drawn with Painter2D rather than a font glyph or a texture: Unity's + // default runtime font has no U+2699 gear, and a procedural icon keeps the button + // working in any scene without an imported sprite. Pass a sprite to override. + private static VisualElement CreateGearIcon(Sprite icon) + { + var el = new VisualElement(); + el.pickingMode = PickingMode.Ignore; + el.style.flexGrow = 1; + + if (icon != null) + { + el.style.backgroundImage = new StyleBackground(icon); + return el; + } + + el.generateVisualContent += PaintGear; + // generateVisualContent runs against contentRect, which is 0×0 until the first + // layout pass — repaint once the element actually has a size. + el.RegisterCallback(_ => el.MarkDirtyRepaint()); + return el; + } + + private static void PaintGear(MeshGenerationContext ctx) + { + var rect = ctx.visualElement.contentRect; + if (rect.width < 4f || rect.height < 4f) return; + + var painter = ctx.painter2D; + Vector2 center = rect.center; + float outer = Mathf.Min(rect.width, rect.height) * 0.5f - 1f; + float ring = outer * 0.62f; + float hub = outer * 0.24f; + + painter.strokeColor = new Color(0.95f, 0.93f, 0.75f); + painter.lineWidth = Mathf.Max(1.5f, outer * 0.20f); + + // Teeth: radial spokes from just inside the ring out to the edge. + const int teeth = 8; + painter.BeginPath(); + for (int i = 0; i < teeth; i++) + { + float angle = i * Mathf.PI * 2f / teeth; + var dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); + painter.MoveTo(center + dir * (ring * 0.85f)); + painter.LineTo(center + dir * outer); + } + painter.Stroke(); + + painter.BeginPath(); + painter.Arc(center, ring, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree)); + painter.Stroke(); + + painter.BeginPath(); + painter.Arc(center, hub, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree)); + painter.Stroke(); + } + } +} diff --git a/Assets/_Project/Scripts/UI/GameMenuView.cs.meta b/Assets/_Project/Scripts/UI/GameMenuView.cs.meta new file mode 100644 index 0000000..0d488ea --- /dev/null +++ b/Assets/_Project/Scripts/UI/GameMenuView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f5b5b3bcd6ea4765a92a784b9c8de13 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 9ff9ca4..488cea9 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -43,6 +43,10 @@ namespace TD.UI [Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f; + [Tooltip("Optional icon for the top-right menu button. Leave empty to use the " + + "procedurally-drawn gear (no art asset required).")] + [SerializeField] private Sprite menuButtonIcon; + [Tooltip("Maximum visible height of the chat feed in pixels. Content past this " + "height is clipped — older messages scroll off the top of the visible area " + "but stay in history (scroll up while chat is open to view).")] @@ -159,6 +163,21 @@ namespace TD.UI // instead of every gameplay script needing to know about every input widget. public static bool IsTextInputActive { get; internal set; } + /// + /// True while a modal UI surface (currently the gear menu) owns the screen. Gameplay + /// systems should treat this exactly like — see + /// , which is the flag they actually gate on. + /// + public static bool IsModalUiOpen { get; private set; } + + /// + /// True when any UI surface is consuming keyboard/mouse input this frame: a focused + /// text field OR an open modal menu. Gameplay input handlers (camera, selection, + /// hotkeys, Escape-to-cancel) gate on this so typing or a menu never doubles as a + /// gameplay command. + /// + public static bool IsUiCapturingInput => IsTextInputActive || IsModalUiOpen; + // Frame until which chat's "Enter opens chat" behavior is suppressed. Other // standalone text-input surfaces (e.g. TD.Dev.DebugConsole) set this when their // own Enter-driven submit/close already consumed the keypress, so the same @@ -183,6 +202,7 @@ namespace TD.UI private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance private MinimapView minimapView; + private GameMenuView gameMenu; // gear button + modal menu overlay private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us // ----- Hotkeys ---------------------------------------------------- @@ -703,6 +723,12 @@ namespace TD.UI // ChatService.PostLocalSystem on every peer. BuildChatPanel(root); + // Gear menu (top-right of the top bar). Built last so its overlay z-orders + // above every other HUD surface. "Return to Title Screen" reuses the same + // disconnect path as the match-end overlay's button. + gameMenu = new GameMenuView(root, Require