From abcefcd7f1234b6d94ff0f6a90a9754cd6e53271 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 12 May 2026 10:31:23 -0700 Subject: [PATCH] Adding Match State controller --- .../.claude/settings.local.json | 13 -- Assets/_Project/Prefabs/Player/Player.prefab | 14 ++ Assets/_Project/Scenes/Levels/Main.unity | 72 +++++++++ Assets/_Project/Scripts/Core/Enums.cs | 32 ++++ Assets/_Project/Scripts/Gameplay/Builder.cs | 37 +---- .../Scripts/Gameplay/CameraController.cs | 14 +- .../_Project/Scripts/Gameplay/MatchState.cs | 138 ++++++++++++++++ .../Scripts/Gameplay/MatchState.cs.meta | 2 + .../Scripts/Gameplay/PlayerBuilderSpawner.cs | 37 +++-- .../Scripts/Gameplay/PlayerMatchState.cs | 153 ++++++++++++++++++ .../Scripts/Gameplay/PlayerMatchState.cs.meta | 2 + .../Gameplay/TowerPlacementController.cs | 16 +- .../Scripts/Gameplay/TowerPlacementManager.cs | 14 +- 13 files changed, 445 insertions(+), 99 deletions(-) delete mode 100644 .claude/worktrees/dazzling-hoover-3d45a2/.claude/settings.local.json create mode 100644 Assets/_Project/Scripts/Gameplay/MatchState.cs create mode 100644 Assets/_Project/Scripts/Gameplay/MatchState.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs.meta 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) {