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