Restructures the game around the cyclical run loop from Game Design Doc V2: 5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss. - New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the run as draggable weighted pools; RunState owns phase/cycle position, the drawn wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is gone -- it now only runs the encounter RunState points at. - New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public live-replicated ballots so the HUD can show who voted for what. - Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage ending early once every player has acted. - Enemy abilities inverted from per-instance random rolls to deterministic, stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink, No Bounty, Gold Theft, Double Up. - Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts an already-placed tower in place. - Boss encounters flag their enemies and drive a boss HP bar. - Player cap reduced to 3 via MatchRules.MaxPlayers. - GoldConfig is now keyed by global encounter number rather than wave index. Compiles clean; NOT yet verified in-engine. Editor wiring still required -- see Docs/2.0_Setup_Checklist.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
9.7 KiB
C#
231 lines
9.7 KiB
C#
// 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>
|
|
/// Returns the <see cref="PlayerMatchState"/> whose assigned slot matches
|
|
/// <paramref name="slot"/>, or null if no connected client holds that slot.
|
|
/// O(n) over connected players (max 9) — acceptable for server-side use.
|
|
/// Used by <c>WaveManager</c> to resolve kill-gold recipients.
|
|
/// </summary>
|
|
public static PlayerMatchState GetForSlot(PlayerSlot slot)
|
|
{
|
|
foreach (var pms in s_byClientId.Values)
|
|
if (pms.Slot == slot) return pms;
|
|
return null;
|
|
}
|
|
|
|
/// <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
|
|
);
|
|
|
|
// Race chosen by this player. Written by the lobby system in response to
|
|
// SetRaceRpc; carries over scene transitions because PlayerMatchState is
|
|
// the player prefab (owned by the connection, persists across scenes).
|
|
private readonly NetworkVariable<RaceId> raceSelection = new NetworkVariable<RaceId>(
|
|
value: RaceId.None,
|
|
readPerm: NetworkVariableReadPermission.Everyone,
|
|
writePerm: NetworkVariableWritePermission.Server
|
|
);
|
|
|
|
// Lobby ready-state. Cleared back to false on scene transitions out of
|
|
// the lobby (the new lobby session starts everyone un-ready).
|
|
private readonly NetworkVariable<bool> isReady = new NetworkVariable<bool>(
|
|
value: false,
|
|
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>Race chosen by this player. <see cref="RaceId.None"/> until selected in the lobby.</summary>
|
|
public RaceId RaceSelection => raceSelection.Value;
|
|
|
|
/// <summary>True if this player has marked themselves ready in the lobby.</summary>
|
|
public bool IsReady => isReady.Value;
|
|
|
|
/// <summary>Server-only. Sets this player's race selection.</summary>
|
|
public void SetRaceSelection(RaceId race)
|
|
{
|
|
if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetRaceSelection called on a client — ignored."); return; }
|
|
raceSelection.Value = race;
|
|
}
|
|
|
|
/// <summary>Server-only. Sets this player's ready state.</summary>
|
|
public void SetReady(bool ready)
|
|
{
|
|
if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetReady called on a client — ignored."); return; }
|
|
isReady.Value = ready;
|
|
}
|
|
|
|
// ----- Client → Server lobby RPCs --------------------------------
|
|
|
|
/// <summary>
|
|
/// Client → server: submit a race selection. Owner-only — players can
|
|
/// only change their own race. Validates that the caller is the owner
|
|
/// of this PlayerMatchState; ignored otherwise.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
|
public void SubmitRaceRpc(RaceId race)
|
|
{
|
|
// Reset ready state when race changes — players shouldn't be "ready"
|
|
// with a stale pick.
|
|
if (raceSelection.Value != race)
|
|
{
|
|
raceSelection.Value = race;
|
|
isReady.Value = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client → server: submit ready state. Owner-only. A player can only
|
|
/// ready up if they've picked a race; the lobby UI gates this already
|
|
/// but the server enforces it as a safety net.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
|
public void SubmitReadyRpc(bool ready)
|
|
{
|
|
if (ready && raceSelection.Value == RaceId.None)
|
|
{
|
|
Debug.Log($"[PlayerMatchState] Slot {Slot} tried to ready up without a race. Ignored.");
|
|
return;
|
|
}
|
|
isReady.Value = ready;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side helper: enumerate every currently-spawned PlayerMatchState.
|
|
/// Used by <c>LobbyService</c> to evaluate "are all players ready" and
|
|
/// by <c>LobbyController</c> on every peer to render the player list.
|
|
/// </summary>
|
|
public static IEnumerable<PlayerMatchState> AllPlayers => s_byClientId.Values;
|
|
|
|
/// <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) -------------------------------
|
|
|
|
// Caps at MatchRules.MaxPlayers rather than at the PlayerSlot enum's range — the enum
|
|
// still runs to 9 so baked LevelData owner grids stay valid, but only the first
|
|
// MaxPlayers slots are ever handed out. See MatchRules for why.
|
|
private static PlayerSlot AllocateNextSlot()
|
|
{
|
|
for (int i = 1; i <= MatchRules.MaxPlayers; i++)
|
|
{
|
|
var candidate = (PlayerSlot)i;
|
|
if (!s_assignedSlots.Contains(candidate))
|
|
return candidate;
|
|
}
|
|
|
|
Debug.LogError($"[PlayerMatchState] No free PlayerSlot (lobby is full at " +
|
|
$"{MatchRules.MaxPlayers} players). Returning None — this client " +
|
|
$"will be unable to play.");
|
|
return PlayerSlot.None;
|
|
}
|
|
}
|
|
}
|