Adding Match State controller

This commit is contained in:
Matt F 2026-05-12 10:31:23 -07:00
parent c100db52e5
commit abcefcd7f1
13 changed files with 445 additions and 99 deletions

View file

@ -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\")"
]
}
}

View file

@ -12,6 +12,7 @@ GameObject:
- component: {fileID: 2152427255203126265} - component: {fileID: 2152427255203126265}
- component: {fileID: 2918837822014987993} - component: {fileID: 2918837822014987993}
- component: {fileID: 7845089877743661692} - component: {fileID: 7845089877743661692}
- component: {fileID: 4336209376377567030}
m_Layer: 0 m_Layer: 0
m_Name: Player m_Name: Player
m_TagString: Untagged m_TagString: Untagged
@ -87,3 +88,16 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerBuilderSpawner m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerBuilderSpawner
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
builderPrefab: {fileID: 116861493430507844, guid: 3398cc5831880954487717577f61b6d7, type: 3} 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

View file

@ -906,6 +906,77 @@ Transform:
m_Children: [] m_Children: []
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 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 --- !u!1 &923592498
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -2231,3 +2302,4 @@ SceneRoots:
- {fileID: 1222526238} - {fileID: 1222526238}
- {fileID: 1058315976} - {fileID: 1058315976}
- {fileID: 1168515846} - {fileID: 1168515846}
- {fileID: 902199262}

View file

