diff --git a/Assets/_Project/Definitions.meta b/Assets/_Project/Definitions.meta
index d3c452a..672694e 100644
--- a/Assets/_Project/Definitions.meta
+++ b/Assets/_Project/Definitions.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 01de85ee5d8a2014594d9910b1a6ff55
+guid: cf294cbfb17f5a5d7846479a116fc7b3
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Assets/_Project/Definitions/Buffs.meta b/Assets/_Project/Definitions/Buffs.meta
new file mode 100644
index 0000000..98de849
--- /dev/null
+++ b/Assets/_Project/Definitions/Buffs.meta
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..c707eae
--- /dev/null
+++ b/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset
@@ -0,0 +1,17 @@
+%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
new file mode 100644
index 0000000..b240b6c
--- /dev/null
+++ b/Assets/_Project/Definitions/Buffs/ExtraDamage25.asset.meta
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..737c5ab
--- /dev/null
+++ b/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset
@@ -0,0 +1,18 @@
+%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
new file mode 100644
index 0000000..ced41c2
--- /dev/null
+++ b/Assets/_Project/Definitions/Buffs/OffensiveBuffs.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d8ed3b9535538f7fc82be878cc307d26
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab
index 8a1fe80..4d7c382 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
@@ -47,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
@@ -101,3 +102,18 @@ 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}
diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs
index acb06ef..ebb8950 100644
--- a/Assets/_Project/Scripts/Combat/TowerCombat.cs
+++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs
@@ -1,10 +1,13 @@
// Assets/_Project/Scripts/Combat/TowerCombat.cs
+
+using System;
using System.Collections.Generic;
-using Unity.Netcode;
-using UnityEngine;
using TD.Core;
using TD.Gameplay;
using TD.Towers;
+using Unity.Netcode;
+using UnityEngine;
+using Object = UnityEngine.Object;
namespace TD.Combat
{
@@ -58,6 +61,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];
@@ -70,17 +78,17 @@ namespace TD.Combat
/// fired in for late-joining clients so visual
/// consumers always initialise from the correct state.
///
- public event System.Action OnTargetAcquired;
+ public event Action OnTargetAcquired;
/// Fired locally on ALL peers when the tower loses its target.
- public event System.Action OnTargetLost;
+ public event Action OnTargetLost;
///
/// Fired locally on ALL peers each time the tower attacks.
/// Argument is the world-space position of the primary target at the moment
/// of fire — use it to aim muzzle flash, spawn a tracer, etc.
///
- public event System.Action OnFire;
+ public event Action OnFire;
// ----- NGO lifecycle -----------------------------------------------
@@ -138,7 +146,7 @@ namespace TD.Combat
if (currentTarget == null) return;
bool dead = currentTarget.IsDead;
- bool destroyed = (currentTarget as UnityEngine.Object) == null;
+ bool destroyed = (currentTarget as Object) == null;
bool outOfRange = Vector3.Distance(
transform.position,
currentTarget.transform.position) > def.Range;
@@ -211,6 +219,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;
+
+ 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)
@@ -218,7 +245,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);
}
@@ -405,7 +434,11 @@ namespace TD.Combat
private void HitEnemy(in CombatProfile p, EnemyHealth target, PlayerSlot owner)
{
- target.TakeDamage(p.Damage, p.DamageType, owner);
+ var multiplier = GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f;
+ float effectiveDamage = p.Damage * multiplier;
+ Debug.Log("multiplier: " + multiplier);
+ Debug.Log("Effective damage: " + effectiveDamage);
+ target.TakeDamage(effectiveDamage, p.DamageType, owner);
ApplyStatusEffect(p, target, owner);
}
@@ -444,9 +477,11 @@ namespace TD.Combat
return;
}
+ float effectiveDamage = def.Damage
+ * (GetOwnerBuffManager()?.GetMultiplier(BuffStat.Damage) ?? 1f);
proj.InitializeServer(
target,
- p.Damage,
+ effectiveDamage,
p.DamageType,
p.TargetType,
p.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/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
new file mode 100644
index 0000000..506791e
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs
@@ -0,0 +1,195 @@
+// 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, 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
diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs
index 6c3963f..8dfe824 100644
--- a/Assets/_Project/Scripts/UI/HUDController.cs
+++ b/Assets/_Project/Scripts/UI/HUDController.cs
@@ -21,55 +21,60 @@ namespace TD.UI
[RequireComponent(typeof(UIDocument))]
public class HUDController : MonoBehaviour
{
+ public static HUDController Instance { get; private set; }
+
// ----- 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;
@@ -77,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;
@@ -89,19 +95,23 @@ 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;
- private Label matchEndTitle;
+
+ // 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
// 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
@@ -123,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 ----------------------------------------------------
//
@@ -150,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 --------------------------------------
@@ -249,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