More major updates to tools, added map area volume, made gold manager network managed per player.

This commit is contained in:
Matt F 2026-05-01 15:18:44 -07:00
parent b44eeaeeff
commit 56dc775c68
18 changed files with 632 additions and 283 deletions

View file

@ -19,19 +19,20 @@ namespace TD.Core
{
// Player colors. Hex values are RGB; alpha is set per-gizmo at draw time.
// Values are tuned to be saturated enough to read against Unity's default scene background.
private static readonly Color Player1Red = HexRGB(0xE0, 0x3A, 0x3A); // red
private static readonly Color Player2Green = HexRGB(0x3A, 0xC0, 0x4A); // green
private static readonly Color Player3Blue = HexRGB(0x3A, 0x7A, 0xE0); // blue
private static readonly Color Player4Purple = HexRGB(0xA0, 0x4A, 0xC0); // purple
private static readonly Color Player5Yellow = HexRGB(0xE0, 0xC8, 0x3A); // yellow
private static readonly Color Player6Gray = HexRGB(0xB0, 0xB0, 0xB8); // gray (slightly cool)
private static readonly Color Player7Teal = HexRGB(0x3A, 0xC0, 0xB8); // teal
private static readonly Color Player8Olive = HexRGB(0x9A, 0x9A, 0x3A); // olive
private static readonly Color Player9DarkGray = HexRGB(0x60, 0x60, 0x68); // dark gray (slightly cool)
private static readonly Color Player1Red = HexRGB(0xE0, 0x3A, 0x3A); // red
private static readonly Color Player2Green = HexRGB(0x3A, 0xC0, 0x4A); // green
private static readonly Color Player3Blue = HexRGB(0x3A, 0x7A, 0xE0); // blue
private static readonly Color Player4Purple = HexRGB(0xA0, 0x4A, 0xC0); // purple
private static readonly Color Player5Yellow = HexRGB(0xE0, 0xC8, 0x3A); // yellow
private static readonly Color Player6Gray = HexRGB(0xB0, 0xB0, 0xB8); // gray (slightly cool)
private static readonly Color Player7Teal = HexRGB(0x3A, 0xC0, 0xB8); // teal
private static readonly Color Player8Olive = HexRGB(0x9A, 0x9A, 0x3A); // olive
private static readonly Color Player9DarkGray = HexRGB(0x60, 0x60, 0x68); // dark gray (slightly cool)
// Non-player colors.
private static readonly Color GoalGold = HexRGB(0xE0, 0xB0, 0x20); // gold
private static readonly Color ErrorPink = HexRGB(0xFF, 0x4A, 0xC8); // diagnostic
private static readonly Color GoalGold = HexRGB(0xE0, 0xB0, 0x20); // gold
private static readonly Color MapAreaCyan = HexRGB(0xB0, 0xD0, 0xE0); // muted cyan (background)
private static readonly Color ErrorPink = HexRGB(0xFF, 0x4A, 0xC8); // diagnostic
/// <summary>
/// Returns the canonical color for a player slot. Returns the diagnostic error pink if
@ -60,6 +61,13 @@ namespace TD.Core
/// <summary>The canonical color used for goal volumes. Not tied to any player.</summary>
public static Color Goal => GoalGold;
/// <summary>
/// The canonical color used for MapAreaVolume gizmos. Muted cyan — distinct from any
/// player color and from goal gold. Intended to read as background context rather than
/// as a gameplay element.
/// </summary>
public static Color MapArea => MapAreaCyan;
/// <summary>Diagnostic color used when a volume has an invalid/missing owner.</summary>
public static Color Error => ErrorPink;
@ -79,4 +87,4 @@ namespace TD.Core
return new Color(r / 255f, g / 255f, b / 255f, 1f);
}
}
}
}

View file

