From 3737ad517c3d47d9b21e7c789e814c69e18008b0 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 2 Jun 2026 23:40:00 -0700 Subject: [PATCH 1/4] add wiring for offensive buffs --- Assets/_Project/Prefabs/Player/Player.prefab | 17 ++ Assets/_Project/Scripts/Combat/TowerCombat.cs | 38 ++++- 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 + .../Scripts/Gameplay/PlayerBuffManager.cs | 156 ++++++++++++++++++ .../Gameplay/PlayerBuffManager.cs.meta | 2 + 12 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 Assets/_Project/Scripts/Core/BuffStat.cs create mode 100644 Assets/_Project/Scripts/Core/BuffStat.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/ActiveBuff.cs create mode 100644 Assets/_Project/Scripts/Gameplay/ActiveBuff.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuffCategory.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuffCategory.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuffDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuffDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs.meta diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 8a1fe80..4649bc7 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -13,6 +13,7 @@ GameObject: - component: {fileID: 2918837822014987993} - component: {fileID: 7845089877743661692} - component: {fileID: 4336209376377567030} + - component: {fileID: 2806524246861401760} m_Layer: 0 m_Name: Player m_TagString: Untagged @@ -101,3 +102,19 @@ 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: 0} + - {fileID: 0} diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs index d270b96..01b1441 100644 --- a/Assets/_Project/Scripts/Combat/TowerCombat.cs +++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs @@ -58,6 +58,11 @@ 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]; @@ -211,6 +216,25 @@ 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; + buffManagerResolved = true; + + var slot = towerInstance.Owner; + if (slot == PlayerSlot.None) return null; + + var matchState = PlayerMatchState.GetForSlot(slot); + if (matchState == null) return null; + + ownerBuffManager = matchState.GetComponent(); + return ownerBuffManager; + } + // ----- Attack tick ------------------------------------------------- private void TickAttack(TowerDefinition def) @@ -218,7 +242,9 @@ namespace TD.Combat attackCooldown -= Time.deltaTime; if (attackCooldown > 0f) return; - attackCooldown = 1f / def.FireRate; + float effectiveFireRate = def.FireRate + * (GetOwnerBuffManager()?.GetMultiplier(BuffStat.AttackSpeed) ?? 1f); + attackCooldown = 1f / effectiveFireRate; Fire(def); } @@ -316,7 +342,9 @@ namespace TD.Combat private void HitEnemy(TowerDefinition def, EnemyHealth target, PlayerSlot owner) { - target.TakeDamage(def.Damage, def.DamageType, owner); + float effectiveDamage = def.Damage + * (GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f); + target.TakeDamage(effectiveDamage, def.DamageType, owner); ApplyStatusEffect(def, target, owner); } @@ -340,6 +368,8 @@ namespace TD.Combat // ----- Projectile spawning ----------------------------------------- + // TODO this seems wacky. Why do we calculate effective damage twice? Are they both used? + // Spawn projectile is void. Should it return a projectile? private void SpawnProjectile(TowerDefinition def, EnemyHealth target) { var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity); @@ -354,9 +384,11 @@ namespace TD.Combat return; } + float effectiveDamage = def.Damage + * (GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f); proj.InitializeServer( target, - def.Damage, + effectiveDamage, def.DamageType, def.TargetType, def.SplashRadius, diff --git a/Assets/_Project/Scripts/Core/BuffStat.cs b/Assets/_Project/Scripts/Core/BuffStat.cs new file mode 100644 index 0000000..3e2132e --- /dev/null +++ b/Assets/_Project/Scripts/Core/BuffStat.cs @@ -0,0 +1,12 @@ +// 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 new file mode 100644 index 0000000..b91fc16 --- /dev/null +++ b/Assets/_Project/Scripts/Core/BuffStat.cs.meta @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..3a54012 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs @@ -0,0 +1,61 @@ +// 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 new file mode 100644 index 0000000..d6868bd --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/ActiveBuff.cs.meta @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..bad6ee3 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuffCategory.cs @@ -0,0 +1,23 @@ +// 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 new file mode 100644 index 0000000..c2d766d --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuffCategory.cs.meta @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..951972e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs @@ -0,0 +1,24 @@ +// 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 new file mode 100644 index 0000000..12b38db --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuffDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b01f9aea62ee8a1c1991bb2a097de224 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs new file mode 100644 index 0000000..8112423 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs @@ -0,0 +1,156 @@ +// Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs +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 + { + [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(); + } + + // ----- 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, RequireOwnership = true)] + 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 new file mode 100644 index 0000000..b1951a5 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7775ee3a2f441b52480aabf54be6c1b6 \ No newline at end of file From 79cd331141a8a209345a0ede49213f04300f27d0 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Wed, 3 Jun 2026 00:05:52 -0700 Subject: [PATCH 2/4] purchase buffs menu works --- Assets/_Project/Prefabs/Player/Player.prefab | 5 +- .../Gameplay/BuilderInputController.cs | 8 + .../Scripts/Gameplay/PlayerBuffManager.cs | 39 +++++ Assets/_Project/Scripts/UI/HUDController.cs | 159 ++++++++++++++++++ Packages/manifest.json | 3 +- Packages/packages-lock.json | 15 +- 6 files changed, 222 insertions(+), 7 deletions(-) diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 4649bc7..4d7c382 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -48,7 +48,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 @@ -116,5 +116,4 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerBuffManager ShowTopMostFoldoutHeaderGroup: 1 categories: - - {fileID: 0} - - {fileID: 0} + - {fileID: 11400000, guid: d8ed3b9535538f7fc82be878cc307d26, type: 2} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs index 5de1f4a..ce1e258 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs @@ -149,6 +149,14 @@ namespace TD.Gameplay // Right-click. Suppressed entirely during a modal mode (placement/paint // 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(); + } + if (pointerOverHud) return; if (!mouse.rightButton.wasPressedThisFrame) return; diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs index 8112423..506791e 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs @@ -1,4 +1,5 @@ // Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs +using System.Collections.Generic; using Unity.Collections; using Unity.Netcode; using UnityEngine; @@ -27,6 +28,31 @@ namespace TD.Gameplay /// 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; @@ -38,6 +64,19 @@ namespace TD.Gameplay 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 ------------------------------------------------- /// diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 6c3963f..e3e169f 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -21,6 +21,8 @@ namespace TD.UI [RequireComponent(typeof(UIDocument))] public class HUDController : MonoBehaviour { + public static HUDController Instance { get; private set; } + // ----- Inspector -------------------------------------------------- [Header("Scene References")] @@ -93,6 +95,10 @@ 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; // Chat panel (bottom-left, above portrait) — programmatic. The container @@ -309,6 +315,9 @@ namespace TD.UI // MatchState.OnPhaseChanged fires Victory or Defeat. BuildMatchEndOverlay(root); + // Build the buff menu overlay. Hidden until the player presses B. + BuildBuffMenuOverlay(root); + // Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar. // Player typing toggled with Enter; system messages (e.g. life lost) post via // ChatService.PostLocalSystem on every peer. @@ -323,6 +332,11 @@ namespace TD.UI uiInitialized = true; } + private void Awake() + { + Instance = this; + } + private void OnEnable() { TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage; @@ -437,6 +451,8 @@ namespace TD.UI private void OnDestroy() { + if (Instance == this) Instance = null; + minimapView?.Dispose(); minimapView = null; @@ -1208,6 +1224,149 @@ 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 diff --git a/Packages/manifest.json b/Packages/manifest.json index 5b70fbf..541a381 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -12,11 +12,12 @@ "com.unity.netcode.gameobjects": "2.11.0", "com.unity.probuilder": "6.0.9", "com.unity.render-pipelines.universal": "17.4.0", - "com.unity.sdk.linux-x86_64": "1.0.2", + "com.unity.sdk.linux-x86_64": "1.1.0", "com.unity.services.multiplayer": "2.1.3", "com.unity.terrain-tools": "5.3.2", "com.unity.test-framework": "1.6.0", "com.unity.timeline": "1.8.12", + "com.unity.toolchain.linux-x86_64-linux": "1.1.0", "com.unity.toolchain.win-x86_64-linux": "1.0.2", "com.unity.ugui": "2.0.0", "com.unity.visualscripting": "1.9.11", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 499be20..ca389ce 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -221,11 +221,11 @@ "url": "https://packages.unity.com" }, "com.unity.sdk.linux-x86_64": { - "version": "1.0.2", + "version": "1.1.0", "depth": 0, "source": "registry", "dependencies": { - "com.unity.sysroot.base": "1.0.2" + "com.unity.sysroot.base": "1.1.0" }, "url": "https://packages.unity.com" }, @@ -355,7 +355,7 @@ "url": "https://packages.unity.com" }, "com.unity.sysroot.base": { - "version": "1.0.2", + "version": "1.1.0", "depth": 1, "source": "registry", "dependencies": {}, @@ -403,6 +403,15 @@ }, "url": "https://packages.unity.com" }, + "com.unity.toolchain.linux-x86_64-linux": { + "version": "1.1.0", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.sysroot.base": "1.1.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.toolchain.win-x86_64-linux": { "version": "1.0.2", "depth": 0, From 3ada934e41dd128a5c1212dc8210696488f5906b Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Wed, 3 Jun 2026 20:53:01 -0700 Subject: [PATCH 3/4] B button launches upgrade menu --- Assets/_Project/Scripts/UI/HUDController.cs | 145 ++++++++++++-------- 1 file changed, 89 insertions(+), 56 deletions(-) diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index e3e169f..45930de 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -25,53 +25,56 @@ namespace TD.UI // ----- Inspector -------------------------------------------------- - [Header("Scene References")] - [Tooltip("The local client's TowerPlacementController.")] - [SerializeField] private TowerPlacementController placementController; + [Header("Scene References")] [Tooltip("The local client's TowerPlacementController.")] [SerializeField] + private TowerPlacementController placementController; [Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")] [SerializeField] private TowerPaintController paintController; - [Tooltip("The TowerPlacementManager NetworkObject in the scene.")] - [SerializeField] private TowerPlacementManager placementManager; + [Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [SerializeField] + private TowerPlacementManager placementManager; [Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " + "and drag-to-pan.")] - [SerializeField] private CameraController cameraController; + [SerializeField] + private CameraController cameraController; - [Header("Settings")] - [SerializeField] private float rejectionMessageDuration = 2.5f; + [Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f; [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).")] - [SerializeField] private float chatMaxHeight = 280f; + [SerializeField] + private float chatMaxHeight = 280f; [Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " + "(int.MaxValue) — every message sent during a match stays scrollable. " + "Lower the value if a long match ever shows DOM perf issues; this field " + "is the safety valve, not a normal-play limit.")] - [SerializeField] private int chatMaxMessages = int.MaxValue; + [SerializeField] + private int chatMaxMessages = int.MaxValue; - [Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] - [SerializeField] private Color chatSystemColor = new Color(1f, 0.7f, 0.2f); + [Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [SerializeField] + private Color chatSystemColor = new Color(1f, 0.7f, 0.2f); [Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")] - [SerializeField] private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f); + [SerializeField] + private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f); // ----- Cached UI element references ------------------------------- private Label goldLabel; private Label waveLabel; private Label livesLabel; - private Label nextWaveLabel; // prep countdown ("next: 0:12") - private Label leakedLabel; // local player's origin-leak count ("leaked: 3") - private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave") + private Label nextWaveLabel; // prep countdown ("next: 0:12") + private Label leakedLabel; // local player's origin-leak count ("leaked: 3") + private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave") private VisualElement playerListContainer; // right-panel scoreboard rows private Label portraitName; private Label levelLabel; private VisualElement statLines; private VisualElement commandGrid; + private VisualElement actionFrame; // hidden via display:none when no actions are available private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection private Button tabBuild; @@ -79,6 +82,7 @@ namespace TD.UI private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections private VisualElement buildProgressFill; // width driven each frame from progress private Label buildProgressPercent; + private Label ttTitle; private Label ttDesc; private Label ttStats; @@ -91,7 +95,7 @@ namespace TD.UI // without rebuilding the elements. private VisualElement enemyHealthBar; private VisualElement enemyHealthFill; - private Label enemyHealthText; + private Label enemyHealthText; // Match-end overlay — built once on Start and toggled on Phase changes. private VisualElement matchEndOverlay; @@ -99,15 +103,15 @@ namespace TD.UI // Buff menu overlay — toggled by the B key via ToggleBuffMenu(). private VisualElement buffMenuOverlay; private VisualElement buffMenuContent; - private Label matchEndTitle; + private Label matchEndTitle; // Chat panel (bottom-left, above portrait) — programmatic. The container // holds both the scrollable feed and the input. Highlight + scroll // interactivity are toggled on the container when typing. private VisualElement chatContainer; - private ScrollView chatFeed; - private TextField chatInput; - private bool chatInputOpen; + private ScrollView chatFeed; + private TextField chatInput; + private bool chatInputOpen; // Frame on which the chat input was opened or closed. Enter on that frame // and the next one is ignored to prevent the open/close-triggering keypress @@ -129,12 +133,12 @@ namespace TD.UI private CommandTab activeTab = CommandTab.Build; private Coroutine rejectionFadeCoroutine; - private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null + private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null private bool uiInitialized; - private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged - private bool matchStateSubscribed; // true once OnPhaseChanged is hooked + private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged + private bool matchStateSubscribed; // true once OnPhaseChanged is hooked private MinimapView minimapView; - private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us + private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us // ----- Hotkeys ---------------------------------------------------- // @@ -156,10 +160,15 @@ namespace TD.UI private readonly struct HotkeyBinding { public readonly Key Key; - public readonly VisualElement Button; // for enabledSelf gating + public readonly VisualElement Button; // for enabledSelf gating public readonly System.Action Action; + public HotkeyBinding(Key k, VisualElement b, System.Action a) - { Key = k; Button = b; Action = a; } + { + Key = k; + Button = b; + Action = a; + } } // ----- Static hit-test probe -------------------------------------- @@ -255,6 +264,7 @@ namespace TD.UI // Cache element references — log a warning for any that are missing // so UXML/USS mismatches surface immediately. + goldLabel = Require