// Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Gameplay.BuilderSpells; namespace TD.Gameplay { /// /// Per-player set of granted builder spells and their cooldowns. Lives on the Player /// prefab alongside and . /// /// /// Slot == grant order == hotkey. is append-only; a /// slot's index in the list is both its identity and the index into /// . There is no player-configurable rebinding. /// /// Cast flow. The owning client calls with a /// slot index and an aimed world point. The server re-validates everything (slot exists, /// off cooldown, ) since client-side checks in /// are UX-only, resolves the /// via , and calls /// . A successful cast starts the cooldown /// and fires so every peer plays the same visual. /// public class PlayerSpellLoadout : NetworkBehaviour { /// Hard cap on granted spells, matching the number of hotkey slots available. public const int MaxSpellSlots = 4; // ----- Static registry (mirrors BuilderUpgradeManager) ----- private static readonly Dictionary s_byClientId = new Dictionary(); /// Returns the loadout owned by the given client, or null. public static PlayerSpellLoadout GetForClient(ulong clientId) { s_byClientId.TryGetValue(clientId, out var loadout); return loadout; } /// Convenience: the local client's own loadout. public static PlayerSpellLoadout Local { get { var nm = NetworkManager.Singleton; if (nm == null || !nm.IsClient) return null; return GetForClient(nm.LocalClientId); } } // ----- Networked state -------------------------------------------- private readonly NetworkList spells = new NetworkList(); /// Fired on every peer when a spell is granted or a cooldown changes. public event System.Action OnLoadoutChanged; // ----- NGO lifecycle ------------------------------------------------ public override void OnNetworkSpawn() { s_byClientId[OwnerClientId] = this; spells.OnListChanged += HandleSpellsChanged; } public override void OnNetworkDespawn() { spells.OnListChanged -= HandleSpellsChanged; if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) s_byClientId.Remove(OwnerClientId); } private void HandleSpellsChanged(NetworkListEvent change) => OnLoadoutChanged?.Invoke(); // ----- Read API ----------------------------------------------------- /// Number of spells currently granted. public int SlotCount => spells.Count; /// True if this player has already been granted the given spell. public bool PlayerHasSpell(BuilderSpellKind kind) { for (int i = 0; i < spells.Count; i++) { if (spells[i].Kind == kind) return true; } return false; } /// The spell kind occupying , or null if out of range. public BuilderSpellKind? GetKind(int slot) => (slot >= 0 && slot < spells.Count) ? spells[slot].Kind : (BuilderSpellKind?)null; /// True if is out of range, empty, or still cooling down. public bool IsSlotOnCooldown(int slot) { if (slot < 0 || slot >= spells.Count) return true; var nm = NetworkManager.Singleton; if (nm == null) return false; return spells[slot].CooldownEndServerTime > nm.ServerTime.Time; } /// Seconds remaining on 's cooldown, or 0 if ready/invalid. public float GetCooldownRemaining(int slot) { if (slot < 0 || slot >= spells.Count) return 0f; var nm = NetworkManager.Singleton; if (nm == null) return 0f; double remaining = spells[slot].CooldownEndServerTime - nm.ServerTime.Time; return remaining > 0d ? (float)remaining : 0f; } // ----- Server mutation ----------------------------------------------- /// /// Server-only: grants the given spell (a draft pick) into the next free slot. /// No-op if already granted or slots are full. Returns true if actually granted. /// public bool ServerGrantSpell(BuilderSpellKind kind) { if (!IsServer) return false; if (spells.Count >= MaxSpellSlots) return false; if (PlayerHasSpell(kind)) return false; spells.Add(SpellSlot.CreateReady(kind)); return true; } // ----- Cast RPC ----------------------------------------------------- /// /// Owning-client entry point. Requests the server resolve a cast of the spell in /// at . All validation happens /// here on the server; the client only uses this to trigger an attempt. /// [Rpc(SendTo.Server, RequireOwnership = true)] public void RequestCastSpellRpc(int slot, Vector3 targetPoint) { if (MatchState.Instance == null || MatchState.Instance.Phase != MatchPhase.Playing) return; if (slot < 0 || slot >= spells.Count) return; var slotValue = spells[slot]; if (slotValue.CooldownEndServerTime > NetworkManager.ServerTime.Time) return; var definition = BuilderSpellPool.Instance?.Get(slotValue.Kind); if (definition == null) { Debug.LogWarning($"[PlayerSpellLoadout] No BuilderSpellDefinition for {slotValue.Kind}."); return; } if (!definition.ServerCast(OwnerClientId, targetPoint)) return; slotValue.CooldownEndServerTime = NetworkManager.ServerTime.Time + definition.Cooldown; spells[slot] = slotValue; PlayVfxClientRpc(slotValue.Kind, targetPoint); } [ClientRpc] private void PlayVfxClientRpc(BuilderSpellKind kind, Vector3 targetPoint) { BuilderSpellPool.Instance?.Get(kind)?.ClientPlayVfx(targetPoint); } } }