@ -89,7 +89,8 @@ namespace TD.Levels.Editor
Debug.Log($"[LevelBake] {outcome} — '{ctx.Authoring.mapName}' " +
$"({ctx.PlayerZoneVolumes.Count} player zones, {ctx.SpawnerVolumes.Count} spawners, " +
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals; " +
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals, " +
$"{ctx.MapAreaVolumes.Count} map areas; " +
$"grid {ctx.GridSize.x}×{ctx.GridSize.y}; {report.WarningCount} warnings)");
if (report.WarningCount > 0)
@ -124,16 +125,18 @@ namespace TD.Levels.Editor
var rootTransform = ctx.Authoring.transform;
// Scoped scan: only volumes parented under _LevelAuthoring's transform are part of the bake.
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false).ToList();
ctx.AllVolumes = new List<VolumeBase>();
ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes);
ctx.AllVolumes.AddRange(ctx.SpawnerVolumes);
ctx.AllVolumes.AddRange(ctx.LeakExitVolumes);
ctx.AllVolumes.AddRange(ctx.GoalVolumes);
ctx.AllVolumes.AddRange(ctx.MapAreaVolumes);
// Full-scene scan: catch volumes that exist but aren't parented under _LevelAuthoring.
var orphanCandidates = UnityEngine.Object.FindObjectsByType<VolumeBase>(FindObjectsInactive.Exclude);
@ -149,9 +152,10 @@ namespace TD.Levels.Editor
// hash input ordering and stable iteration in subsequent phases.
ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.PlayerZoneVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
ctx.MapAreaVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
}
// Builds the canonical path string for a transform, relative to the root, with sibling
@ -214,6 +218,17 @@ namespace TD.Levels.Editor
report.Error("P2-2", $"Found {ctx.GoalVolumes.Count} GoalVolume(s); LevelAuthoring expects {auth.expectedGoalCount}.");
}
// P2-22: at least one MapAreaVolume is present.
// The map area is required (not inferred) so that gameplay bounds stay decoupled from
// visual geometry. See "Lessons Learned" — inferring playable bounds from terrain
// mesh extent was deliberately rejected.
if (ctx.MapAreaVolumes.Count == 0)
{
report.Error("P2-22", "No MapAreaVolume found. Every map must have at least one " +
"MapAreaVolume defining the playable bounds (where the builder " +
"can move and the camera can pan).");
}
// P2-16: author non-empty (soft warning)
if (string.IsNullOrWhiteSpace(auth.author))
{
@ -403,6 +418,7 @@ namespace TD.Levels.Editor
ctx.PerVolumeTiles = new Dictionary<VolumeBase, HashSet<Vector2Int>>();
ctx.PerOwnerZoneTiles = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
ctx.PerGoalTiles = new List<HashSet<Vector2Int>>();
ctx.MapAreaTiles = new HashSet<Vector2Int>();
bool initializedAggregate = false;
int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;
@ -461,6 +477,14 @@ namespace TD.Levels.Editor
{
ctx.PerGoalTiles.Add(tiles);
}
// Map area: union all MapAreaVolume tiles into a single set. The map area is the
// union of all MapAreaVolumes, so a tile is "in the map" if ANY MapAreaVolume
// covers it.
if (v is MapAreaVolume)
{
foreach (var t in tiles) ctx.MapAreaTiles.Add(t);
}
}
if (!initializedAggregate)
@ -498,12 +522,27 @@ namespace TD.Levels.Editor
// 2D arrays during composition; flattened to 1D in Phase 6.
ctx.PlacementGrid2D = new PlacementState[width, height]; // defaults to Outside (0)
ctx.WalkabilityGrid2D = new bool[width, height]; // defaults to false
ctx.MapAreaGrid2D = new bool[width, height]; // defaults to false
// Map-area composition: a tile is "in map" iff any MapAreaVolume covers it. We have
// the union pre-computed in ctx.MapAreaTiles from Phase 3.
foreach (var t in ctx.MapAreaTiles)
{
int gx = t.x - ctx.GridOriginTile.x;
int gy = t.y - ctx.GridOriginTile.y;
ctx.MapAreaGrid2D[gx, gy] = true;
}
// Volume-iterating algorithm: O(total tile-coverage) rather than O(grid * volumes).
foreach (var v in ctx.AllVolumes)
{
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
// MapAreaVolume contributes ONLY to the map-area grid (handled above) — not to
// walkability or placement. Buffer tiles (in-map but not gameplay) must remain
// non-walkable so enemies cannot path through them.
if (v is MapAreaVolume) continue;
bool isInvalid = IsInvalidValidity(v);
bool isPlayerZone = v is PlayerZoneVolume;
@ -536,10 +575,10 @@ namespace TD.Levels.Editor
switch (v)
{
case PlayerZoneVolume pz: return pz.placementValidity == PlacementValidity.Invalid;
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
default: return false;
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
default: return false;
}
}
@ -774,6 +813,45 @@ namespace TD.Levels.Editor
report.Warning("P5-11", $"Walkability grid has {components} disconnected regions. " +
"Some areas of the map are unreachable from others.");
}
// P5-12: every gameplay volume's tiles must be a subset of the map area.
// Coverage rule from the design spec — gameplay can't poke outside the playable map.
// Skipped if no MapAreaVolume exists (P2-22 already errored; this would just spam).
if (ctx.MapAreaTiles.Count > 0)
{
ValidateGameplayContainedInMapArea(ctx, report);
}
}
// P5-12: each gameplay volume (PlayerZone, Spawner, LeakExit, Goal) must have all its
// tiles inside ctx.MapAreaTiles. Reports one error per offending volume with the count of
// out-of-map tiles to avoid drowning the report in per-tile errors.
private static void ValidateGameplayContainedInMapArea(BakeContext ctx, BakeReport report)
{
foreach (var v in ctx.AllVolumes)
{
if (v is MapAreaVolume) continue; // The map area itself doesn't need to contain itself.
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
int outsideCount = 0;
Vector2Int firstOutside = Vector2Int.zero;
foreach (var t in tiles)
{
if (!ctx.MapAreaTiles.Contains(t))
{
if (outsideCount == 0) firstOutside = t;
outsideCount++;
}
}
if (outsideCount > 0)
{
report.Error("P5-12",
$"{v.GetType().Name} '{v.name}' has {outsideCount} tile(s) outside the map area " +
$"(first offender: {firstOutside}). Every gameplay volume must be fully contained " +
"within the union of MapAreaVolumes.");
}
}
}
private static int CountWalkableComponents(HashSet<Vector2Int> walkable)
@ -830,6 +908,7 @@ namespace TD.Levels.Editor
data.PlacementGrid = new PlacementState[total];
data.WalkabilityGrid = new bool[total];
data.OwnerGrid = new PlayerSlot[total]; // defaults to PlayerSlot.None (=0)
data.MapAreaGrid = new bool[total]; // defaults to false
for (int x = 0; x < width; x++)
{
@ -838,6 +917,7 @@ namespace TD.Levels.Editor
int idx = y * width + x;
data.PlacementGrid[idx] = ctx.PlacementGrid2D[x, y];
data.WalkabilityGrid[idx] = ctx.WalkabilityGrid2D[x, y];
data.MapAreaGrid[idx] = ctx.MapAreaGrid2D[x, y];
}
}
@ -984,26 +1064,26 @@ namespace TD.Levels.Editor
// -- Hash computation -------------------------------------------------
/// <summary>
/// Computes the authoring hash for a LevelAuthoring's scene state,
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
/// to detect drift from baked data.
/// </summary>
/// <remarks>
/// Delegates to the same hashing routine the bake itself uses, so equal
/// hashes mean the scene would bake to equivalent LevelData. Hashes
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
/// content (terrain, lighting, decorations) is NOT included.
///
/// Performs its own volume discovery and canonical sort. Does NOT run
/// pre-validation or rasterization, so it returns a hash even for scenes
/// that would fail the bake (e.g. missing zones, overlapping volumes).
/// That's intentional: the play-mode hook needs a hash even when the
/// scene is broken, so it can report "drift" as the actionable problem
/// rather than silently differing from the last successful bake.
/// </remarks>
public static string ComputeAuthoringHash(LevelAuthoring authoring)
{
/// <summary>
/// Computes the authoring hash for a LevelAuthoring's scene state,
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
/// to detect drift from baked data.
/// </summary>
/// <remarks>
/// Delegates to the same hashing routine the bake itself uses, so equal
/// hashes mean the scene would bake to equivalent LevelData. Hashes
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
/// content (terrain, lighting, decorations) is NOT included.
///
/// Performs its own volume discovery and canonical sort. Does NOT run
/// pre-validation or rasterization, so it returns a hash even for scenes
/// that would fail the bake (e.g. missing zones, overlapping volumes).
/// That's intentional: the play-mode hook needs a hash even when the
/// scene is broken, so it can report "drift" as the actionable problem
/// rather than silently differing from the last successful bake.
/// </remarks>
public static string ComputeAuthoringHash(LevelAuthoring authoring)
{
if (authoring == null)
return string.Empty;
@ -1018,13 +1098,15 @@ namespace TD.Levels.Editor
var spawners = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false);
var leakExits = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false);
var goals = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false);
var mapAreas = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false);
ctx.AllVolumes = new System.Collections.Generic.List<VolumeBase>(
playerZones.Length + spawners.Length + leakExits.Length + goals.Length);
playerZones.Length + spawners.Length + leakExits.Length + goals.Length + mapAreas.Length);
ctx.AllVolumes.AddRange(playerZones);
ctx.AllVolumes.AddRange(spawners);
ctx.AllVolumes.AddRange(leakExits);
ctx.AllVolumes.AddRange(goals);
ctx.AllVolumes.AddRange(mapAreas);
// Match Phase 1's canonical sort exactly. The hash routine re-orders
// by canonical path internally, but sorting AllVolumes here keeps
@ -1240,6 +1322,7 @@ namespace TD.Levels.Editor
target.PlacementGrid = src.PlacementGrid;
target.WalkabilityGrid = src.WalkabilityGrid;
target.OwnerGrid = src.OwnerGrid;
target.MapAreaGrid = src.MapAreaGrid;
target.PlayerZones = src.PlayerZones;
target.Goals = src.Goals;
@ -1272,6 +1355,7 @@ namespace TD.Levels.Editor
public List<SpawnerVolume> SpawnerVolumes;
public List<LeakExitVolume> LeakExitVolumes;
public List<GoalVolume> GoalVolumes;
public List<MapAreaVolume> MapAreaVolumes;
// Phase 2 outputs
public HashSet<PlayerSlot> ExpectedOwners;
@ -1281,6 +1365,7 @@ namespace TD.Levels.Editor
public Dictionary<VolumeBase, HashSet<Vector2Int>> PerVolumeTiles;
public Dictionary<PlayerSlot, HashSet<Vector2Int>> PerOwnerZoneTiles;
public List<HashSet<Vector2Int>> PerGoalTiles;
public HashSet<Vector2Int> MapAreaTiles;
public Vector2Int MapMinTile;
public Vector2Int MapMaxTile;
@ -1289,6 +1374,7 @@ namespace TD.Levels.Editor
public Vector2Int GridSize;
public PlacementState[,] PlacementGrid2D;
public bool[,] WalkabilityGrid2D;
public bool[,] MapAreaGrid2D;
// Phase 5 outputs
public HashSet<PlayerSlot> GoalAdjacentZones;
@ -1297,4 +1383,4 @@ namespace TD.Levels.Editor
public LevelData AssembledData;
public string ThumbnailPath;
}
}
}

View file

@ -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;
}
}
}

View file

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d44ebdd0b2fc4144c8f8a181a714b738

View file

@ -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
}
}
}
}
}

View 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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b9796562d7cc274f832657f21a61cce