diff --git a/Assets/DefaultNetworkPrefabs.asset b/Assets/DefaultNetworkPrefabs.asset
index 219664e..abe922b 100644
--- a/Assets/DefaultNetworkPrefabs.asset
+++ b/Assets/DefaultNetworkPrefabs.asset
@@ -13,4 +13,9 @@ MonoBehaviour:
m_Name: DefaultNetworkPrefabs
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkPrefabsList
IsDefault: 1
- List: []
+ List:
+ - Override: 0
+ Prefab: {fileID: 3493329038866903420, guid: 9a9c23b8584ab444aa5066a48579a9ec, type: 3}
+ SourcePrefabToOverride: {fileID: 0}
+ SourceHashToOverride: 0
+ OverridingTargetPrefab: {fileID: 0}
diff --git a/Assets/_Project/Levels/LevelAuthoring.cs b/Assets/_Project/Levels/LevelAuthoring.cs
index 390fef0..8a5c349 100644
--- a/Assets/_Project/Levels/LevelAuthoring.cs
+++ b/Assets/_Project/Levels/LevelAuthoring.cs
@@ -69,6 +69,11 @@ namespace TD.Levels
[Tooltip("If true, GoalVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowGoals = false;
+ [Tooltip("If true, MapAreaVolume gizmos draw whether or not the volume is selected. " +
+ "Defaults to true — the map boundary is generally useful to see while authoring " +
+ "the rest of the map, not just when the MapAreaVolume itself is selected.")]
+ public bool alwaysShowMapArea = true;
+
// -------------------------------------------------------------------
// Bake API
// -------------------------------------------------------------------
@@ -242,4 +247,4 @@ namespace TD.Levels
Gizmos.color = prev;
}
}
-}
+}
\ No newline at end of file
diff --git a/Assets/_Project/Levels/LevelData.cs b/Assets/_Project/Levels/LevelData.cs
index df02d83..5e33677 100644
--- a/Assets/_Project/Levels/LevelData.cs
+++ b/Assets/_Project/Levels/LevelData.cs
@@ -81,6 +81,13 @@ namespace TD.Levels
"Length = GridSize.x * GridSize.y.")]
public PlayerSlot[] OwnerGrid;
+ [Tooltip("Per-tile map-area membership. True if the tile is part of the playable map " +
+ "(inside any MapAreaVolume); false for void tiles outside the map. This is the " +
+ "outermost gating layer — gameplay queries (placement, ownership, walkability) " +
+ "are only meaningful for tiles where MapAreaGrid is true. Length = GridSize.x * " +
+ "GridSize.y.")]
+ public bool[] MapAreaGrid;
+
// -------------------------------------------------------------------
// Per-zone and per-goal structures (populated by bake from volume data).
// -------------------------------------------------------------------
@@ -141,4 +148,4 @@ namespace TD.Levels
[Tooltip("Full tile coverage of the goal volume.")]
public Vector2Int[] TileArea;
}
-}
+}
\ No newline at end of file
diff --git a/Assets/_Project/Levels/MapAreaVolume.cs b/Assets/_Project/Levels/MapAreaVolume.cs
new file mode 100644
index 0000000..14ec397
--- /dev/null
+++ b/Assets/_Project/Levels/MapAreaVolume.cs
@@ -0,0 +1,103 @@
+using UnityEngine;
+using TD.Core;
+
+#if UNITY_EDITOR
+using UnityEditor;
+#endif
+
+namespace TD.Levels
+{
+ ///
+ /// Authoring volume defining the playable map area — the bounds the builder can move within
+ /// and the camera can pan to. Has no gameplay payload (no owner, no placement validity, no
+ /// spawner data, no leak target). Multiple instances are allowed; their union defines the
+ /// "map area" as referenced in the per-tile data layering spec.
+ ///
+ ///
+ /// Map area = where the player's attention can go.
+ /// Gameplay area = union of PlayerZone, Spawner, LeakExit, Goal volumes (where rules apply).
+ /// Buffer area = map area minus gameplay area (visible terrain, no gameplay, no building).
+ ///
+ /// Coverage rule: every gameplay volume's tiles must be a subset of the map area. The bake
+ /// validates this in Phase 5 (P5-12) and fails with a hard error if any gameplay volume
+ /// pokes outside the map area.
+ ///
+ /// Drawn as a thicker outline only (no fill) so it reads as a boundary marker rather than
+ /// as a translucent overlay on top of the gameplay volumes. The "always show" toggle on
+ /// LevelAuthoring defaults to true for this volume type because the map boundary is
+ /// generally useful to see during all authoring, not just when this volume is selected.
+ ///
+ public class MapAreaVolume : VolumeBase
+ {
+ // Map area draws BELOW player zones (which sit at 0.05). Just above the LevelAuthoring
+ // map-bounds line (0.01) so it doesn't z-fight with that wireframe.
+ private const float FillYLevel = 0.02f;
+
+ protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
+ {
+ return authoring.alwaysShowMapArea;
+ }
+
+ private void OnDrawGizmosSelected()
+ {
+ DrawGizmosCore();
+ }
+
+ private void OnDrawGizmos()
+ {
+ if (ShouldDrawAlwaysOn())
+ {
+ DrawGizmosCore();
+ }
+ }
+
+ private void DrawGizmosCore()
+ {
+ Color baseColor = PlayerColors.MapArea;
+ DrawThickRectangularOutline(baseColor, yLevel: FillYLevel);
+
+#if UNITY_EDITOR
+ var col = Collider;
+ if (col != null)
+ {
+ Vector3 labelPos = new Vector3(col.bounds.center.x, FillYLevel + 0.1f, col.bounds.center.z);
+ Handles.Label(labelPos, "Map Area");
+ }
+#endif
+ }
+
+ // Draws the volume's tight tile rectangle as a thicker outline. Unity's Gizmos.DrawLine
+ // has no width parameter, so we approximate thickness by drawing the rectangle three
+ // times with small lateral offsets. The offsets are in tile units; 0.04 reads as a
+ // visibly thicker line at typical scene-view zoom without looking like multiple lines.
+ private void DrawThickRectangularOutline(Color outlineColor, float yLevel)
+ {
+ if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
+
+ const float thickness = 0.04f;
+ float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
+
+ // Three concentric rectangles: the original edge, slightly inset, slightly outset.
+ DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, 0f, outlineColor);
+ DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, +thickness, outlineColor);
+ DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, -thickness, outlineColor);
+ }
+
+ private static void DrawOutlineAtInset(Vector2Int minTile, Vector2Int maxTile, float halfTile,
+ float yLevel, float inset, Color color)
+ {
+ Vector3 sw = new Vector3(minTile.x - halfTile - inset, yLevel, minTile.y - halfTile - inset);
+ Vector3 se = new Vector3(maxTile.x + halfTile + inset, yLevel, minTile.y - halfTile - inset);
+ Vector3 ne = new Vector3(maxTile.x + halfTile + inset, yLevel, maxTile.y + halfTile + inset);
+ Vector3 nw = new Vector3(minTile.x - halfTile - inset, yLevel, maxTile.y + halfTile + inset);
+
+ Color prev = Gizmos.color;
+ Gizmos.color = color;
+ Gizmos.DrawLine(sw, se);
+ Gizmos.DrawLine(se, ne);
+ Gizmos.DrawLine(ne, nw);
+ Gizmos.DrawLine(nw, sw);
+ Gizmos.color = prev;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/_Project/Levels/MapAreaVolume.cs.meta b/Assets/_Project/Levels/MapAreaVolume.cs.meta
new file mode 100644
index 0000000..2c66f04
--- /dev/null
+++ b/Assets/_Project/Levels/MapAreaVolume.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bacb3662546735544bc6c56b5bf5830a
\ No newline at end of file
diff --git a/Assets/_Project/Prefabs/Player.meta b/Assets/_Project/Prefabs/Player.meta
new file mode 100644
index 0000000..3f74df3
--- /dev/null
+++ b/Assets/_Project/Prefabs/Player.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 36f79ad6231ec054a9ceed380b33ce11
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab
new file mode 100644
index 0000000..827282d
--- /dev/null
+++ b/Assets/_Project/Prefabs/Player/Player.prefab
@@ -0,0 +1,74 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &3493329038866903420
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 8600750867913649879}
+ - component: {fileID: 2152427255203126265}
+ - component: {fileID: 2918837822014987993}
+ m_Layer: 0
+ m_Name: Player
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &8600750867913649879
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3493329038866903420}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 36.397, y: 0.5, z: 6.33766}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &2152427255203126265
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3493329038866903420}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
+ GlobalObjectIdHash: 2568017024
+ InScenePlacedSourceGlobalObjectIdHash: 0
+ DeferredDespawnTick: 0
+ Ownership: 1
+ AlwaysReplicateAsRoot: 0
+ SynchronizeTransform: 1
+ ActiveSceneSynchronization: 0
+ SceneMigrationSynchronization: 0
+ SpawnWithObservers: 1
+ DontDestroyWithOwner: 0
+ AutoObjectParentSync: 1
+ SyncOwnerTransformWhenParented: 1
+ AllowOwnerToParent: 0
+--- !u!114 &2918837822014987993
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3493329038866903420}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 6b9796562d7cc274f832657f21a61cce, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerGoldManager
+ ShowTopMostFoldoutHeaderGroup: 1
+ startingGold: 100
diff --git a/Assets/_Project/Prefabs/Player/Player.prefab.meta b/Assets/_Project/Prefabs/Player/Player.prefab.meta
new file mode 100644
index 0000000..0cfc686
--- /dev/null
+++ b/Assets/_Project/Prefabs/Player/Player.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9a9c23b8584ab444aa5066a48579a9ec
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity
index 7f41077..7043dbf 100644
--- a/Assets/_Project/Scenes/Levels/Main.unity
+++ b/Assets/_Project/Scenes/Levels/Main.unity
@@ -237,78 +237,6 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
---- !u!1 &239104687
-GameObject:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- serializedVersion: 6
- m_Component:
- - component: {fileID: 239104690}
- - component: {fileID: 239104688}
- - component: {fileID: 239104689}
- m_Layer: 0
- m_Name: GoldManager
- m_TagString: Untagged
- m_Icon: {fileID: 0}
- m_NavMeshLayer: 0
- m_StaticEditorFlags: 0
- m_IsActive: 1
---- !u!114 &239104688
-MonoBehaviour:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 239104687}
- m_Enabled: 1
- m_EditorHideFlags: 0
- m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
- m_Name:
- m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
- GlobalObjectIdHash: 3224213014
- InScenePlacedSourceGlobalObjectIdHash: 0
- DeferredDespawnTick: 0
- Ownership: 1
- AlwaysReplicateAsRoot: 0
- SynchronizeTransform: 1
- ActiveSceneSynchronization: 0
- SceneMigrationSynchronization: 0
- SpawnWithObservers: 1
- DontDestroyWithOwner: 0
- AutoObjectParentSync: 1
- SyncOwnerTransformWhenParented: 1
- AllowOwnerToParent: 0
---- !u!114 &239104689
-MonoBehaviour:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 239104687}
- m_Enabled: 1
- m_EditorHideFlags: 0
- m_Script: {fileID: 11500000, guid: d44ebdd0b2fc4144c8f8a181a714b738, type: 3}
- m_Name:
- m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.GoldManager
- ShowTopMostFoldoutHeaderGroup: 1
- startingGold: 100
---- !u!4 &239104690
-Transform:
- m_ObjectHideFlags: 0
- m_CorrespondingSourceObject: {fileID: 0}
- m_PrefabInstance: {fileID: 0}
- m_PrefabAsset: {fileID: 0}
- m_GameObject: {fileID: 239104687}
- serializedVersion: 2
- m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: 0, y: 0, z: 0}
- m_LocalScale: {x: 1, y: 1, z: 1}
- m_ConstrainProportionsScale: 0
- m_Children: []
- m_Father: {fileID: 0}
- m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &304575571
GameObject:
m_ObjectHideFlags: 0
@@ -682,6 +610,7 @@ MonoBehaviour:
alwaysShowSpawners: 1
alwaysShowLeakExits: 1
alwaysShowGoals: 1
+ alwaysShowMapArea: 1
--- !u!4 &441239881
Transform:
m_ObjectHideFlags: 0
@@ -701,6 +630,7 @@ Transform:
- {fileID: 1078485324}
- {fileID: 1064792476}
- {fileID: 1360337263}
+ - {fileID: 923592499}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &832575517
@@ -752,6 +682,72 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &923592498
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 923592499}
+ - component: {fileID: 923592501}
+ - component: {fileID: 923592500}
+ m_Layer: 0
+ m_Name: MapArea
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &923592499
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 923592498}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 441239881}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &923592500
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 923592498}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: bacb3662546735544bc6c56b5bf5830a, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.MapAreaVolume
+--- !u!65 &923592501
+BoxCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 923592498}
+ m_Material: {fileID: 0}
+ m_IncludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ExcludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_LayerOverridePriority: 0
+ m_IsTrigger: 0
+ m_ProvidesContacts: 0
+ m_Enabled: 1
+ serializedVersion: 3
+ m_Size: {x: 70, y: 0.5, z: 39}
+ m_Center: {x: 0.5, y: 0, z: 19}
--- !u!1 &1064792475
GameObject:
m_ObjectHideFlags: 0
@@ -1137,7 +1133,7 @@ MonoBehaviour:
NetworkConfig:
ProtocolVersion: 0
NetworkTransport: {fileID: 1682341400}
- PlayerPrefab: {fileID: 0}
+ PlayerPrefab: {fileID: 3493329038866903420, guid: 9a9c23b8584ab444aa5066a48579a9ec, type: 3}
Prefabs:
NetworkPrefabsLists:
- {fileID: 11400000, guid: 481ab1d7456efd044bc3e349aacd92ae, type: 2}
@@ -1254,7 +1250,6 @@ SceneRoots:
- {fileID: 410087041}
- {fileID: 832575519}
- {fileID: 1682341402}
- - {fileID: 239104690}
- {fileID: 441239881}
- {fileID: 1464027364}
- {fileID: 167151709}
diff --git a/Assets/_Project/Scenes/Levels/TestLevel.asset b/Assets/_Project/Scenes/Levels/TestLevel.asset
index ee59cd6..57f092c 100644
--- a/Assets/_Project/Scenes/Levels/TestLevel.asset
+++ b/Assets/_Project/Scenes/Levels/TestLevel.asset
@@ -18,15 +18,16 @@ MonoBehaviour:
Author: Matt
MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3}
ScenePath: Assets/_Project/Scenes/Levels/Main.unity
- AuthoringHash: 521a1ef38caafd70be6e364f81e999f5da6c425332fe32933766854b8cfad413
- LastBakeTimestamp: 2026-04-30T19:05:42.7013062Z
+ AuthoringHash: de422400f5f1f75440a3d909f65f34569621293a1499b988881845ac0dc248a4
+ LastBakeTimestamp: 2026-05-01T22:11:16.4327588Z
LastBakeOutcome: 1
- LastBakeWarningCount: 1
- GridOriginTile: {x: 0, y: 0}
- GridSize: {x: 68, y: 17}
- PlacementGrid: 02020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020202020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020202020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000
- WalkabilityGrid: 01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000
- OwnerGrid: 01010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+ LastBakeWarningCount: 2
+ GridOriginTile: {x: -1, y: -10}
+ GridSize: {x: 70, y: 39}
+ PlacementGrid: 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020000020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020200000202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202000002020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020000020202020202020101010101010101010101010101010101010101010101010101010102020101010101010101010101010101010101010101010101010101010102020200000202020202020201010101010101010101010101010101010101010101010101010101020201010101010101010101010101010101010101010101010101010101020202000002020202020202010101010101010101010101010101010101010101010101010101010202010101010101010101010101010101010101010101010101010101010202020000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002020202020202000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000202020202020200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+ WalkabilityGrid: 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010000010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010100000101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101000001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010000010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010100000101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101000001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010101010101010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000101010101010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+ OwnerGrid: 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000000010101010101010101010101010101010101010101010101010101010101010101010100000202020202020202020202020202020202020202020202020202020200000000000101010101010101010101010101010101010101010101010101010101010101010101000002020202020202020202020202020202020202020202020202020202000000000001010101010101010101010101010101010101010101010101010101010101010101010000020202020202020202020202020202020202020202020202020202020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
+ MapAreaGrid: 010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101
PlayerZones:
- Owner: 1
Spawners:
diff --git a/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png b/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
index d469050..8f50996 100644
--- a/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
+++ b/Assets/_Project/Scenes/Levels/TestLevel_Thumbnail.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9b13dafb78f7bc013cba5dd80719fd2e86d11f2601ad7103adc85bfd7aed067d
-size 5198
+oid sha256:62cfc398409e4285b50681d79ae4767183db364b3f9feb9072ddf9dd60ec07d9
+size 12253
diff --git a/Assets/_Project/Scripts/Core/PlayerColors.cs b/Assets/_Project/Scripts/Core/PlayerColors.cs
index d220f7f..3d74d31 100644
--- a/Assets/_Project/Scripts/Core/PlayerColors.cs
+++ b/Assets/_Project/Scripts/Core/PlayerColors.cs
@@ -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
///
/// Returns the canonical color for a player slot. Returns the diagnostic error pink if
@@ -60,6 +61,13 @@ namespace TD.Core
/// The canonical color used for goal volumes. Not tied to any player.
public static Color Goal => GoalGold;
+ ///
+ /// 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.
+ ///
+ public static Color MapArea => MapAreaCyan;
+
/// Diagnostic color used when a volume has an invalid/missing owner.
public static Color Error => ErrorPink;
@@ -79,4 +87,4 @@ namespace TD.Core
return new Color(r / 255f, g / 255f, b / 255f, 1f);
}
}
-}
+}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
index 6b9dd6e..2c4dc05 100644
--- a/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
+++ b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs
@@ -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(includeInactive: false).ToList();
- ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
- ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
- ctx.GoalVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
+ ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
+ ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
+ ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
+ ctx.GoalVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
+ ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren(includeInactive: false).ToList();
ctx.AllVolumes = new List();
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(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>();
ctx.PerOwnerZoneTiles = new Dictionary>();
ctx.PerGoalTiles = new List>();
+ ctx.MapAreaTiles = new HashSet();
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 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 -------------------------------------------------
- ///
- /// Computes the authoring hash for a LevelAuthoring's scene state,
- /// suitable for comparing against
- /// to detect drift from baked data.
- ///
- ///
- /// 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.
- ///
- public static string ComputeAuthoringHash(LevelAuthoring authoring)
- {
+ ///
+ /// Computes the authoring hash for a LevelAuthoring's scene state,
+ /// suitable for comparing against
+ /// to detect drift from baked data.
+ ///
+ ///
+ /// 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.
+ ///
+ public static string ComputeAuthoringHash(LevelAuthoring authoring)
+ {
if (authoring == null)
return string.Empty;
@@ -1018,13 +1098,15 @@ namespace TD.Levels.Editor
var spawners = rootTransform.GetComponentsInChildren(includeInactive: false);
var leakExits = rootTransform.GetComponentsInChildren(includeInactive: false);
var goals = rootTransform.GetComponentsInChildren(includeInactive: false);
+ var mapAreas = rootTransform.GetComponentsInChildren(includeInactive: false);
ctx.AllVolumes = new System.Collections.Generic.List(
- 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 SpawnerVolumes;
public List LeakExitVolumes;
public List GoalVolumes;
+ public List MapAreaVolumes;
// Phase 2 outputs
public HashSet ExpectedOwners;
@@ -1281,6 +1365,7 @@ namespace TD.Levels.Editor
public Dictionary> PerVolumeTiles;
public Dictionary> PerOwnerZoneTiles;
public List> PerGoalTiles;
+ public HashSet 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 GoalAdjacentZones;
@@ -1297,4 +1383,4 @@ namespace TD.Levels.Editor
public LevelData AssembledData;
public string ThumbnailPath;
}
-}
+}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/GoldManager.cs b/Assets/_Project/Scripts/Gameplay/GoldManager.cs
deleted file mode 100644
index 393ec0f..0000000
--- a/Assets/_Project/Scripts/Gameplay/GoldManager.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-using Unity.Netcode;
-using UnityEngine;
-
-namespace TD.Gameplay
-{
- ///
- /// 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.
- ///
- 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 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 currentGold = new NetworkVariable(
- value: 0,
- readPerm: NetworkVariableReadPermission.Everyone,
- writePerm: NetworkVariableWritePermission.Server
- );
-
- // Public read-only accessor for other scripts (UI, tower placement).
- public int CurrentGold => currentGold.Value;
-
- // --- Lifecycle ----------------------------------------------------
-
- ///
- /// OnNetworkSpawn runs on every peer (server + all clients) when this
- /// NetworkBehaviour becomes active on the network. Replaces Start()
- /// for networked setup.
- ///
- 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) ----------------------
-
- ///
- /// 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.
- ///
- public void RequestSpendGold(int amount)
- {
- SpendGoldRpc(amount);
- }
-
- ///
- /// 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.
- ///
- 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;
- }
- }
-}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/GoldManager.cs.meta b/Assets/_Project/Scripts/Gameplay/GoldManager.cs.meta
deleted file mode 100644
index 53e1a6e..0000000
--- a/Assets/_Project/Scripts/Gameplay/GoldManager.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: d44ebdd0b2fc4144c8f8a181a714b738
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
index 01c6969..b515909 100644
--- a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
+++ b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
@@ -264,6 +264,23 @@ namespace TD.Gameplay
return x >= 0 && x < level.GridSize.x && y >= 0 && y < level.GridSize.y;
}
+ ///
+ /// True if 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.
+ ///
+ 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];
+ }
+
///
/// True if is currently walkable. Returns
/// false for out-of-bounds tiles. Reflects the runtime walkability
@@ -451,4 +468,4 @@ namespace TD.Gameplay
}
}
}
-}
+}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs
new file mode 100644
index 0000000..65dfca6
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs
@@ -0,0 +1,178 @@
+using System.Collections.Generic;
+using Unity.Netcode;
+using UnityEngine;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Per-player gold pool. One instance is spawned per connected client (via
+ /// ) 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.
+ ///
+ ///
+ /// 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 .
+ ///
+ /// This component is expected to live on the Player Prefab assigned to
+ /// NetworkManager. NGO handles spawn-on-connect and ownership assignment
+ /// automatically.
+ ///
+ 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 s_byClientId
+ = new Dictionary();
+
+ ///
+ /// Returns the PlayerGoldManager owned by the given client, or null if
+ /// none is currently spawned. Safe to call on server or client.
+ ///
+ public static PlayerGoldManager GetForClient(ulong clientId)
+ {
+ s_byClientId.TryGetValue(clientId, out var manager);
+ return manager;
+ }
+
+ ///
+ /// Convenience: the local client's own gold manager. Returns null on a
+ /// dedicated server or before the local player has spawned.
+ ///
+ 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 currentGold = new NetworkVariable(
+ 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 --------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ public void RequestSpendGold(int amount)
+ {
+ SpendGoldRpc(amount);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs.meta
new file mode 100644
index 0000000..53368a8
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6b9796562d7cc274f832657f21a61cce
\ No newline at end of file