More major updates to tools, added map area volume, made gold manager network managed per player.
This commit is contained in:
parent
b44eeaeeff
commit
56dc775c68
18 changed files with 632 additions and 283 deletions
|
|
@ -1,147 +0,0 @@
|
|||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// GoldManager — canonical server-authoritative template for this project.
|
||||
///
|
||||
/// Every gameplay system (towers, enemies, waves, damage) should follow
|
||||
/// the same three-beat pattern demonstrated here:
|
||||
/// 1. State lives in NetworkVariables, which only the server can write.
|
||||
/// 2. Clients REQUEST changes via [Rpc(SendTo.Server, ...)] methods.
|
||||
/// They never change state directly.
|
||||
/// 3. The server VALIDATES the request before applying it.
|
||||
/// Never trust the client.
|
||||
///
|
||||
/// Cosmetic-only reactions (sounds, VFX, UI popups) can use
|
||||
/// [Rpc(SendTo.ClientsAndHost)] or [Rpc(SendTo.NotServer)] to broadcast.
|
||||
/// </summary>
|
||||
public class GoldManager : NetworkBehaviour
|
||||
{
|
||||
// --- Tunables (editable in Inspector) -----------------------------
|
||||
|
||||
[Tooltip("How much gold every player starts with when the game begins.")]
|
||||
[SerializeField] private int startingGold = 100;
|
||||
|
||||
// --- Networked state ----------------------------------------------
|
||||
|
||||
// A NetworkVariable<T> automatically syncs from server to clients.
|
||||
// readPerm = Everyone: all clients can read the current value.
|
||||
// writePerm = Server: only the server can change it.
|
||||
private readonly NetworkVariable<int> currentGold = new NetworkVariable<int>(
|
||||
value: 0,
|
||||
readPerm: NetworkVariableReadPermission.Everyone,
|
||||
writePerm: NetworkVariableWritePermission.Server
|
||||
);
|
||||
|
||||
// Public read-only accessor for other scripts (UI, tower placement).
|
||||
public int CurrentGold => currentGold.Value;
|
||||
|
||||
// --- Lifecycle ----------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// OnNetworkSpawn runs on every peer (server + all clients) when this
|
||||
/// NetworkBehaviour becomes active on the network. Replaces Start()
|
||||
/// for networked setup.
|
||||
/// </summary>
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
Debug.Log($"[GoldManager] OnNetworkSpawn ran. IsServer={IsServer}, IsClient={IsClient}, IsHost={IsHost}");
|
||||
|
||||
currentGold.OnValueChanged += HandleGoldChanged;
|
||||
Debug.Log($"[GoldManager] Subscribed to OnValueChanged. Current value before init: {currentGold.Value}");
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
currentGold.Value = startingGold;
|
||||
Debug.Log($"[GoldManager] Server initialized gold. Current value after set: {currentGold.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
// Always unsubscribe to avoid callback leaks.
|
||||
currentGold.OnValueChanged -= HandleGoldChanged;
|
||||
}
|
||||
|
||||
private void HandleGoldChanged(int previous, int current)
|
||||
{
|
||||
// Fires on every peer whenever the value syncs. Use Log here so
|
||||
// you can see syncing in the Console during development.
|
||||
Debug.Log($"[GoldManager] Gold changed: {previous} -> {current}");
|
||||
}
|
||||
|
||||
// --- Public API (called by client-side code) ----------------------
|
||||
|
||||
/// <summary>
|
||||
/// Client-side entry point for spending gold. Called by gameplay code
|
||||
/// like TowerPlacement when the local player clicks "build tower."
|
||||
///
|
||||
/// The actual spending happens on the server via the Rpc.
|
||||
/// </summary>
|
||||
public void RequestSpendGold(int amount)
|
||||
{
|
||||
SpendGoldRpc(amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
||||
/// Not Rpc-wrapped — this is called directly by server game logic in
|
||||
/// response to server-authoritative events.
|
||||
/// </summary>
|
||||
public void AwardGold(int amount)
|
||||
{
|
||||
if (!IsServer)
|
||||
{
|
||||
Debug.LogError("[GoldManager] AwardGold called on a client! " +
|
||||
"Only server code should call this directly.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount <= 0) return;
|
||||
currentGold.Value += amount;
|
||||
}
|
||||
|
||||
// --- Server-side RPC ----------------------------------------------
|
||||
|
||||
// [Rpc(SendTo.Server, ...)] means: a client calls this locally, but
|
||||
// NGO routes the call and executes the method on the server.
|
||||
//
|
||||
// RequireOwnership = false lets any client call it (correct for a
|
||||
// shared GoldManager). For per-player NetworkObjects you'd usually
|
||||
// leave the default ownership requirement in place.
|
||||
//
|
||||
// Naming convention: methods with [Rpc] attributes must end with "Rpc".
|
||||
// The source generator relies on this suffix.
|
||||
[Rpc(SendTo.Server, RequireOwnership = false)]
|
||||
private void SpendGoldRpc(int amount, RpcParams rpcParams = default)
|
||||
{
|
||||
// This method body runs on the server only.
|
||||
// Validate everything — do not trust the client.
|
||||
|
||||
// Validation 1: reject non-positive amounts. A negative amount
|
||||
// would let a malicious client GAIN gold if we just subtracted.
|
||||
if (amount <= 0)
|
||||
{
|
||||
Debug.LogWarning($"[GoldManager] Rejected spend of {amount} " +
|
||||
$"from client {rpcParams.Receive.SenderClientId}: " +
|
||||
$"amount must be positive.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation 2: can't spend more than current balance.
|
||||
if (currentGold.Value < amount)
|
||||
{
|
||||
Debug.LogWarning($"[GoldManager] Rejected spend of {amount} " +
|
||||
$"from client {rpcParams.Receive.SenderClientId}: " +
|
||||
$"insufficient funds (have {currentGold.Value}).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Server applies the change. NetworkVariable syncs to clients
|
||||
// automatically at the next network tick.
|
||||
currentGold.Value -= amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d44ebdd0b2fc4144c8f8a181a714b738
|
||||
|
|
@ -264,6 +264,23 @@ namespace TD.Gameplay
|
|||
return x >= 0 && x < level.GridSize.x && y >= 0 && y < level.GridSize.y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="tile"/> is part of the playable map area (inside any
|
||||
/// MapAreaVolume at bake time). Returns false for out-of-bounds tiles and for in-bounds
|
||||
/// "void" tiles outside the map area. This is the outermost gate — gameplay queries
|
||||
/// (IsWalkable, GetPlacement, GetOwner) are only meaningful where IsInMap is true.
|
||||
///
|
||||
/// Use this for: builder movement clamp, camera pan clamp, minimap rendering bounds.
|
||||
/// </summary>
|
||||
public bool IsInMap(Vector2Int tile)
|
||||
{
|
||||
if (!TryFlatIndex(tile, out int idx)) return false;
|
||||
// Defensive: existing maps that haven't been re-baked since MapAreaGrid was added
|
||||
// will have a null array. Treat that as "not in map" so callers don't false-positive.
|
||||
if (level.MapAreaGrid == null || level.MapAreaGrid.Length == 0) return false;
|
||||
return level.MapAreaGrid[idx];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="tile"/> is currently walkable. Returns
|
||||
/// false for out-of-bounds tiles. Reflects the runtime walkability
|
||||
|
|
@ -451,4 +468,4 @@ namespace TD.Gameplay
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs
Normal file
178
Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
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("How much gold this player starts with when the match begins.")]
|
||||
[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
|
||||
);
|
||||
|
||||
public int CurrentGold => currentGold.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.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6b9796562d7cc274f832657f21a61cce
|
||||
Loading…
Add table
Add a link
Reference in a new issue