diff --git a/.claude/worktrees/dazzling-hoover-3d45a2/.claude/settings.local.json b/.claude/worktrees/dazzling-hoover-3d45a2/.claude/settings.local.json
deleted file mode 100644
index 9a16201..0000000
--- a/.claude/worktrees/dazzling-hoover-3d45a2/.claude/settings.local.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(grep -E \"\\\\.\\(prefab|fbx|controller\\)$\")",
- "Bash(dir /s /b \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\*.md\")",
- "Bash(findstr /v \"Library\\\\PackageCache\")",
- "Bash(dir \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\" /a-d)",
- "Bash(mkdir -p \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\_Project\\\\UI\")",
- "Bash(mkdir -p \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\_Project\\\\Scripts\\\\UI\")",
- "Bash(mkdir -p \"C:/Users/catos/UnityTowerDefense/Assets/_Project/Scripts/UI/Minimap\")"
- ]
- }
-}
diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab
index 1a8c604..8a1fe80 100644
--- a/Assets/_Project/Prefabs/Player/Player.prefab
+++ b/Assets/_Project/Prefabs/Player/Player.prefab
@@ -12,6 +12,7 @@ GameObject:
- component: {fileID: 2152427255203126265}
- component: {fileID: 2918837822014987993}
- component: {fileID: 7845089877743661692}
+ - component: {fileID: 4336209376377567030}
m_Layer: 0
m_Name: Player
m_TagString: Untagged
@@ -87,3 +88,16 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerBuilderSpawner
ShowTopMostFoldoutHeaderGroup: 1
builderPrefab: {fileID: 116861493430507844, guid: 3398cc5831880954487717577f61b6d7, type: 3}
+--- !u!114 &4336209376377567030
+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: 4ce7a1bcbbd94474d9f5bc855c2394fc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerMatchState
+ ShowTopMostFoldoutHeaderGroup: 1
diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity
index e064af5..d4be64c 100644
--- a/Assets/_Project/Scenes/Levels/Main.unity
+++ b/Assets/_Project/Scenes/Levels/Main.unity
@@ -906,6 +906,77 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &902199259
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 902199262}
+ - component: {fileID: 902199260}
+ - component: {fileID: 902199261}
+ m_Layer: 0
+ m_Name: MatchState
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &902199260
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 902199259}
+ 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: 1302863789
+ 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 &902199261
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 902199259}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: a34585be59d49e94b811d96be51c5088, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.MatchState
+ ShowTopMostFoldoutHeaderGroup: 1
+--- !u!4 &902199262
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 902199259}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 19.73428, y: 12.20541, z: 11.3821}
+ 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 &923592498
GameObject:
m_ObjectHideFlags: 0
@@ -2231,3 +2302,4 @@ SceneRoots:
- {fileID: 1222526238}
- {fileID: 1058315976}
- {fileID: 1168515846}
+ - {fileID: 902199262}
diff --git a/Assets/_Project/Scripts/Core/Enums.cs b/Assets/_Project/Scripts/Core/Enums.cs
index 5f28068..d84c1b4 100644
--- a/Assets/_Project/Scripts/Core/Enums.cs
+++ b/Assets/_Project/Scripts/Core/Enums.cs
@@ -8,6 +8,38 @@ namespace TD.Core
/// Player1..Player9 cover the maximum supported player count. Maps using fewer players use a
/// contiguous prefix (e.g., a 3-player map uses Player1, Player2, Player3 only).
///
+ ///
+ /// Global phase of a match, driven by MatchState.
+ ///
+ ///
+ /// Transitions are server-authoritative. Clients react to
+ /// NetworkVariable<MatchPhase>.OnValueChanged.
+ ///
+ public enum MatchPhase : byte
+ {
+ /// Pre-match; players are connecting and the server hasn't started the countdown.
+ Lobby = 0,
+ /// Brief count-down before waves begin. Placement is still allowed.
+ CountDown = 1,
+ /// Waves are in progress; normal gameplay.
+ Playing = 2,
+ /// All waves cleared; co-op win state.
+ Victory = 3,
+ /// All lives lost; co-op defeat state.
+ Defeat = 4,
+ }
+
+ ///
+ /// Identifies the race a player has chosen in the race-pick phase.
+ /// Backed by byte. Specific race values are defined in Phase 1.8.
+ ///
+ public enum RaceId : byte
+ {
+ /// No race selected yet (lobby / pre-pick).
+ None = 0,
+ // Race entries added in Phase 1.8.
+ }
+
public enum PlayerSlot : byte
{
None = 0,
diff --git a/Assets/_Project/Scripts/Gameplay/Builder.cs b/Assets/_Project/Scripts/Gameplay/Builder.cs
index 5975391..00421d0 100644
--- a/Assets/_Project/Scripts/Gameplay/Builder.cs
+++ b/Assets/_Project/Scripts/Gameplay/Builder.cs
@@ -268,23 +268,13 @@ namespace TD.Gameplay
// ----- IMinimapEntity ---------------------------------------------
//
- // Read every minimap refresh tick. Position is read live (NetworkTransform
- // interpolation handles remote-client smoothing). Color comes from the same
- // OwnerClientId → PlayerSlot stub mapping used by ApplyOwnerColor; both will pick
- // up the real mapping when MatchState lands.
+ // Position is read live (NetworkTransform interpolation handles remote-client smoothing).
+ // Color comes from PlayerMatchState slot assignment.
Vector3 IMinimapEntity.WorldPosition => transform.position;
Color IMinimapEntity.MinimapColor
- {
- get
- {
- byte slotByte = (byte)(OwnerClientId + 1);
- PlayerSlot slot = (slotByte >= 1 && slotByte <= 9)
- ? (PlayerSlot)slotByte : PlayerSlot.None;
- return PlayerColors.Get(slot);
- }
- }
+ => PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Builder;
@@ -305,12 +295,7 @@ namespace TD.Gameplay
private void ApplyOwnerColor()
{
- // Owner color comes from the slot mapping. Same stub mapping as elsewhere —
- // replaced when MatchState lands.
- byte slotByte = (byte)(OwnerClientId + 1);
- PlayerSlot slot = (slotByte >= 1 && slotByte <= 9) ? (PlayerSlot)slotByte : PlayerSlot.None;
-
- Color c = PlayerColors.Get(slot);
+ Color c = PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
c.a = 1f;
colorPropertyBlock ??= new MaterialPropertyBlock();
@@ -560,12 +545,7 @@ namespace TD.Gameplay
return;
}
- // Owner slot: same stub mapping as elsewhere.
- byte slotByte = (byte)(OwnerClientId + 1);
- PlayerSlot owner = (slotByte >= 1 && slotByte <= 9)
- ? (PlayerSlot)slotByte
- : PlayerSlot.None;
-
+ PlayerSlot owner = PlayerMatchState.SlotForClient(OwnerClientId);
visual.InitializeServer(def, owner, job.Anchor, job.TowerTypeId, job.GoldSpent);
var netObj = go.GetComponent();
@@ -1078,12 +1058,7 @@ namespace TD.Gameplay
// ----- Helpers ----------------------------------------------------
private static PlayerSlot OwnerToSlot(ulong clientId)
- {
- // STUB — replaced when MatchState lands. Same mapping as elsewhere.
- byte slotByte = (byte)(clientId + 1);
- if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
- return (PlayerSlot)slotByte;
- }
+ => PlayerMatchState.SlotForClient(clientId);
private static Vector2Int ResolveFootprint(int towerTypeId)
{
diff --git a/Assets/_Project/Scripts/Gameplay/CameraController.cs b/Assets/_Project/Scripts/Gameplay/CameraController.cs
index c3ecec4..d9c6197 100644
--- a/Assets/_Project/Scripts/Gameplay/CameraController.cs
+++ b/Assets/_Project/Scripts/Gameplay/CameraController.cs
@@ -498,19 +498,7 @@ namespace TD.Gameplay
// ----- Player slot ------------------------------------------------
- ///
- /// Returns the local player's PlayerSlot.
- /// STUB: same trivial mapping used elsewhere; replaced when MatchState lands.
- ///
private static PlayerSlot GetLocalPlayerSlot()
- {
- var nm = Unity.Netcode.NetworkManager.Singleton;
- if (nm == null || !nm.IsClient) return PlayerSlot.None;
-
- ulong clientId = nm.LocalClientId;
- byte slotByte = (byte)(clientId + 1);
- if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
- return (PlayerSlot)slotByte;
- }
+ => PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
}
}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/MatchState.cs b/Assets/_Project/Scripts/Gameplay/MatchState.cs
new file mode 100644
index 0000000..92ffbdc
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/MatchState.cs
@@ -0,0 +1,138 @@
+// Assets/_Project/Scripts/Gameplay/MatchState.cs
+using Unity.Netcode;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Singleton NetworkBehaviour that tracks global match data.
+ /// Lives on a persistent scene object.
+ ///
+ /// All state is server-authoritative (Server-write NetworkVariables).
+ /// Clients react to OnValueChanged events on individual fields.
+ /// Phase transitions (Lobby → CountDown → Playing → Victory/Defeat)
+ /// and wave/lives logic are wired in Phases 1.4–1.6 and 1.8.
+ ///
+ public class MatchState : NetworkBehaviour
+ {
+ // --- Singleton ---------------------------------------------------
+
+ public static MatchState Instance { get; private set; }
+
+ // --- Networked state ---------------------------------------------
+
+ private readonly NetworkVariable phase = new NetworkVariable(
+ value: MatchPhase.Playing,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ /// Current match phase. Authoritative on the server, replicated to all clients.
+ public MatchPhase Phase => phase.Value;
+
+ // STUBBED — written by the wave system (Phase 1.5/1.6). Zero until waves begin.
+ [Tooltip("STUBBED — not written until the wave system lands (Phase 1.5/1.6).")]
+ private readonly NetworkVariable currentWave = new NetworkVariable(
+ value: 0,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ /// STUBBED. Current wave index. Zero until the wave system (Phase 1.5/1.6) writes it.
+ public int CurrentWave => currentWave.Value;
+
+ // STUBBED — written by the combat/leak system (Phase 1.4). Zero until then.
+ [Tooltip("STUBBED — not written until the combat/leak system lands (Phase 1.4).")]
+ private readonly NetworkVariable lives = new NetworkVariable(
+ value: 0,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ /// STUBBED. Shared lives pool. Zero until the combat/leak system (Phase 1.4) writes it.
+ public int Lives => lives.Value;
+
+ // STUBBED — driven by the race-pick UI (Phase 1.8). Zero when no pick is in progress.
+ [Tooltip("STUBBED — not driven until the race-pick system lands (Phase 1.8).")]
+ private readonly NetworkVariable racePickTimer = new NetworkVariable(
+ value: 0f,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ /// STUBBED. Seconds remaining in the race-pick countdown. Zero when no pick is in progress.
+ public float RacePickTimer => racePickTimer.Value;
+
+ ///
+ /// Fired on every peer (server and clients) when the phase changes.
+ /// Subscribe to react to phase transitions without polling.
+ ///
+ public event System.Action OnPhaseChanged;
+
+ // --- Lifecycle ---------------------------------------------------
+
+ public override void OnNetworkSpawn()
+ {
+ if (Instance != null && Instance != this)
+ {
+ Debug.LogError("[MatchState] Duplicate MatchState detected — only one may exist per scene. " +
+ "Despawning the duplicate.");
+ NetworkObject.Despawn();
+ return;
+ }
+
+ Instance = this;
+ phase.OnValueChanged += HandlePhaseChanged;
+
+ Debug.Log($"[MatchState] Spawned. Phase={phase.Value}");
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ phase.OnValueChanged -= HandlePhaseChanged;
+ if (Instance == this) Instance = null;
+ }
+
+ // --- Server API --------------------------------------------------
+
+ ///
+ /// Transitions to a new phase. Server-only; no-op on clients with a log warning.
+ ///
+ public void SetPhase(MatchPhase next)
+ {
+ if (!IsServer) { Debug.LogWarning("[MatchState] SetPhase called on a client — ignored."); return; }
+ if (phase.Value == next) return;
+ phase.Value = next;
+ }
+
+ /// STUBBED. Sets the current wave index. Called by the wave system (Phase 1.5/1.6).
+ public void SetCurrentWave(int wave)
+ {
+ if (!IsServer) { Debug.LogWarning("[MatchState] SetCurrentWave called on a client — ignored."); return; }
+ currentWave.Value = wave;
+ }
+
+ /// STUBBED. Sets the shared lives pool. Called by the combat/leak system (Phase 1.4).
+ public void SetLives(int value)
+ {
+ if (!IsServer) { Debug.LogWarning("[MatchState] SetLives called on a client — ignored."); return; }
+ lives.Value = Mathf.Max(0, value);
+ }
+
+ /// STUBBED. Sets the race-pick countdown timer. Called by the race-pick system (Phase 1.8).
+ public void SetRacePickTimer(float seconds)
+ {
+ if (!IsServer) { Debug.LogWarning("[MatchState] SetRacePickTimer called on a client — ignored."); return; }
+ racePickTimer.Value = Mathf.Max(0f, seconds);
+ }
+
+ // --- Handlers ----------------------------------------------------
+
+ private void HandlePhaseChanged(MatchPhase previous, MatchPhase current)
+ {
+ Debug.Log($"[MatchState] Phase: {previous} → {current}");
+ OnPhaseChanged?.Invoke(previous, current);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/MatchState.cs.meta b/Assets/_Project/Scripts/Gameplay/MatchState.cs.meta
new file mode 100644
index 0000000..bb51f57
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/MatchState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a34585be59d49e94b811d96be51c5088
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs b/Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs
index 6e3a0fa..f204146 100644
--- a/Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs
+++ b/Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs
@@ -45,7 +45,27 @@ namespace TD.Gameplay
return;
}
- SpawnBuilderForOwner();
+ var pms = GetComponent();
+ if (pms == null)
+ {
+ Debug.LogError("[PlayerBuilderSpawner] PlayerMatchState not found on Player Prefab. " +
+ "Add it as a sibling component.");
+ return;
+ }
+
+ // PlayerMatchState.OnNetworkSpawn may have already fired (component order: it first)
+ // or may fire after us (component order: we first). Handle both cases.
+ if (pms.Slot != PlayerSlot.None)
+ SpawnBuilderForOwner(pms.Slot);
+ else
+ pms.SlotReady += OnOwnerSlotReady;
+ }
+
+ private void OnOwnerSlotReady(PlayerSlot slot)
+ {
+ var pms = GetComponent();
+ if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
+ SpawnBuilderForOwner(slot);
}
public override void OnNetworkDespawn()
@@ -58,11 +78,11 @@ namespace TD.Gameplay
spawnedBuilder = null;
}
- private void SpawnBuilderForOwner()
+ private void SpawnBuilderForOwner(PlayerSlot slot)
{
// Compute initial position: centroid of this player's zone.
// Falls back to origin if loader/zone data isn't available.
- Vector3 spawnPos = ComputeZoneCentroid(OwnerToSlot(OwnerClientId));
+ Vector3 spawnPos = ComputeZoneCentroid(slot);
var go = Instantiate(builderPrefab, spawnPos, Quaternion.identity);
var netObj = go.GetComponent();
@@ -94,17 +114,6 @@ namespace TD.Gameplay
// ----- Helpers ----------------------------------------------------
- ///
- /// Stub mapping: client 0 = Player1, client 1 = Player2, etc.
- /// Replaced by MatchState's authoritative assignment when that lands.
- ///
- private static PlayerSlot OwnerToSlot(ulong clientId)
- {
- byte slotByte = (byte)(clientId + 1);
- if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
- return (PlayerSlot)slotByte;
- }
-
private static Vector3 ComputeZoneCentroid(PlayerSlot slot)
{
var loader = LevelLoader.Instance;
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
new file mode 100644
index 0000000..3507d31
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
@@ -0,0 +1,153 @@
+// Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
+using System.Collections.Generic;
+using Unity.Collections;
+using Unity.Netcode;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Per-player match state. One instance is spawned per connected client (via
+ /// ) and owned by that client.
+ ///
+ /// Mirrors the pattern: static registry keyed by
+ /// OwnerClientId, server-authoritative NetworkVariables, and a
+ /// Local convenience accessor.
+ ///
+ /// The server assigns a on spawn using the next free slot
+ /// (Player1..Player9 in connect order). All five former STUB mappings
+ /// (Builder, TowerPlacementManager, TowerPlacementController, CameraController,
+ /// PlayerBuilderSpawner) delegate here instead.
+ ///
+ public class PlayerMatchState : NetworkBehaviour
+ {
+ // --- Static registry ---------------------------------------------
+
+ private static readonly Dictionary s_byClientId
+ = new Dictionary();
+
+ // Tracks which slots are currently occupied so the server can assign the next free one.
+ private static readonly HashSet s_assignedSlots = new HashSet();
+
+ ///
+ /// Returns the for the given client, or null if not spawned.
+ /// Safe to call on server or client.
+ ///
+ public static PlayerMatchState GetForClient(ulong clientId)
+ {
+ s_byClientId.TryGetValue(clientId, out var state);
+ return state;
+ }
+
+ ///
+ /// Returns the for the given client, or
+ /// if the client has no spawned .
+ /// Convenience wrapper over .
+ ///
+ public static PlayerSlot SlotForClient(ulong clientId)
+ => GetForClient(clientId)?.Slot ?? PlayerSlot.None;
+
+ ///
+ /// The local client's own state. Null on a dedicated server or before the
+ /// local player has spawned.
+ ///
+ public static PlayerMatchState Local
+ {
+ get
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null || !nm.IsClient) return null;
+ return GetForClient(nm.LocalClientId);
+ }
+ }
+
+ // --- Networked state ---------------------------------------------
+
+ private readonly NetworkVariable slot = new NetworkVariable(
+ value: PlayerSlot.None,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ private readonly NetworkVariable displayName = new NetworkVariable(
+ value: default,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ // STUBBED — written by the race-pick system (Phase 1.8). None until a race is chosen.
+ [Tooltip("STUBBED — not written until the race-pick system lands (Phase 1.8).")]
+ private readonly NetworkVariable raceSelection = new NetworkVariable(
+ value: RaceId.None,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server
+ );
+
+ /// This player's assigned slot in the match. Authoritative once spawned.
+ public PlayerSlot Slot => slot.Value;
+
+ /// Display name. Stub until a lobby/name system provides it.
+ public string DisplayName => displayName.Value.ToString();
+
+ /// STUBBED. Race chosen by this player. until Phase 1.8.
+ public RaceId RaceSelection => raceSelection.Value;
+
+ /// STUBBED. Sets this player's race selection. Called by the race-pick system (Phase 1.8).
+ public void SetRaceSelection(RaceId race)
+ {
+ if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetRaceSelection called on a client — ignored."); return; }
+ raceSelection.Value = race;
+ }
+
+ ///
+ /// Server-only. Fires on the server immediately after the slot is assigned in
+ /// . Sibling components (e.g. PlayerBuilderSpawner)
+ /// subscribe to defer work that requires a valid slot.
+ ///
+ public event System.Action SlotReady;
+
+ // --- Lifecycle ---------------------------------------------------
+
+ public override void OnNetworkSpawn()
+ {
+ s_byClientId[OwnerClientId] = this;
+
+ if (IsServer)
+ {
+ PlayerSlot assigned = AllocateNextSlot();
+ slot.Value = assigned;
+ displayName.Value = new FixedString32Bytes($"Player {(int)assigned}");
+ s_assignedSlots.Add(assigned);
+
+ Debug.Log($"[PlayerMatchState] Assigned slot {assigned} to client {OwnerClientId}.");
+ SlotReady?.Invoke(assigned);
+ }
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
+ s_byClientId.Remove(OwnerClientId);
+
+ if (IsServer)
+ s_assignedSlots.Remove(slot.Value);
+ }
+
+ // --- Slot allocation (server-only) -------------------------------
+
+ private static PlayerSlot AllocateNextSlot()
+ {
+ for (int i = 1; i <= 9; i++)
+ {
+ var candidate = (PlayerSlot)i;
+ if (!s_assignedSlots.Contains(candidate))
+ return candidate;
+ }
+
+ Debug.LogError("[PlayerMatchState] No free PlayerSlot (already 9 players). " +
+ "Returning None — this client will be unable to play.");
+ return PlayerSlot.None;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs.meta
new file mode 100644
index 0000000..0e9f2a3
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4ce7a1bcbbd94474d9f5bc855c2394fc
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
index 73d81b4..4e29d24 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
@@ -400,21 +400,7 @@ namespace TD.Gameplay
// ----- Player slot ------------------------------------------------
- ///
- /// Returns the local player's PlayerSlot.
- /// STUB: Uses the same trivial client-ID → slot mapping as
- /// TowerPlacementManager.ClientIdToPlayerSlot. Will be replaced
- /// when MatchState carries the authoritative assignment.
- ///
private static PlayerSlot GetLocalPlayerSlot()
- {
- var nm = Unity.Netcode.NetworkManager.Singleton;
- if (nm == null || !nm.IsClient) return PlayerSlot.None;
-
- ulong clientId = nm.LocalClientId;
- byte slotByte = (byte)(clientId + 1);
- if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
- return (PlayerSlot)slotByte;
- }
+ => PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
}
}
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs
index 82b6a6f..b2f51ce 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs
@@ -610,20 +610,8 @@ namespace TD.Gameplay
}
}
- ///
- /// Maps a client ID to the PlayerSlot assigned to that client.
- ///
- ///
- /// STUB: Currently uses a trivial mapping where client 0 = Player1, client 1 = Player2,
- /// etc. This will be replaced when MatchState / PlayerMatchState is implemented and
- /// carries the authoritative client-to-slot assignment.
- ///
private static PlayerSlot ClientIdToPlayerSlot(ulong clientId)
- {
- byte slotByte = (byte)(clientId + 1);
- if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
- return (PlayerSlot)slotByte;
- }
+ => PlayerMatchState.SlotForClient(clientId);
private void Reject(PlacementRequest req, PlacementRejectionReason reason)
{