diff --git a/Assets/_Project/Definitions.meta b/Assets/_Project/Definitions.meta index d3c452a..a2de22b 100644 --- a/Assets/_Project/Definitions.meta +++ b/Assets/_Project/Definitions.meta @@ -1,5 +1,9 @@ fileFormatVersion: 2 +<<<<<<< HEAD guid: 01de85ee5d8a2014594d9910b1a6ff55 +======= +guid: cf294cbfb17f5a5d7846479a116fc7b3 +>>>>>>> 31b0b5f (adding uncommited files (oops)) 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 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/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..45930de 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