// Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs using System.Collections; 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 . An instant /// spell ( == 0) resolves on cast: a hit starts /// the cooldown and fires ; a miss is a no-op. A delayed /// spell (a projectile, e.g. the Fireball meteor) commits on the throw — cooldown + the falling /// visual fire immediately — while its damage and impact sound are held until the projectile /// lands seconds later. /// 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; } /// /// Server-only: replaces a previously granted spell with a different one (an upgrade /// pick), in place — the upgraded spell keeps the base spell's hotkey slot and starts /// off cooldown. No-op if the old spell isn't currently granted or the new one already /// is. Returns true if the swap happened. /// public bool ServerUpgradeSpell(BuilderSpellKind oldKind, BuilderSpellKind newKind) { if (!IsServer) return false; if (PlayerHasSpell(newKind)) return false; for (int i = 0; i < spells.Count; i++) { if (spells[i].Kind == oldKind) { spells[i] = SpellSlot.CreateReady(newKind); return true; } } return false; } // ----- 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, InvokePermission = RpcInvokePermission.Owner)] 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.ImpactDelay <= 0f) { // Instant spell (e.g. Slow Area): resolve on cast. A miss (hit nothing) is a no-op // and does NOT start the cooldown — unchanged behavior. if (!definition.ServerCast(OwnerClientId, targetPoint)) return; StartCooldown(slot, definition); PlayVfxClientRpc(slotValue.Kind, targetPoint); } else { // Delayed spell (e.g. the Fireball meteor): the projectile has to fall before it // lands, so the cast is COMMITTED on the throw — cooldown starts and the falling // visual spawns now — while damage and the impact sound wait until it hits the // ground (ImpactDelay later). Enemies are re-scanned at impact, so movement during // the fall resolves correctly. StartCooldown(slot, definition); PlayVfxClientRpc(slotValue.Kind, targetPoint); StartCoroutine(ServerResolveImpactAfterDelay( slotValue.Kind, OwnerClientId, targetPoint, definition.ImpactDelay)); } } // Server-only: stamp the slot's cooldown from the definition and replicate it. private void StartCooldown(int slot, BuilderSpellDefinition definition) { var slotValue = spells[slot]; slotValue.CooldownEndServerTime = NetworkManager.ServerTime.Time + definition.Cooldown; spells[slot] = slotValue; } // Server-only: apply a delayed spell's effect once its projectile has landed. Re-resolves // targets at impact — the enemy set may have shifted during the fall. private IEnumerator ServerResolveImpactAfterDelay( BuilderSpellKind kind, ulong clientId, Vector3 targetPoint, float delay) { yield return new WaitForSeconds(delay); if (!IsServer) yield break; BuilderSpellPool.Instance?.Get(kind)?.ServerCast(clientId, targetPoint); } [ClientRpc] private void PlayVfxClientRpc(BuilderSpellKind kind, Vector3 targetPoint) { var def = BuilderSpellPool.Instance?.Get(kind); if (def == null) return; def.ClientSpawnVfx(targetPoint); // spawn the (possibly falling) visual now if (def.ImpactDelay <= 0f) def.ClientPlayImpact(targetPoint); else StartCoroutine(ClientPlayImpactAfterDelay(kind, targetPoint, def.ImpactDelay)); } // Client-side: play the impact (sound + any contact visual) when the projectile lands. // Timed locally off the same ImpactDelay so it stays in lockstep with this peer's own // falling visual — no second RPC, no double latency. private IEnumerator ClientPlayImpactAfterDelay( BuilderSpellKind kind, Vector3 targetPoint, float delay) { yield return new WaitForSeconds(delay); BuilderSpellPool.Instance?.Get(kind)?.ClientPlayImpact(targetPoint); } } }