@ -8,6 +8,38 @@ namespace TD.Core
/// Player1..Player9 cover the maximum supported player count. Maps using fewer players use a /// 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). /// contiguous prefix (e.g., a 3-player map uses Player1, Player2, Player3 only).
/// </remarks> /// </remarks>
/// <summary>
/// Global phase of a match, driven by <c>MatchState</c>.
/// </summary>
/// <remarks>
/// Transitions are server-authoritative. Clients react to
/// <c>NetworkVariable&lt;MatchPhase&gt;.OnValueChanged</c>.
/// </remarks>
public enum MatchPhase : byte
{
/// <summary>Pre-match; players are connecting and the server hasn't started the countdown.</summary>
Lobby = 0,
/// <summary>Brief count-down before waves begin. Placement is still allowed.</summary>
CountDown = 1,
/// <summary>Waves are in progress; normal gameplay.</summary>
Playing = 2,
/// <summary>All waves cleared; co-op win state.</summary>
Victory = 3,
/// <summary>All lives lost; co-op defeat state.</summary>
Defeat = 4,
}
/// <summary>
/// Identifies the race a player has chosen in the race-pick phase.
/// Backed by byte. Specific race values are defined in Phase 1.8.
/// </summary>
public enum RaceId : byte
{
/// <summary>No race selected yet (lobby / pre-pick).</summary>
None = 0,
// Race entries added in Phase 1.8.
}
public enum PlayerSlot : byte public enum PlayerSlot : byte
{ {
None = 0, None = 0,

View file

@ -268,23 +268,13 @@ namespace TD.Gameplay
// ----- IMinimapEntity --------------------------------------------- // ----- IMinimapEntity ---------------------------------------------
// //
// Read every minimap refresh tick. Position is read live (NetworkTransform // Position is read live (NetworkTransform interpolation handles remote-client smoothing).
// interpolation handles remote-client smoothing). Color comes from the same // Color comes from PlayerMatchState slot assignment.
// OwnerClientId → PlayerSlot stub mapping used by ApplyOwnerColor; both will pick
// up the real mapping when MatchState lands.
Vector3 IMinimapEntity.WorldPosition => transform.position; Vector3 IMinimapEntity.WorldPosition => transform.position;
Color IMinimapEntity.MinimapColor Color IMinimapEntity.MinimapColor
{ => PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
get
{
byte slotByte = (byte)(OwnerClientId + 1);
PlayerSlot slot = (slotByte >= 1 && slotByte <= 9)
? (PlayerSlot)slotByte : PlayerSlot.None;
return PlayerColors.Get(slot);
}
}
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Builder; MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Builder;
@ -305,12 +295,7 @@ namespace TD.Gameplay
private void ApplyOwnerColor() private void ApplyOwnerColor()
{ {
// Owner color comes from the slot mapping. Same stub mapping as elsewhere — Color c = PlayerColors.Get(PlayerMatchState.SlotForClient(OwnerClientId));
// replaced when MatchState lands.
byte slotByte = (byte)(OwnerClientId + 1);
PlayerSlot slot = (slotByte >= 1 && slotByte <= 9) ? (PlayerSlot)slotByte : PlayerSlot.None;
Color c = PlayerColors.Get(slot);
c.a = 1f; c.a = 1f;
colorPropertyBlock ??= new MaterialPropertyBlock(); colorPropertyBlock ??= new MaterialPropertyBlock();
@ -560,12 +545,7 @@ namespace TD.Gameplay
return; return;
} }
// Owner slot: same stub mapping as elsewhere. PlayerSlot owner = PlayerMatchState.SlotForClient(OwnerClientId);
byte slotByte = (byte)(OwnerClientId + 1);
PlayerSlot owner = (slotByte >= 1 && slotByte <= 9)
? (PlayerSlot)slotByte
: PlayerSlot.None;
visual.InitializeServer(def, owner, job.Anchor, job.TowerTypeId, job.GoldSpent); visual.InitializeServer(def, owner, job.Anchor, job.TowerTypeId, job.GoldSpent);
var netObj = go.GetComponent<NetworkObject>(); var netObj = go.GetComponent<NetworkObject>();
@ -1078,12 +1058,7 @@ namespace TD.Gameplay
// ----- Helpers ---------------------------------------------------- // ----- Helpers ----------------------------------------------------
private static PlayerSlot OwnerToSlot(ulong clientId) private static PlayerSlot OwnerToSlot(ulong clientId)
{ => PlayerMatchState.SlotForClient(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;
}
private static Vector2Int ResolveFootprint(int towerTypeId) private static Vector2Int ResolveFootprint(int towerTypeId)
{ {

View file

@ -498,19 +498,7 @@ namespace TD.Gameplay
// ----- Player slot ------------------------------------------------ // ----- Player slot ------------------------------------------------
/// <summary>
/// Returns the local player's PlayerSlot.
/// STUB: same trivial mapping used elsewhere; replaced when MatchState lands.
/// </summary>
private static PlayerSlot GetLocalPlayerSlot() private static PlayerSlot GetLocalPlayerSlot()
{ => PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
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;
}
} }
} }

View file

@ -0,0 +1,138 @@
// Assets/_Project/Scripts/Gameplay/MatchState.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// 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.41.6 and 1.8.
/// </summary>
public class MatchState : NetworkBehaviour
{
// --- Singleton ---------------------------------------------------
public static MatchState Instance { get; private set; }
// --- Networked state ---------------------------------------------
private readonly NetworkVariable<MatchPhase> phase = new NetworkVariable<MatchPhase>(
value: MatchPhase.Playing,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
/// <summary>Current match phase. Authoritative on the server, replicated to all clients.</summary>
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<int> currentWave = new NetworkVariable<int>(
value: 0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
/// <summary>STUBBED. Current wave index. Zero until the wave system (Phase 1.5/1.6) writes it.</summary>
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<int> lives = new NetworkVariable<int>(
value: 0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
/// <summary>STUBBED. Shared lives pool. Zero until the combat/leak system (Phase 1.4) writes it.</summary>
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<float> racePickTimer = new NetworkVariable<float>(
value: 0f,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
/// <summary>STUBBED. Seconds remaining in the race-pick countdown. Zero when no pick is in progress.</summary>
public float RacePickTimer => racePickTimer.Value;
/// <summary>
/// Fired on every peer (server and clients) when the phase changes.
/// Subscribe to react to phase transitions without polling.
/// </summary>
public event System.Action<MatchPhase, MatchPhase> 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 --------------------------------------------------
/// <summary>
/// Transitions to a new phase. Server-only; no-op on clients with a log warning.
/// </summary>
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;
}
/// <summary>STUBBED. Sets the current wave index. Called by the wave system (Phase 1.5/1.6).</summary>
public void SetCurrentWave(int wave)
{
if (!IsServer) { Debug.LogWarning("[MatchState] SetCurrentWave called on a client — ignored."); return; }
currentWave.Value = wave;
}
/// <summary>STUBBED. Sets the shared lives pool. Called by the combat/leak system (Phase 1.4).</summary>
public void SetLives(int value)
{
if (!IsServer) { Debug.LogWarning("[MatchState] SetLives called on a client — ignored."); return; }
lives.Value = Mathf.Max(0, value);
}
/// <summary>STUBBED. Sets the race-pick countdown timer. Called by the race-pick system (Phase 1.8).</summary>
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);
}
}
}

View file

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

View file

@ -45,7 +45,27 @@ namespace TD.Gameplay
return; return;
} }
SpawnBuilderForOwner(); var pms = GetComponent<PlayerMatchState>();
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<PlayerMatchState>();
if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
SpawnBuilderForOwner(slot);
} }
public override void OnNetworkDespawn() public override void OnNetworkDespawn()
@ -58,11 +78,11 @@ namespace TD.Gameplay
spawnedBuilder = null; spawnedBuilder = null;
} }
private void SpawnBuilderForOwner() private void SpawnBuilderForOwner(PlayerSlot slot)
{ {
// Compute initial position: centroid of this player's zone. // Compute initial position: centroid of this player's zone.
// Falls back to origin if loader/zone data isn't available. // 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 go = Instantiate(builderPrefab, spawnPos, Quaternion.identity);
var netObj = go.GetComponent<NetworkObject>(); var netObj = go.GetComponent<NetworkObject>();
@ -94,17 +114,6 @@ namespace TD.Gameplay
// ----- Helpers ---------------------------------------------------- // ----- Helpers ----------------------------------------------------
/// <summary>
/// Stub mapping: client 0 = Player1, client 1 = Player2, etc.
/// Replaced by MatchState's authoritative assignment when that lands.
/// </summary>
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) private static Vector3 ComputeZoneCentroid(PlayerSlot slot)
{ {
var loader = LevelLoader.Instance; var loader = LevelLoader.Instance;

View file

@ -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
{
/// <summary>
/// Per-player match state. One instance is spawned per connected client (via
/// <see cref="NetworkManager.PlayerPrefab"/>) and owned by that client.
///
/// Mirrors the <see cref="PlayerGoldManager"/> pattern: static registry keyed by
/// <c>OwnerClientId</c>, server-authoritative <c>NetworkVariable</c>s, and a
/// <c>Local</c> convenience accessor.
///
/// The server assigns a <see cref="PlayerSlot"/> 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.
/// </summary>
public class PlayerMatchState : NetworkBehaviour
{
// --- Static registry ---------------------------------------------
private static readonly Dictionary<ulong, PlayerMatchState> s_byClientId
= new Dictionary<ulong, PlayerMatchState>();
// Tracks which slots are currently occupied so the server can assign the next free one.
private static readonly HashSet<PlayerSlot> s_assignedSlots = new HashSet<PlayerSlot>();
/// <summary>
/// Returns the <see cref="PlayerMatchState"/> for the given client, or null if not spawned.
/// Safe to call on server or client.
/// </summary>
public static PlayerMatchState GetForClient(ulong clientId)
{
s_byClientId.TryGetValue(clientId, out var state);
return state;
}
/// <summary>
/// Returns the <see cref="PlayerSlot"/> for the given client, or <see cref="PlayerSlot.None"/>
/// if the client has no spawned <see cref="PlayerMatchState"/>.
/// Convenience wrapper over <see cref="GetForClient"/>.
/// </summary>
public static PlayerSlot SlotForClient(ulong clientId)
=> GetForClient(clientId)?.Slot ?? PlayerSlot.None;
/// <summary>
/// The local client's own state. Null on a dedicated server or before the
/// local player has spawned.
/// </summary>
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<PlayerSlot> slot = new NetworkVariable<PlayerSlot>(
value: PlayerSlot.None,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
private readonly NetworkVariable<FixedString32Bytes> displayName = new NetworkVariable<FixedString32Bytes>(
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<RaceId> raceSelection = new NetworkVariable<RaceId>(
value: RaceId.None,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
/// <summary>This player's assigned slot in the match. Authoritative once spawned.</summary>
public PlayerSlot Slot => slot.Value;
/// <summary>Display name. Stub until a lobby/name system provides it.</summary>
public string DisplayName => displayName.Value.ToString();
/// <summary>STUBBED. Race chosen by this player. <see cref="RaceId.None"/> until Phase 1.8.</summary>
public RaceId RaceSelection => raceSelection.Value;
/// <summary>STUBBED. Sets this player's race selection. Called by the race-pick system (Phase 1.8).</summary>
public void SetRaceSelection(RaceId race)
{
if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetRaceSelection called on a client — ignored."); return; }
raceSelection.Value = race;
}
/// <summary>
/// Server-only. Fires on the server immediately after the slot is assigned in
/// <see cref="OnNetworkSpawn"/>. Sibling components (e.g. PlayerBuilderSpawner)
/// subscribe to defer work that requires a valid slot.
/// </summary>
public event System.Action<PlayerSlot> 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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ce7a1bcbbd94474d9f5bc855c2394fc

View file

@ -400,21 +400,7 @@ namespace TD.Gameplay
// ----- Player slot ------------------------------------------------ // ----- Player slot ------------------------------------------------
/// <summary>
/// Returns the local player's PlayerSlot.
/// STUB: Uses the same trivial client-ID → slot mapping as
/// <c>TowerPlacementManager.ClientIdToPlayerSlot</c>. Will be replaced
/// when MatchState carries the authoritative assignment.
/// </summary>
private static PlayerSlot GetLocalPlayerSlot() private static PlayerSlot GetLocalPlayerSlot()
{ => PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
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;
}
} }
} }

View file

@ -610,20 +610,8 @@ namespace TD.Gameplay
} }
} }
/// <summary>
/// Maps a client ID to the PlayerSlot assigned to that client.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static PlayerSlot ClientIdToPlayerSlot(ulong clientId) private static PlayerSlot ClientIdToPlayerSlot(ulong clientId)
{ => PlayerMatchState.SlotForClient(clientId);
byte slotByte = (byte)(clientId + 1);
if (slotByte < 1 || slotByte > 9) return PlayerSlot.None;
return (PlayerSlot)slotByte;
}
private void Reject(PlacementRequest req, PlacementRejectionReason reason) private void Reject(PlacementRequest req, PlacementRejectionReason reason)
{ {