UnityTowerDefense/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs

268 lines
12 KiB
C#

// 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
{
/// <summary>
/// Per-player set of granted builder spells and their cooldowns. Lives on the Player
/// prefab alongside <see cref="BuilderUpgradeManager"/> and <see cref="PlayerDraft"/>.
/// </summary>
/// <remarks>
/// <para><b>Slot == grant order == hotkey.</b> <see cref="spells"/> is append-only; a
/// slot's index in the list is both its identity and the index into
/// <see cref="SpellHotkeys.Layout"/>. There is no player-configurable rebinding.</para>
///
/// <para><b>Cast flow.</b> The owning client calls <see cref="RequestCastSpellRpc"/> with a
/// slot index and an aimed world point. The server re-validates everything (slot exists,
/// off cooldown, <see cref="MatchPhase.Playing"/>) since client-side checks in
/// <see cref="BuilderSpellCastController"/> are UX-only, resolves the
/// <see cref="BuilderSpellDefinition"/> via <see cref="BuilderSpellPool"/>. An <b>instant</b>
/// spell (<see cref="BuilderSpellDefinition.ImpactDelay"/> == 0) resolves on cast: a hit starts
/// the cooldown and fires <see cref="PlayVfxClientRpc"/>; a miss is a no-op. A <b>delayed</b>
/// 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 <see cref="BuilderSpellDefinition.ImpactDelay"/> seconds later.</para>
/// </remarks>
public class PlayerSpellLoadout : NetworkBehaviour
{
/// <summary>Hard cap on granted spells, matching the number of hotkey slots available.</summary>
public const int MaxSpellSlots = 4;
// ----- Static registry (mirrors BuilderUpgradeManager) -----
private static readonly Dictionary<ulong, PlayerSpellLoadout> s_byClientId
= new Dictionary<ulong, PlayerSpellLoadout>();
/// <summary>Returns the loadout owned by the given client, or null.</summary>
public static PlayerSpellLoadout GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var loadout);
return loadout;
}
/// <summary>Convenience: the local client's own loadout.</summary>
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<SpellSlot> spells = new NetworkList<SpellSlot>();
// Every kind ever granted this match, including ones since upgraded away. Unlike
// `spells`, entries here are never removed — see PlayerHasEverGranted.
private readonly NetworkList<byte> everGrantedKinds = new NetworkList<byte>();
/// <summary>Fired on every peer when a spell is granted or a cooldown changes.</summary>
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<SpellSlot> change) => OnLoadoutChanged?.Invoke();
// ----- Read API -----------------------------------------------------
/// <summary>Number of spells currently granted.</summary>
public int SlotCount => spells.Count;
/// <summary>True if this player has already been granted the given spell.</summary>
public bool PlayerHasSpell(BuilderSpellKind kind)
{
for (int i = 0; i < spells.Count; i++)
{
if (spells[i].Kind == kind) return true;
}
return false;
}
/// <summary>The spell kind occupying <paramref name="slot"/>, or null if out of range.</summary>
public BuilderSpellKind? GetKind(int slot)
=> (slot >= 0 && slot < spells.Count) ? spells[slot].Kind : (BuilderSpellKind?)null;
/// <summary>
/// True if this player has ever been granted the given spell this match, whether or
/// not it's still occupying a slot (e.g. it may have since been upgraded away). Used
/// by <see cref="TD.Gameplay.Draft.BuilderSpellDraftOption"/> so a base spell already
/// upgraded past is never re-offered.
/// </summary>
public bool PlayerHasEverGranted(BuilderSpellKind kind) => everGrantedKinds.Contains((byte)kind);
/// <summary>True if <paramref name="slot"/> is out of range, empty, or still cooling down.</summary>
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;
}
/// <summary>Seconds remaining on <paramref name="slot"/>'s cooldown, or 0 if ready/invalid.</summary>
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 -----------------------------------------------
/// <summary>
/// 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.
/// </summary>
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));
if (!everGrantedKinds.Contains((byte)kind)) everGrantedKinds.Add((byte)kind);
return true;
}
/// <summary>
/// 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.
/// </summary>
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);
if (!everGrantedKinds.Contains((byte)newKind)) everGrantedKinds.Add((byte)newKind);
return true;
}
}
return false;
}
// ----- Cast RPC -----------------------------------------------------
/// <summary>
/// Owning-client entry point. Requests the server resolve a cast of the spell in
/// <paramref name="slot"/> at <paramref name="targetPoint"/>. All validation happens
/// here on the server; the client only uses this to trigger an attempt.
/// </summary>
[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);
}
}
}