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

247 lines
No EOL
10 KiB
C#

using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// Per-player gold pool. One instance is spawned per connected client (via
/// <see cref="NetworkManager.PlayerPrefab"/>) and owned by that client.
///
/// Replaces the earlier singleton-style GoldManager. Same server-authoritative
/// pattern (NetworkVariable + server-validated Rpc), but every player has their
/// own pool instead of sharing one.
///
/// Three-beat pattern (unchanged from the original template):
/// 1. State lives in NetworkVariables (server-only writes).
/// 2. The owning client REQUESTS spends via a server-targeted Rpc.
/// 3. The server VALIDATES before applying.
/// </summary>
/// <remarks>
/// Lookup ergonomics: each instance registers itself in a static dictionary
/// keyed by OwnerClientId during OnNetworkSpawn and removes itself during
/// OnNetworkDespawn. Server code that needs to award gold to a specific
/// player can call <see cref="GetForClient(ulong)"/>.
///
/// This component is expected to live on the Player Prefab assigned to
/// NetworkManager. NGO handles spawn-on-connect and ownership assignment
/// automatically.
/// </remarks>
public class PlayerGoldManager : NetworkBehaviour
{
// --- Static registry ---------------------------------------------
// Keyed by OwnerClientId. Populated on every peer when an instance
// spawns; the same client can be looked up on the server (for awards)
// and on clients (for UI). Kept private — access goes through GetForClient.
private static readonly Dictionary<ulong, PlayerGoldManager> s_byClientId
= new Dictionary<ulong, PlayerGoldManager>();
/// <summary>
/// Returns the PlayerGoldManager owned by the given client, or null if
/// none is currently spawned. Safe to call on server or client.
/// </summary>
public static PlayerGoldManager GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var manager);
return manager;
}
/// <summary>
/// Convenience: the local client's own gold manager. Returns null on a
/// dedicated server or before the local player has spawned.
/// </summary>
public static PlayerGoldManager Local
{
get
{
var nm = NetworkManager.Singleton;
if (nm == null || !nm.IsClient) return null;
return GetForClient(nm.LocalClientId);
}
}
// --- Tunables ----------------------------------------------------
[Tooltip("Fallback starting gold used only when no GoldConfig is reachable at " +
"match start (e.g. editor testing in the Match scene without the lobby " +
"flow). Normally WaveManager overwrites this with GoldConfig.StartingGold " +
"during InitAfterSpawn.")]
[SerializeField] private int startingGold = 100;
// --- Networked state ---------------------------------------------
// readPerm = Everyone so any client can read any other player's gold
// (needed for scoreboard / opponent UI). writePerm = Server keeps
// authority where it belongs.
private readonly NetworkVariable<int> currentGold = new NetworkVariable<int>(
value: 0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
// Gold this player has accumulated since the start of the current wave. Reset to
// 0 at the start of each new wave by WaveManager. Includes kill rewards,
// completion bonus, and no-leak bonus — anything that flows through AwardGold.
// Spending does NOT decrement it; it's a "gold earned", not "gold held", counter.
// The HUD's top-bar "+N g/wave" reads this for the LOCAL player.
private readonly NetworkVariable<int> goldEarnedThisWave = new NetworkVariable<int>(
value: 0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
public int CurrentGold => currentGold.Value;
public int GoldEarnedThisWave => goldEarnedThisWave.Value;
// --- Lifecycle ---------------------------------------------------
public override void OnNetworkSpawn()
{
Debug.Log($"[PlayerGoldManager] OnNetworkSpawn. OwnerClientId={OwnerClientId}, " +
$"IsOwner={IsOwner}, IsServer={IsServer}");
currentGold.OnValueChanged += HandleGoldChanged;
s_byClientId[OwnerClientId] = this;
if (IsServer)
{
currentGold.Value = startingGold;
}
}
public override void OnNetworkDespawn()
{
currentGold.OnValueChanged -= HandleGoldChanged;
// Only remove if the entry still points to this instance — guards
// against the (unlikely) case where a new instance for the same
// client has already overwritten the slot.
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
{
s_byClientId.Remove(OwnerClientId);
}
}
private void HandleGoldChanged(int previous, int current)
{
Debug.Log($"[PlayerGoldManager] Client {OwnerClientId} gold: {previous} -> {current}");
}
// --- Public API --------------------------------------------------
/// <summary>
/// Owning-client entry point for spending gold. Sends a server Rpc;
/// the server validates and applies. Calling this on a non-owning
/// client will be rejected by the Rpc's Owner permission.
/// </summary>
public void RequestSpendGold(int amount)
{
SpendGoldRpc(amount);
}
/// <summary>
/// Server-side entry point for awarding gold (wave clear, enemy kill).
/// Direct call — not Rpc-wrapped — because awards always originate
/// from server-authoritative game events. Also increments
/// <see cref="GoldEarnedThisWave"/> so the HUD's per-wave counter reflects
/// it; spending does not decrement that counter (it tracks earnings, not balance).
/// </summary>
public void AwardGold(int amount)
{
if (!IsServer)
{
Debug.LogError("[PlayerGoldManager] AwardGold called on a client. " +
"Only server code should call this directly.");
return;
}
if (amount <= 0) return;
currentGold.Value += amount;
goldEarnedThisWave.Value += amount;
}
/// <summary>
/// Server-side: overwrites the player's current gold to the exact specified
/// amount. Used by <c>WaveManager</c> at match start to apply
/// <see cref="GoldConfig.StartingGold"/>, replacing the inspector-default value
/// set during initial spawn. Does NOT touch <see cref="GoldEarnedThisWave"/>.
/// </summary>
public void ServerSetGold(int amount)
{
if (!IsServer)
{
Debug.LogError("[PlayerGoldManager] ServerSetGold called on a client. " +
"Only server code should call this directly.");
return;
}
currentGold.Value = Mathf.Max(0, amount);
}
/// <summary>
/// Server-side: resets <see cref="GoldEarnedThisWave"/> back to 0. Called by
/// <c>WaveManager</c> at the start of each new wave so the HUD's top-bar
/// per-wave counter starts fresh.
/// </summary>
public void ServerResetWaveEarnings()
{
if (!IsServer)
{
Debug.LogError("[PlayerGoldManager] ServerResetWaveEarnings called on a client.");
return;
}
goldEarnedThisWave.Value = 0;
}
/// <summary>
/// Server-side entry point for deducting gold (tower placement, other costs).
/// Direct call — not Rpc-wrapped — because deductions always originate from
/// server-authoritative validation (e.g., <c>TowerPlacementManager</c> after
/// all checks have passed). Clamps to zero; gold cannot go negative.
/// </summary>
public void DeductGold(int amount)
{
if (!IsServer)
{
Debug.LogError("[PlayerGoldManager] DeductGold called on a client. " +
"Only server code should call this directly.");
return;
}
if (amount <= 0) return;
currentGold.Value = Mathf.Max(0, currentGold.Value - amount);
}
// --- Server-side Rpc ---------------------------------------------
// InvokePermission = Owner: only the client that owns this NetworkObject
// can invoke the Rpc. NGO will reject calls from anyone else, so a
// malicious client can't spend another player's gold.
//
// This replaces the old "RequireOwnership = false" + the implicit
// shared-pool semantics. The deprecation warning is gone.
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
private void SpendGoldRpc(int amount, RpcParams rpcParams = default)
{
// Validation 1: positive amount.
if (amount <= 0)
{
Debug.LogWarning($"[PlayerGoldManager] Rejected spend of {amount} " +
$"from client {rpcParams.Receive.SenderClientId}: " +
$"amount must be positive.");
return;
}
// Validation 2: sufficient funds.
if (currentGold.Value < amount)
{
Debug.LogWarning($"[PlayerGoldManager] Rejected spend of {amount} " +
$"from client {rpcParams.Receive.SenderClientId}: " +
$"insufficient funds (have {currentGold.Value}).");
return;
}
currentGold.Value -= amount;
}
}
}