// 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);
}
}
}