From 35ea90f71bbee8a28f95e69f23cdc2f5b500ff46 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 21 Jul 2026 20:56:55 -0700 Subject: [PATCH 01/20] fix slow area spell area of effect --- .../BuilderSpells/SlowAreaSpellDefinition.cs | 31 ++++---- .../Gameplay/BuilderSpells/SlowAreaZone.cs | 77 +++++++++++++++++++ .../BuilderSpells/SlowAreaZone.cs.meta | 2 + 3 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs.meta diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs index bd4cd8b..4885848 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs @@ -14,6 +14,12 @@ namespace TD.Gameplay.BuilderSpells /// while aiming, and this spell resolves against that same radius (no separate field). /// Reuses the existing slow/DoT system () rather than /// introducing a new status mechanism. + /// + /// The cast spawns a that lingers for + /// and keeps re-applying the slow to anything standing in + /// the radius, so enemies walking through the area get slowed rather than only whatever + /// happened to be standing there at the exact instant of cast. + /// /// [CreateAssetMenu(fileName = "SlowAreaSpell", menuName = "TD/Builder Spells/Slow Area")] public class SlowAreaSpellDefinition : BuilderSpellDefinition @@ -35,7 +41,9 @@ namespace TD.Gameplay.BuilderSpells [Range(0f, 1f)] public float SlowFactor = 0.5f; - [Tooltip("Seconds the slow lasts. Re-casting on an already-slowed enemy refreshes it.")] + [Tooltip("Seconds the slow lasts on an affected enemy, and how long the area itself " + + "lingers, re-slowing anything that walks through it. Re-casting on an " + + "already-slowed enemy refreshes it.")] [Min(0f)] public float EffectDuration = 3f; @@ -43,25 +51,12 @@ namespace TD.Gameplay.BuilderSpells { PlayerSlot owner = PlayerMatchState.SlotForClient(clientId); - int count = Physics.OverlapSphereNonAlloc( - targetPoint, Mathf.Max(Radius, 0.01f), s_overlapBuffer, enemyLayerMask); + SlowAreaZone.Spawn(targetPoint, Mathf.Max(Radius, 0.01f), SlowFactor, + EffectDuration, enemyLayerMask, owner); - bool hitAny = false; - for (int i = 0; i < count; i++) - { - var enemyHealth = s_overlapBuffer[i].GetComponent(); - if (enemyHealth == null || enemyHealth.IsDead) continue; - - var enemyStatus = s_overlapBuffer[i].GetComponent(); - if (enemyStatus == null) continue; - - enemyStatus.ApplyEffect(DamageType.Cold, SlowFactor, EffectDuration, owner); - hitAny = true; - } - - return hitAny; + return true; } - + public override void ClientSpawnVfx(Vector3 targetPoint) { if (areaVfxPrefab != null) diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs new file mode 100644 index 0000000..de1c76a --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs @@ -0,0 +1,77 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.BuilderSpells +{ + /// + /// Server-only, non-networked lingering area spawned by + /// . Re-scans its radius on an + /// interval for its lifetime and keeps refreshing the slow on anything standing + /// inside, so enemies get slowed as they walk through rather than only at the + /// instant of cast. + /// + /// + /// Never spawned as a — it runs + /// server-side only, and the slow it applies already reaches clients through + /// 's own NetworkVariable, the same as an instant hit. + /// + public class SlowAreaZone : MonoBehaviour + { + private const float TickInterval = 0.2f; + + private float radius; + private float slowFactor; + private float effectDuration; + private LayerMask enemyLayerMask; + private PlayerSlot owner; + + private readonly Collider[] overlapBuffer = new Collider[64]; + private float tickTimer; + + public static SlowAreaZone Spawn(Vector3 position, float radius, float slowFactor, + float effectDuration, LayerMask enemyLayerMask, PlayerSlot owner) + { + var zoneObject = new GameObject("SlowAreaZone (Server)"); + zoneObject.transform.position = position; + + var zone = zoneObject.AddComponent(); + zone.radius = radius; + zone.slowFactor = slowFactor; + zone.effectDuration = effectDuration; + zone.enemyLayerMask = enemyLayerMask; + zone.owner = owner; + zone.ApplyToOverlapping(); + + Destroy(zoneObject, effectDuration); + return zone; + } + + private void Update() + { + tickTimer -= Time.deltaTime; + if (tickTimer > 0f) return; + + ApplyToOverlapping(); + } + + private void ApplyToOverlapping() + { + tickTimer = TickInterval; + + int count = Physics.OverlapSphereNonAlloc( + transform.position, radius, overlapBuffer, enemyLayerMask); + + for (int i = 0; i < count; i++) + { + var enemyHealth = overlapBuffer[i].GetComponent(); + if (enemyHealth == null || enemyHealth.IsDead) continue; + + var enemyStatus = overlapBuffer[i].GetComponent(); + if (enemyStatus == null) continue; + + enemyStatus.ApplyEffect(DamageType.Cold, slowFactor, effectDuration, owner); + } + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs.meta new file mode 100644 index 0000000..7c880a9 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaZone.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7b00a1f57e8be13cd91c0ed5f515bfc8 \ No newline at end of file From 330a92998534bdfbf30ef094c03b5c895cb274dc Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 21 Jul 2026 21:20:30 -0700 Subject: [PATCH 02/20] remove deprecated buff system --- Assets/_Project/Definitions/Buffs.meta | 8 - .../Definitions/Buffs/ExtraDamage25.asset | 17 -- .../Buffs/ExtraDamage25.asset.meta | 8 - .../Definitions/Buffs/OffensiveBuffs.asset | 18 -- .../Buffs/OffensiveBuffs.asset.meta | 8 - .../BuilderSpells/SlowAreaSpell.asset | 2 +- Assets/_Project/Prefabs/Player/Player.prefab | 18 +- Assets/_Project/Scripts/Combat/TowerCombat.cs | 36 +--- Assets/_Project/Scripts/Core/BuffStat.cs | 12 -- Assets/_Project/Scripts/Core/BuffStat.cs.meta | 2 - .../_Project/Scripts/Gameplay/ActiveBuff.cs | 61 ------ .../Scripts/Gameplay/ActiveBuff.cs.meta | 2 - .../_Project/Scripts/Gameplay/BuffCategory.cs | 23 --- .../Scripts/Gameplay/BuffCategory.cs.meta | 2 - .../Scripts/Gameplay/BuffDefinition.cs | 24 --- .../Scripts/Gameplay/BuffDefinition.cs.meta | 2 - .../Gameplay/BuilderInputController.cs | 7 - .../Scripts/Gameplay/BuilderUpgradeManager.cs | 3 +- .../Scripts/Gameplay/PlayerBuffManager.cs | 195 ------------------ .../Gameplay/PlayerBuffManager.cs.meta | 2 - .../Scripts/Gameplay/PlayerTowerDeck.cs | 4 +- Assets/_Project/Scripts/UI/HUDController.cs | 172 +-------------- 22 files changed, 11 insertions(+), 615 deletions(-) delete mode 100644 Assets/_Project/Definitions/Buffs.meta delete mode 100644 Assets/_Project/Definitions/Buffs/ExtraDamage25.asset delete mode 100644 Assets/_Project/Definitions/Buffs/ExtraDamage25.asset.meta delete mode 100644 Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset delete mode 100644 Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset.meta delete mode 100644 Assets/_Project/Scripts/Core/BuffStat.cs delete mode 100644 Assets/_Project/Scripts/Core/BuffStat.cs.meta delete mode 100644 Assets/_Project/Scripts/Gameplay/ActiveBuff.cs delete mode 100644 Assets/_Project/Scripts/Gameplay/ActiveBuff.cs.meta delete mode 100644 Assets/_Project/Scripts/Gameplay/BuffCategory.cs delete mode 100644 Assets/_Project/Scripts/Gameplay/BuffCategory.cs.meta delete mode 100644 Assets/_Project/Scripts/Gameplay/BuffDefinition.cs delete mode 100644 Assets/_Project/Scripts/Gameplay/BuffDefinition.cs.meta delete mode 100644 Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs delete mode 100644 Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs.meta diff --git a/Assets/_Project/Definitions/Buffs.meta b/Assets/_Project/Definitions/Buffs.meta deleted file mode 100644 index 98de849..0000000 --- a/Assets/_Project/Definitions/Buffs.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: eef6329e4294d86c6978ff9453b56956 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset b/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset deleted file mode 100644 index c707eae..0000000 --- a/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset +++ /dev/null @@ -1,17 +0,0 @@ -%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: b01f9aea62ee8a1c1991bb2a097de224, type: 3} - m_Name: ExtraDamage25 - m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuffDefinition - DisplayName: 25% Increased Damage - Stat: 0 - Multiplier: 1.25 diff --git a/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset.meta b/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset.meta deleted file mode 100644 index b240b6c..0000000 --- a/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 9ec170e540b1f91cca762dcebe81a856 -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 11400000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset b/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset deleted file mode 100644 index 737c5ab..0000000 --- a/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset +++ /dev/null @@ -1,18 +0,0 @@ -%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: 516e8faec948a4b3d977ec019376c438, type: 3} - m_Name: OffensiveBuffs - m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuffCategory - DisplayName: Offensive Buffs - Cost: 5 - Pool: - - {fileID: 11400000, guid: 9ec170e540b1f91cca762dcebe81a856, type: 2} diff --git a/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset.meta b/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset.meta deleted file mode 100644 index ced41c2..0000000 --- a/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: d8ed3b9535538f7fc82be878cc307d26 -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 11400000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset index e2550d2..3439119 100644 --- a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset @@ -20,7 +20,7 @@ MonoBehaviour: Radius: 5 enemyLayerMask: serializedVersion: 2 - m_Bits: 1024 + m_Bits: 1152 areaVfxPrefab: {fileID: 1462673130280185047, guid: bf2f1f2a9a54e9162b80673e3f7eeaf6, type: 3} areaSound: clip: {fileID: 8300000, guid: 038ff3eab9aa0034bac26ccb648ef2a1, type: 3} diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 6e06dfd..ce17790 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -13,7 +13,6 @@ GameObject: - component: {fileID: 2918837822014987993} - component: {fileID: 7845089877743661692} - component: {fileID: 4336209376377567030} - - component: {fileID: 2806524246861401760} - component: {fileID: 2806524246861401799} - component: {fileID: 2806524246861401801} - component: {fileID: 5683786710272852339} @@ -53,7 +52,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 121878297 + GlobalObjectIdHash: 1552073510 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -107,21 +106,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerMatchState ShowTopMostFoldoutHeaderGroup: 1 ---- !u!114 &2806524246861401760 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 3493329038866903420} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 7775ee3a2f441b52480aabf54be6c1b6, type: 3} - m_Name: - m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerBuffManager - ShowTopMostFoldoutHeaderGroup: 1 - categories: - - {fileID: 11400000, guid: d8ed3b9535538f7fc82be878cc307d26, type: 2} --- !u!114 &2806524246861401799 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs index 6c1a53f..3001be9 100644 --- a/Assets/_Project/Scripts/Combat/TowerCombat.cs +++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs @@ -67,11 +67,6 @@ namespace TD.Combat // Cached on OnNetworkSpawn — avoids GetComponent every Update. private TowerInstance towerInstance; - // Lazily resolved on first attack — the owner's slot isn't guaranteed to be - // set at spawn time, so we defer the lookup until combat actually begins. - private PlayerBuffManager ownerBuffManager; - private bool buffManagerResolved; - // Shared OverlapSphere result buffer. 32 covers any realistic enemy // density; size up if profiling reveals overflow. private static readonly Collider[] s_overlapBuffer = new Collider[32]; @@ -239,25 +234,6 @@ namespace TD.Combat ClearTarget(); } - // ----- Buff multiplier lookup ------------------------------------- - - // Resolved lazily on the first attack because the owner slot NetworkVariable - // may not yet have replicated by the time OnNetworkSpawn runs. - private PlayerBuffManager GetOwnerBuffManager() - { - if (buffManagerResolved) return ownerBuffManager; - - var slot = towerInstance.Owner; - if (slot == PlayerSlot.None) return null; - - var matchState = PlayerMatchState.GetForSlot(slot); - if (matchState == null) return null; - - ownerBuffManager = matchState.GetComponent(); - buffManagerResolved = true; - return ownerBuffManager; - } - // ----- Attack tick ------------------------------------------------- private void TickAttack(TowerDefinition def) @@ -265,9 +241,7 @@ namespace TD.Combat attackCooldown -= Time.deltaTime; if (attackCooldown > 0f) return; - float effectiveFireRate = def.AttacksPerSecond - * (GetOwnerBuffManager()?.GetMultiplier(BuffStat.AttackSpeed) ?? 1f); - attackCooldown = 1f / effectiveFireRate; + attackCooldown = 1f / def.AttacksPerSecond; Fire(def); } @@ -484,9 +458,7 @@ namespace TD.Combat private void HitEnemy(in CombatProfile p, EnemyHealth target, PlayerSlot owner) { - var multiplier = GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f; - float effectiveDamage = p.Damage * multiplier; - target.TakeDamage(effectiveDamage, p.DamageType, owner); + target.TakeDamage(p.Damage, p.DamageType, owner); ApplyStatusEffect(p, target, owner); } @@ -525,11 +497,9 @@ namespace TD.Combat return; } - float effectiveDamage = def.Damage - * (GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f); proj.InitializeServer( target, - effectiveDamage, + def.Damage, p.DamageType, p.TargetType, p.SplashRadius, diff --git a/Assets/_Project/Scripts/Core/BuffStat.cs b/Assets/_Project/Scripts/Core/BuffStat.cs deleted file mode 100644 index 3e2132e..0000000 --- a/Assets/_Project/Scripts/Core/BuffStat.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Assets/_Project/Scripts/Core/BuffStat.cs -namespace TD.Core -{ - /// - /// Identifies which tower stat a modifies. - /// - public enum BuffStat : byte - { - Damage = 0, - AttackSpeed = 1, - } -} diff --git a/Assets/_Project/Scripts/Core/BuffStat.cs.meta b/Assets/_Project/Scripts/Core/BuffStat.cs.meta deleted file mode 100644 index b91fc16..0000000 --- a/Assets/_Project/Scripts/Core/BuffStat.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fc84fec503f24bc7693ee29558f45d27 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs b/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs deleted file mode 100644 index 3a54012..0000000 --- a/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Assets/_Project/Scripts/Gameplay/ActiveBuff.cs -using System; -using Unity.Collections; -using Unity.Netcode; -using TD.Core; - -namespace TD.Gameplay -{ - /// - /// One buff currently held by a player. Stored in - /// 's NetworkList so all peers see the same set. - /// - /// - /// IEquatable. NGO's NetworkList indexer setter short-circuits when - /// Equals returns true, silently dropping the write. Every mutable field - /// (only ) is included in the comparison so toggling a - /// buff correctly propagates to clients. - /// - public struct ActiveBuff : INetworkSerializable, IEquatable - { - /// Which tower stat this buff multiplies. - public BuffStat Stat; - - /// Multiplicative factor, e.g. 1.25 for +25%. - public float Multiplier; - - /// - /// False when disabled (e.g. by an enemy ability). Excluded from - /// while false. - /// - public bool IsActive; - - /// Human-readable name for the HUD buff list. - public FixedString64Bytes DisplayName; - - // ----- INetworkSerializable --------------------------------------- - - public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter - { - byte statByte = (byte)Stat; - serializer.SerializeValue(ref statByte); - Stat = (BuffStat)statByte; - - serializer.SerializeValue(ref Multiplier); - serializer.SerializeValue(ref IsActive); - serializer.SerializeValue(ref DisplayName); - } - - // ----- IEquatable ------------------------------------------------- - - public bool Equals(ActiveBuff other) - => Stat == other.Stat - && Multiplier == other.Multiplier - && IsActive == other.IsActive - && DisplayName == other.DisplayName; - - public override bool Equals(object obj) => obj is ActiveBuff other && Equals(other); - - public override int GetHashCode() => HashCode.Combine((int)Stat, Multiplier, IsActive); - } -} diff --git a/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs.meta b/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs.meta deleted file mode 100644 index d6868bd..0000000 --- a/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b09203f8acc450bf3b3c4bd8ef3fe84e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuffCategory.cs b/Assets/_Project/Scripts/Gameplay/BuffCategory.cs deleted file mode 100644 index bad6ee3..0000000 --- a/Assets/_Project/Scripts/Gameplay/BuffCategory.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Assets/_Project/Scripts/Gameplay/BuffCategory.cs -using UnityEngine; - -namespace TD.Gameplay -{ - /// - /// A purchasable category of buffs. The player pays gold - /// and receives a randomly chosen from . - /// - [CreateAssetMenu(fileName = "BuffCategory", menuName = "TD/Buffs/Buff Category")] - public class BuffCategory : ScriptableObject - { - [Tooltip("Name shown on the buff menu purchase button.")] - public string DisplayName; - - [Tooltip("Gold cost to purchase one random buff from this category.")] - [Min(0)] - public int Cost; - - [Tooltip("Set of buffs the player can receive. One is chosen uniformly at random.")] - public BuffDefinition[] Pool; - } -} diff --git a/Assets/_Project/Scripts/Gameplay/BuffCategory.cs.meta b/Assets/_Project/Scripts/Gameplay/BuffCategory.cs.meta deleted file mode 100644 index c2d766d..0000000 --- a/Assets/_Project/Scripts/Gameplay/BuffCategory.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 516e8faec948a4b3d977ec019376c438 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs deleted file mode 100644 index 951972e..0000000 --- a/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Assets/_Project/Scripts/Gameplay/BuffDefinition.cs -using UnityEngine; -using TD.Core; - -namespace TD.Gameplay -{ - /// - /// One possible buff that can be awarded to a player. Lives in a - /// 's pool and is drawn randomly on purchase. - /// - [CreateAssetMenu(fileName = "BuffDefinition", menuName = "TD/Buffs/Buff Definition")] - public class BuffDefinition : ScriptableObject - { - [Tooltip("Name shown in the buff menu and tooltip.")] - public string DisplayName; - - [Tooltip("Which tower stat this buff multiplies.")] - public BuffStat Stat; - - [Tooltip("Multiplicative factor applied to the stat. 1.25 = +25%.")] - [Min(1f)] - public float Multiplier = 1.25f; - } -} diff --git a/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs.meta deleted file mode 100644 index 12b38db..0000000 --- a/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b01f9aea62ee8a1c1991bb2a097de224 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs index 23a1c05..41eeee6 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs @@ -151,13 +151,6 @@ namespace TD.Gameplay // controllers handle right-click as cancel there) and when over HUD. if (isModal) return; - // B: toggle buff menu. - if (keyboard != null && keyboard.bKey.wasPressedThisFrame - && !HUDController.IsTextInputActive) - { - HUDController.Instance?.ToggleBuffMenu(); - } - // Tab: select the builder, or (if already selected) recenter the camera on it. if (keyboard != null && keyboard.tabKey.wasPressedThisFrame && !HUDController.IsTextInputActive) diff --git a/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs b/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs index 5f07d31..f11f9ec 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs @@ -9,8 +9,7 @@ namespace TD.Gameplay { /// /// Per-player set of granted builder effects. Lives on the Player prefab alongside - /// , , and - /// . + /// and . /// /// /// List of granted kinds. A builder effect carries no per-grant state (no diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs deleted file mode 100644 index 52efbdc..0000000 --- a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs +++ /dev/null @@ -1,195 +0,0 @@ -// Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs -using System.Collections.Generic; -using Unity.Collections; -using Unity.Netcode; -using UnityEngine; -using TD.Core; - -namespace TD.Gameplay -{ - /// - /// Holds all buffs currently owned by one player. Lives on the Player prefab - /// alongside and . - /// - /// - /// Purchase flow. The owning client calls - /// with a category index. The server - /// validates gold, deducts the cost, picks a random - /// from the category's pool, and appends an - /// to the replicated list. - /// - /// Multiplier query. is called by - /// on the server each time a tower fires. - /// It multiplies all active buffs for the requested stat together. - /// - /// Enable / disable. Enemies can call - /// and by index to temporarily suppress buffs - /// without removing them from the list. - /// - public class PlayerBuffManager : NetworkBehaviour - { - // ----- Static registry (mirrors PlayerGoldManager pattern) ----------- - - private static readonly Dictionary s_byClientId - = new Dictionary(); - - /// Returns the PlayerBuffManager owned by the given client, or null. - public static PlayerBuffManager GetForClient(ulong clientId) - { - s_byClientId.TryGetValue(clientId, out var mgr); - return mgr; - } - - /// Convenience: the local client's own buff manager. - public static PlayerBuffManager Local - { - get - { - var nm = NetworkManager.Singleton; - if (nm == null) return null; - return GetForClient(nm.LocalClientId); - } - } - - // ----- Inspector ------------------------------------------------------ - - [Tooltip("Purchasable buff categories shown in the buff menu. Index in this " + - "array is the categoryIndex passed to RequestPurchaseBuffRpc.")] - [SerializeField] private BuffCategory[] categories; - - private NetworkList buffs; - - private void Awake() - { - buffs = new NetworkList(); - } - - // ----- NGO lifecycle ---------------------------------------------- - - public override void OnNetworkSpawn() - { - s_byClientId[OwnerClientId] = this; - } - - public override void OnNetworkDespawn() - { - if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) - s_byClientId.Remove(OwnerClientId); - } - - // ----- Public API ------------------------------------------------- - - /// - /// Returns the combined multiplier for across all - /// active buffs owned by this player. Returns 1.0 if no matching buffs - /// are active. Safe to call from any peer. - /// - public float GetMultiplier(BuffStat stat) - { - float result = 1f; - for (int i = 0; i < buffs.Count; i++) - { - var buff = buffs[i]; - if (buff.Stat == stat && buff.IsActive) - result *= buff.Multiplier; - } - return result; - } - - /// - /// Returns the number of buff categories available, for building the UI. - /// - public int CategoryCount => categories?.Length ?? 0; - - /// Returns the category at the given index, or null. - public BuffCategory GetCategory(int index) - => (categories != null && index >= 0 && index < categories.Length) - ? categories[index] - : null; - - /// Read-only access to the buff list for UI rendering. - public NetworkList Buffs => buffs; - - // ----- Server helpers --------------------------------------------- - - /// - /// Server-only: disables the buff at . - /// The buff remains in the list and can be re-enabled. - /// - public void ServerDisableBuff(int index) - { - if (!IsServer) return; - if (index < 0 || index >= buffs.Count) return; - - var b = buffs[index]; - if (!b.IsActive) return; - b.IsActive = false; - buffs[index] = b; - } - - /// Server-only: re-enables the buff at . - public void ServerEnableBuff(int index) - { - if (!IsServer) return; - if (index < 0 || index >= buffs.Count) return; - - var b = buffs[index]; - if (b.IsActive) return; - b.IsActive = true; - buffs[index] = b; - } - - // ----- Purchase RPC ----------------------------------------------- - - /// - /// Owning-client entry point. Sends a request to the server to purchase - /// a random buff from the category at . - /// The server validates gold and adds the buff if the purchase succeeds. - /// - [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] - public void RequestPurchaseBuffRpc(int categoryIndex) - { - if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length) - { - Debug.LogWarning("[PlayerBuffManager] RequestPurchaseBuff: invalid category index."); - return; - } - - var category = categories[categoryIndex]; - if (category == null || category.Pool == null || category.Pool.Length == 0) - { - Debug.LogWarning("[PlayerBuffManager] RequestPurchaseBuff: category has no buffs."); - return; - } - - var goldManager = PlayerGoldManager.GetForClient(OwnerClientId); - if (goldManager == null) - { - Debug.LogError("[PlayerBuffManager] RequestPurchaseBuff: no PlayerGoldManager found."); - return; - } - - if (goldManager.CurrentGold < category.Cost) - { - Debug.Log($"[PlayerBuffManager] Client {OwnerClientId} cannot afford " + - $"'{category.DisplayName}' (cost {category.Cost}, " + - $"gold {goldManager.CurrentGold})."); - return; - } - - goldManager.DeductGold(category.Cost); - - var def = category.Pool[Random.Range(0, category.Pool.Length)]; - buffs.Add(new ActiveBuff - { - Stat = def.Stat, - Multiplier = def.Multiplier, - IsActive = true, - DisplayName = new FixedString64Bytes(def.DisplayName ?? string.Empty), - }); - - Debug.Log($"[PlayerBuffManager] Client {OwnerClientId} purchased buff " + - $"'{def.DisplayName}' ({def.Stat} ×{def.Multiplier})."); - } - } -} diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs.meta deleted file mode 100644 index b1951a5..0000000 --- a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7775ee3a2f441b52480aabf54be6c1b6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs index eb5687c..356d459 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs @@ -7,8 +7,8 @@ namespace TD.Gameplay { /// /// Per-player set of unlocked tower types — the player's "deck". Lives on the - /// Player prefab alongside , - /// , and . + /// Player prefab alongside and + /// . /// /// /// Roguelike keystone. Players no longer see every tower in the match diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index badd2b8..450e0f9 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -101,10 +101,6 @@ namespace TD.UI // Match-end overlay — built once on Start and toggled on Phase changes. private VisualElement matchEndOverlay; - - // Buff menu overlay — toggled by the B key via ToggleBuffMenu(). - private VisualElement buffMenuOverlay; - private VisualElement buffMenuContent; private Label matchEndTitle; // Draft overlay (roguelike between-waves draft). Non-modal: the card row floats at @@ -698,9 +694,6 @@ namespace TD.UI // MatchState.OnPhaseChanged fires Victory or Defeat. BuildMatchEndOverlay(root); - // Build the buff menu overlay. Hidden until the player presses B. - BuildBuffMenuOverlay(root); - // Build the draft overlay (roguelike between-waves draft). Hidden until the // local player has an active draft (or to show the paid "Buy Roll" button). BuildDraftOverlay(root); @@ -1173,13 +1166,11 @@ namespace TD.UI { // Build buttons come from the LOCAL player's deck — the per-player // unlocked set — not the global catalog. Each deck entry is a - // TowerTypeId resolved back to its definition via the catalog. The - // last grid slot is reserved for the Buffs button, so towers fill - // slots 0..GRID_MAX-2. + // TowerTypeId resolved back to its definition via the catalog. var deck = PlayerTowerDeck.Local; if (deck != null) { - int count = Mathf.Min(deck.Count, GRID_MAX - 1); + int count = Mathf.Min(deck.Count, GRID_MAX); for (int i = 0; i < count; i++) { int typeId = deck.GetTypeIdAt(i); @@ -1189,7 +1180,6 @@ namespace TD.UI } } } - cells[GRID_MAX - 1] = CreateBuffMenuButton(HotkeyLayout[GRID_MAX - 1]); } else if (selection is TowerInstance tower) { @@ -1374,9 +1364,7 @@ namespace TD.UI // Sell action for a completed tower. Refund is computed on the TowerInstance (single // source of truth shared with the server) so the badge shows exactly what the player - // gets back. Hotkey is Key.None: the bottom-right slot's letter (B) is already the - // global buff-menu toggle — see CreateBuffMenuButton — so binding it here would fire - // both. The button stays click-only. + // gets back. The button stays click-only (Key.None). private VisualElement CreateSellButton(TowerInstance tower, Key hotkey) { // Only the tower's owner can sell it (the server enforces this too). A non-owner @@ -1399,17 +1387,6 @@ namespace TD.UI return btn; } - private VisualElement CreateBuffMenuButton(Key hotkey) - { - // Key.None so HandleHotkeys doesn't register B here — BuilderInputController - // already fires ToggleBuffMenu on B globally. Registering it in both places - // causes a double-toggle on the same frame when the builder is selected. - return CreateActionButton( - costText: "Buffs [B]", - hotkey: Key.None, - onClick: () => ToggleBuffMenu()); - } - // Cancel action for an in-progress build. Fires the owner-only RPC; the // server cancels the matching job (or, for shelved sites, refunds + despawns // directly), full gold is refunded, the BuildSiteVisual is despawned, and @@ -1712,149 +1689,6 @@ namespace TD.UI // ----- Chat feed + input ----------------------------------------- - // ----- Buff menu overlay ------------------------------------------ - - private void BuildBuffMenuOverlay(VisualElement root) - { - buffMenuOverlay = new VisualElement(); - buffMenuOverlay.style.position = Position.Absolute; - buffMenuOverlay.style.left = 0; - buffMenuOverlay.style.right = 0; - buffMenuOverlay.style.top = 0; - buffMenuOverlay.style.bottom = 0; - buffMenuOverlay.style.alignItems = Align.Center; - buffMenuOverlay.style.justifyContent = Justify.Center; - buffMenuOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.5f); - buffMenuOverlay.style.display = DisplayStyle.None; - buffMenuOverlay.pickingMode = PickingMode.Position; - - var panel = new VisualElement(); - panel.style.minWidth = 340; - panel.style.paddingTop = 20; - panel.style.paddingBottom = 20; - panel.style.paddingLeft = 28; - panel.style.paddingRight = 28; - panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.95f); - 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; - - var title = new Label("Buffs"); - title.style.fontSize = 24; - title.style.color = Color.white; - title.style.unityFontStyleAndWeight = FontStyle.Bold; - title.style.marginBottom = 12; - panel.Add(title); - - // Scrollable content area: current buffs + purchase buttons. - // Rebuilt each time the menu is shown via RefreshBuffMenuContent(). - buffMenuContent = new VisualElement(); - panel.Add(buffMenuContent); - - var closeBtn = new Button(() => SetBuffMenuVisible(false)) { text = "Close [B]" }; - closeBtn.style.marginTop = 16; - closeBtn.style.height = 32; - panel.Add(closeBtn); - - buffMenuOverlay.Add(panel); - root.Add(buffMenuOverlay); - } - - /// - /// Toggles the buff menu overlay. Called by - /// when the player presses B. - /// - public void ToggleBuffMenu() - { - bool nowVisible = buffMenuOverlay?.style.display == DisplayStyle.None; - SetBuffMenuVisible(nowVisible); - } - - private void SetBuffMenuVisible(bool visible) - { - if (buffMenuOverlay == null) return; - buffMenuOverlay.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None; - if (visible) RefreshBuffMenuContent(); - } - - private void RefreshBuffMenuContent() - { - if (buffMenuContent == null) return; - buffMenuContent.Clear(); - - var buffManager = TD.Gameplay.PlayerBuffManager.Local; - - // Current buffs section. - var buffsHeader = new Label("Active Buffs"); - buffsHeader.style.color = new Color(0.7f, 0.9f, 0.7f); - buffsHeader.style.fontSize = 14; - buffsHeader.style.marginBottom = 4; - buffMenuContent.Add(buffsHeader); - - if (buffManager == null || buffManager.Buffs.Count == 0) - { - var none = new Label("None"); - none.style.color = new Color(0.55f, 0.55f, 0.55f); - none.style.marginBottom = 8; - buffMenuContent.Add(none); - } - else - { - for (int i = 0; i < buffManager.Buffs.Count; i++) - { - var buff = buffManager.Buffs[i]; - string statName = buff.Stat == TD.Core.BuffStat.Damage ? "Damage" : "Attack Speed"; - string activeTag = buff.IsActive ? "" : " [DISABLED]"; - var row = new Label($"• {buff.DisplayName} ({statName} ×{buff.Multiplier:F2}){activeTag}"); - row.style.color = buff.IsActive ? Color.white : new Color(0.5f, 0.5f, 0.5f); - row.style.fontSize = 13; - buffMenuContent.Add(row); - } - buffMenuContent.style.marginBottom = 12; - } - - // Purchase buttons section. - var buyHeader = new Label("Purchase"); - buyHeader.style.color = new Color(0.9f, 0.8f, 0.5f); - buyHeader.style.fontSize = 14; - buyHeader.style.marginTop = 8; - buyHeader.style.marginBottom = 4; - buffMenuContent.Add(buyHeader); - - int categoryCount = buffManager?.CategoryCount ?? 0; - if (categoryCount == 0) - { - var none = new Label("No categories available."); - none.style.color = new Color(0.55f, 0.55f, 0.55f); - buffMenuContent.Add(none); - return; - } - - int localGold = TD.Gameplay.PlayerGoldManager.Local?.CurrentGold ?? 0; - for (int i = 0; i < categoryCount; i++) - { - var category = buffManager.GetCategory(i); - if (category == null) continue; - - int idx = i; // capture for lambda - var btn = new Button(() => - { - TD.Gameplay.PlayerBuffManager.Local?.RequestPurchaseBuffRpc(idx); - RefreshBuffMenuContent(); - }) - { - text = $"{category.DisplayName} — {category.Cost}g" - }; - btn.style.height = 34; - btn.style.fontSize = 13; - btn.style.marginBottom = 4; - btn.SetEnabled(localGold >= category.Cost); - buffMenuContent.Add(btn); - } - } - // Bottom-left chat panel. Anchored 12px from the left edge, with the // bottom edge sitting above the 220px bottom-ui. Layout uses a flex // column: scrollable feed on top, input below. The feed clips at From e78ae0cda1e52989c1b2f1e0bd66d930e9851f21 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 21 Jul 2026 21:23:18 -0700 Subject: [PATCH 03/20] remove duplicate extra gold draft option --- Assets/_Project/Scenes/Levels/9Player.unity | 1 - 1 file changed, 1 deletion(-) diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index 8c21309..684df64 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -28785,7 +28785,6 @@ MonoBehaviour: - {fileID: 11400000, guid: f4a2b8d1c6e93f05a7b2d4c8e1f6a930, type: 2} - {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, type: 2} - {fileID: 11400000, guid: 5f390de69ba43b2a6a0d89cf09580320, type: 2} - - {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, type: 2} - {fileID: 11400000, guid: 29b35841f7e5db454903798cb5d83434, type: 2} --- !u!4 &2139601601 Transform: From db9c828e63c3c26392c9c86e6f43ebf7c3b94a43 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Wed, 22 Jul 2026 00:00:05 -0700 Subject: [PATCH 04/20] start of draft option upgrades --- .../Scripts/Gameplay/BuilderUpgradeManager.cs | 24 ++++++- .../Draft/BuilderEffectUpgradeDraftOption.cs | 42 +++++++++++ .../Draft/BuilderSpellUpgradeDraftOption.cs | 52 ++++++++++++++ .../Gameplay/Draft/TowerUpgradeDraftOption.cs | 70 +++++++++++++++++++ .../Draft/TowerUpgradeDraftOption.cs.meta | 2 + .../Scripts/Gameplay/PlayerSpellLoadout.cs | 22 ++++++ .../Scripts/Gameplay/PlayerTowerDeck.cs | 25 +++++++ 7 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs.meta diff --git a/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs b/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs index f11f9ec..6b81aea 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderUpgradeManager.cs @@ -20,8 +20,9 @@ namespace TD.Gameplay /// same constraint on a struct); byte already does, for free. /// /// Granted via draft. - /// calls when a player picks it. Effects are permanent for - /// the match — there is no revoke. + /// calls when a player picks it. Effects are otherwise + /// permanent for the match — the only way one leaves the granted set is + /// swapping it for its upgraded replacement. /// /// Query, don't snapshot. Consumers (e.g. awarding /// kill gold) query this manager live at the point of use rather than towers caching their @@ -117,5 +118,24 @@ namespace TD.Gameplay grantedKinds.Add(value); return true; } + + /// + /// Server-only: replaces a previously granted effect with a different one (an upgrade + /// pick). No-op if the old effect isn't currently granted or the new one already is. + /// Returns true if the swap happened. + /// + public bool ServerUpgradeEffect(BuilderEffectKind oldKind, BuilderEffectKind newKind) + { + if (!IsServer) return false; + + byte newValue = (byte)newKind; + if (grantedKinds.Contains(newValue)) return false; + + byte oldValue = (byte)oldKind; + if (!grantedKinds.Remove(oldValue)) return false; + + grantedKinds.Add(newValue); + return true; + } } } diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs new file mode 100644 index 0000000..46022b5 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs @@ -0,0 +1,42 @@ +// Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs +using UnityEngine; +using TD.Core; +using TD.Gameplay.BuilderEffects; + +namespace TD.Gameplay.Draft +{ + /// + /// Draft choice — "upgrade a builder effect you already have". Replaces + /// with in the player's + /// . + /// + /// + /// Mirrors , but swaps rather than adds. Only offered + /// while the player currently has granted — once taken, + /// is removed, which automatically makes this option (and any sibling + /// upgrade branching off the same base) invalid for future drafts. + /// + [CreateAssetMenu(fileName = "BuilderEffectUpgradeOption", + menuName = "TD/Draft/Builder Effect Upgrade Option")] + public class BuilderEffectUpgradeDraftOption : DraftOption + { + [Header("Payload")] + [Tooltip("The builder effect this option upgrades from.")] + public BuilderEffectKind BaseKind; + + [Tooltip("The builder effect this option upgrades to.")] + public BuilderEffectKind UpgradedKind; + + public override bool IsValidFor(ulong clientId) + { + var upgrades = BuilderUpgradeManager.GetForClient(clientId); + return upgrades != null && upgrades.PlayerHasEffect(BaseKind); + } + + public override bool ServerApply(ulong clientId) + { + var upgrades = BuilderUpgradeManager.GetForClient(clientId); + return upgrades != null && upgrades.ServerUpgradeEffect(BaseKind, UpgradedKind); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs new file mode 100644 index 0000000..a9d56bd --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs @@ -0,0 +1,52 @@ +// Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs +using UnityEngine; +using TD.Core; +using TD.Gameplay.BuilderSpells; + +namespace TD.Gameplay.Draft +{ + /// + /// Draft choice — "upgrade a builder spell you already have". Replaces + /// with in the player's + /// , in place — the upgraded spell keeps the same hotkey + /// slot the base spell occupied. + /// + /// + /// Mirrors , but swaps rather than adds (so it doesn't + /// consume a slot). Only offered while the player currently has + /// granted — once taken, is removed, which automatically makes this + /// option (and any sibling upgrade branching off the same base) invalid for future drafts. + /// + [CreateAssetMenu(fileName = "BuilderSpellUpgradeOption", + menuName = "TD/Draft/Builder Spell Upgrade Option")] + public class BuilderSpellUpgradeDraftOption : DraftOption + { + [Header("Payload")] + [Tooltip("The builder spell this option upgrades from.")] + public BuilderSpellKind BaseKind; + + [Tooltip("The builder spell this option upgrades to.")] + public BuilderSpellKind UpgradedKind; + + public override bool IsValidFor(ulong clientId) + { + var loadout = PlayerSpellLoadout.GetForClient(clientId); + return loadout != null && loadout.PlayerHasSpell(BaseKind); + } + + public override bool ServerApply(ulong clientId) + { + var loadout = PlayerSpellLoadout.GetForClient(clientId); + return loadout != null && loadout.ServerUpgradeSpell(BaseKind, UpgradedKind); + } + + /// Inherits the upgraded spell's icon (resolved from the pool by + /// ) unless this option assigns an override. + public override Sprite ResolveIcon() + { + if (Icon != null) return Icon; + var def = BuilderSpellPool.Instance != null ? BuilderSpellPool.Instance.Get(UpgradedKind) : null; + return def != null ? def.Icon : null; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs new file mode 100644 index 0000000..7746e3e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs @@ -0,0 +1,70 @@ +// Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs +using UnityEngine; +using TD.Towers; + +namespace TD.Gameplay.Draft +{ + /// + /// Draft choice — "upgrade a tower you already have". Replaces + /// with in the player's : every + /// tower of that type placed from now on is the upgraded version. Already-placed towers + /// are unaffected. + /// + /// + /// Mirrors , but swaps rather than adds. Only offered + /// while the player currently has unlocked — once taken, + /// is removed from the deck, which automatically makes this option + /// (and any sibling upgrade branching off the same base) invalid for future drafts. + /// + [CreateAssetMenu(fileName = "TowerUpgradeOption", menuName = "TD/Draft/Tower Upgrade Option")] + public class TowerUpgradeDraftOption : DraftOption + { + [Header("Payload")] + [Tooltip("The tower this option upgrades from. Must be present in the player's deck " + + "for this option to be offered.")] + public TowerDefinition BaseTower; + + [Tooltip("The tower this option upgrades to. Must also be present in the " + + "TowerPlacementManager catalog (that's where its TowerTypeId comes from).")] + public TowerDefinition UpgradedTower; + + public override bool IsValidFor(ulong clientId) + { + var deck = PlayerTowerDeck.GetForClient(clientId); + var pm = TowerPlacementManager.Instance; + if (deck == null || pm == null || BaseTower == null || UpgradedTower == null) return false; + + if (!pm.TryGetTypeId(BaseTower, out int baseTypeId)) return false; + + return deck.Contains(baseTypeId); + } + + public override bool ServerApply(ulong clientId) + { + var deck = PlayerTowerDeck.GetForClient(clientId); + var pm = TowerPlacementManager.Instance; + if (deck == null || pm == null || BaseTower == null || UpgradedTower == null) return false; + + if (!pm.TryGetTypeId(BaseTower, out int baseTypeId)) + { + Debug.LogError($"[TowerUpgradeDraftOption] '{BaseTower.name}' is not in the tower " + + $"catalog; cannot apply. Add it to TowerPlacementManager.towerDefinitions."); + return false; + } + + if (!pm.TryGetTypeId(UpgradedTower, out int upgradedTypeId)) + { + Debug.LogError($"[TowerUpgradeDraftOption] '{UpgradedTower.name}' is not in the " + + $"tower catalog; cannot apply. Add it to " + + $"TowerPlacementManager.towerDefinitions."); + return false; + } + + return deck.ServerUpgradeTower(baseTypeId, upgradedTypeId); + } + + /// Inherits the upgraded tower's icon unless this option assigns an override. + public override Sprite ResolveIcon() + => Icon != null ? Icon : (UpgradedTower != null ? UpgradedTower.Icon : null); + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs.meta b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs.meta new file mode 100644 index 0000000..a35a5d2 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aeec22f152a57ef5798eae035c091543 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs index ef994bb..0d8bfb3 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs @@ -139,6 +139,28 @@ namespace TD.Gameplay return true; } + /// + /// Server-only: replaces a previously granted spell with a different one (an upgrade + /// pick), in place — the upgraded spell keeps the base spell's hotkey slot and starts + /// off cooldown. No-op if the old spell isn't currently granted or the new one already + /// is. Returns true if the swap happened. + /// + public bool ServerUpgradeSpell(BuilderSpellKind oldKind, BuilderSpellKind newKind) + { + if (!IsServer) return false; + if (PlayerHasSpell(newKind)) return false; + + for (int i = 0; i < spells.Count; i++) + { + if (spells[i].Kind == oldKind) + { + spells[i] = SpellSlot.CreateReady(newKind); + return true; + } + } + return false; + } + // ----- Cast RPC ----------------------------------------------------- /// diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs index 356d459..9d7b54d 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerDeck.cs @@ -146,5 +146,30 @@ namespace TD.Gameplay unlockedTypeIds.Add(towerTypeId); return true; } + + /// + /// Server-only: replaces a previously unlocked tower type with a different one (an + /// upgrade pick) — every tower of that type placed from now on uses the new definition. + /// Already-placed towers keep whatever type they were placed with; this only changes + /// what's placeable going forward. No-op if the old type isn't currently unlocked or + /// the new one already is. Returns true if the swap happened. + /// + public bool ServerUpgradeTower(int oldTypeId, int newTypeId) + { + if (!IsServer) return false; + if (newTypeId <= 0) return false; + if (Contains(newTypeId)) return false; + + for (int i = 0; i < unlockedTypeIds.Count; i++) + { + if (unlockedTypeIds[i] == oldTypeId) + { + unlockedTypeIds.RemoveAt(i); + unlockedTypeIds.Add(newTypeId); + return true; + } + } + return false; + } } } From 2de6d85b2f163e3889a8292b3e649918a16f91c7 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Sat, 25 Jul 2026 23:22:11 -0700 Subject: [PATCH 05/20] slow area upgrade test --- .../_Project/Scripts/Core/BuilderSpellKind.cs | 1 + .../SlowAreaUpgradedSpellDefinition.cs | 21 +++++++++++++++++++ .../SlowAreaUpgradedSpellDefinition.cs.meta | 2 ++ .../BuilderEffectUpgradeDraftOption.cs.meta | 2 ++ .../BuilderSpellUpgradeDraftOption.cs.meta | 2 ++ 5 files changed, 28 insertions(+) create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs.meta diff --git a/Assets/_Project/Scripts/Core/BuilderSpellKind.cs b/Assets/_Project/Scripts/Core/BuilderSpellKind.cs index 58fcb5d..91c56e9 100644 --- a/Assets/_Project/Scripts/Core/BuilderSpellKind.cs +++ b/Assets/_Project/Scripts/Core/BuilderSpellKind.cs @@ -11,5 +11,6 @@ namespace TD.Core { Fireball = 0, SlowArea = 1, + SlowAreaUpgraded = 2, } } diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs new file mode 100644 index 0000000..dd339f7 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs @@ -0,0 +1,21 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs + +using TD.Core; +using UnityEngine; + +namespace TD.Gameplay.BuilderSpells +{ + /// + /// Draft upgrade for — identical behavior, just a + /// distinct so can replace the base spell + /// with this one in the player's loadout. All fields (SlowFactor, EffectDuration, etc.) + /// are inherited; author this asset with a longer EffectDuration than the base spell. + /// + [CreateAssetMenu(fileName = "SlowAreaUpgradedSpell", + menuName = "TD/Builder Spells/Slow Area (Upgraded)")] + public class SlowAreaUpgradedSpellDefinition : SlowAreaSpellDefinition + { + public override BuilderSpellKind Kind => BuilderSpellKind.SlowAreaUpgraded; + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs.meta new file mode 100644 index 0000000..af8991f --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaUpgradedSpellDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37cc60a4a6e53e24cb6d1c0be310868a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs.meta b/Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs.meta new file mode 100644 index 0000000..393a4c1 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderEffectUpgradeDraftOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 717648113e8090fc6856744d898ab375 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs.meta b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs.meta new file mode 100644 index 0000000..2d6304f --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellUpgradeDraftOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7b1c5b562f39c72eea3bafd0165af078 \ No newline at end of file From a26d0acbfb0ce55732a1e41a56879fc795d848f3 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Sat, 25 Jul 2026 23:46:45 -0700 Subject: [PATCH 06/20] upgrades working; fix a small bug in which a base spell could be re-offered after picking an upgrade --- .../BuilderSpells/SlowAreaSpell.asset | 2 +- .../BuilderSpells/SlowAreaUpgradedSpell.asset | 31 +++++++++++++++++++ .../SlowAreaUpgradedSpell.asset.meta | 8 +++++ ...t_BuilderSpellOption_SlowAreaUpgrade.asset | 20 ++++++++++++ ...lderSpellOption_SlowAreaUpgrade.asset.meta | 8 +++++ Assets/_Project/Scenes/Levels/9Player.unity | 2 ++ .../Gameplay/Draft/BuilderSpellDraftOption.cs | 6 ++-- .../Scripts/Gameplay/PlayerSpellLoadout.cs | 14 +++++++++ 8 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset create mode 100644 Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset.meta create mode 100644 Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset create mode 100644 Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset index 3439119..cb2746a 100644 --- a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset @@ -25,7 +25,7 @@ MonoBehaviour: areaSound: clip: {fileID: 8300000, guid: 038ff3eab9aa0034bac26ccb648ef2a1, type: 3} volume: 0.632 - minPitch: 1.14 + minPitch: 1.04 maxPitch: 0.96 SlowFactor: 0.5 EffectDuration: 8 diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset b/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset new file mode 100644 index 0000000..0465991 --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset @@ -0,0 +1,31 @@ +%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: 37cc60a4a6e53e24cb6d1c0be310868a, type: 3} + m_Name: SlowAreaUpgradedSpell + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.SlowAreaUpgradedSpellDefinition + DisplayName: Slow Area II + Description: Upgraded Slow Area. Slows for 50% longer. + Icon: {fileID: 0} + Cooldown: 10 + TargetType: 1 + Radius: 5 + enemyLayerMask: + serializedVersion: 2 + m_Bits: 1152 + areaVfxPrefab: {fileID: 1462673130280185047, guid: bf2f1f2a9a54e9162b80673e3f7eeaf6, type: 3} + areaSound: + clip: {fileID: 8300000, guid: 038ff3eab9aa0034bac26ccb648ef2a1, type: 3} + volume: 0.632 + minPitch: 1.04 + maxPitch: 0.96 + SlowFactor: 0.5 + EffectDuration: 12 diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset.meta b/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset.meta new file mode 100644 index 0000000..16266b6 --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaUpgradedSpell.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 364edf6abc3c31e33b53d0a01142b8ab +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset new file mode 100644 index 0000000..743c46d --- /dev/null +++ b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.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: 7b1c5b562f39c72eea3bafd0165af078, type: 3} + m_Name: Draft_BuilderSpellOption_SlowAreaUpgrade + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.BuilderSpellUpgradeDraftOption + DisplayName: Slow Area II + Description: Slows enemies in the area for 50% longer than the default slow area. + Icon: {fileID: 0} + Weight: 1 + BaseKind: 1 + UpgradedKind: 2 diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta new file mode 100644 index 0000000..13ca9fd --- /dev/null +++ b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d180030b68a9a5e1da497bf6f83451d4 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index 684df64..9a4d028 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -13428,6 +13428,7 @@ MonoBehaviour: spells: - {fileID: 11400000, guid: 051c48f416e5366b59d49b0e062333ac, type: 2} - {fileID: 11400000, guid: d893f46b037536b4ba91307e184cecfe, type: 2} + - {fileID: 11400000, guid: 364edf6abc3c31e33b53d0a01142b8ab, type: 2} --- !u!4 &914832726 Transform: m_ObjectHideFlags: 0 @@ -28786,6 +28787,7 @@ MonoBehaviour: - {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, type: 2} - {fileID: 11400000, guid: 5f390de69ba43b2a6a0d89cf09580320, type: 2} - {fileID: 11400000, guid: 29b35841f7e5db454903798cb5d83434, type: 2} + - {fileID: 11400000, guid: d180030b68a9a5e1da497bf6f83451d4, type: 2} --- !u!4 &2139601601 Transform: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs index 7582ea2..360a566 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs @@ -15,7 +15,9 @@ namespace TD.Gameplay.Draft /// and already key off of. /// also checks slot capacity — once /// is reached, no further spell options are - /// offered. + /// offered. Gates on rather than + /// current possession, so a base spell already upgraded past (see + /// ) is never re-offered. /// [CreateAssetMenu(fileName = "BuilderSpellOption", menuName = "TD/Draft/Builder Spell Option")] public class BuilderSpellDraftOption : DraftOption @@ -29,7 +31,7 @@ namespace TD.Gameplay.Draft var loadout = PlayerSpellLoadout.GetForClient(clientId); if (loadout == null) return false; if (loadout.SlotCount >= PlayerSpellLoadout.MaxSpellSlots) return false; - return !loadout.PlayerHasSpell(Kind); + return !loadout.PlayerHasEverGranted(Kind); } public override bool ServerApply(ulong clientId) diff --git a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs index 0d8bfb3..4782e9f 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs @@ -60,6 +60,10 @@ namespace TD.Gameplay private readonly NetworkList spells = new NetworkList(); + // Every kind ever granted this match, including ones since upgraded away. Unlike + // `spells`, entries here are never removed — see PlayerHasEverGranted. + private readonly NetworkList everGrantedKinds = new NetworkList(); + /// Fired on every peer when a spell is granted or a cooldown changes. public event System.Action OnLoadoutChanged; @@ -100,6 +104,14 @@ namespace TD.Gameplay public BuilderSpellKind? GetKind(int slot) => (slot >= 0 && slot < spells.Count) ? spells[slot].Kind : (BuilderSpellKind?)null; + /// + /// True if this player has ever been granted the given spell this match, whether or + /// not it's still occupying a slot (e.g. it may have since been upgraded away). Used + /// by so a base spell already + /// upgraded past is never re-offered. + /// + public bool PlayerHasEverGranted(BuilderSpellKind kind) => everGrantedKinds.Contains((byte)kind); + /// True if is out of range, empty, or still cooling down. public bool IsSlotOnCooldown(int slot) { @@ -136,6 +148,7 @@ namespace TD.Gameplay if (PlayerHasSpell(kind)) return false; spells.Add(SpellSlot.CreateReady(kind)); + if (!everGrantedKinds.Contains((byte)kind)) everGrantedKinds.Add((byte)kind); return true; } @@ -155,6 +168,7 @@ namespace TD.Gameplay if (spells[i].Kind == oldKind) { spells[i] = SpellSlot.CreateReady(newKind); + if (!everGrantedKinds.Contains((byte)newKind)) everGrantedKinds.Add((byte)newKind); return true; } } From 726af6d379abf3c041cd5f22093bc0cd899b3383 Mon Sep 17 00:00:00 2001 From: Ben Calegari Date: Tue, 28 Jul 2026 20:47:06 -0700 Subject: [PATCH 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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