Adding new Races, Main Menu -> Lobby flow, and what's needed to set up multiplayer testing.

This commit is contained in:
Matt F 2026-05-17 23:31:02 -07:00
parent 60fa58b07f
commit fdada6f132
29 changed files with 2581 additions and 176 deletions

View file

@ -1,15 +1,18 @@
// Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
using TD.Core;
using TD.Levels;
using TD.Net;
namespace TD.Gameplay
{
/// <summary>
/// Lives on the Player Prefab. On the server, when the player NetworkObject spawns,
/// instantiates and spawns a separate <see cref="Builder"/> NetworkObject owned by that
/// player. The builder is positioned at the centroid of the player's zone before spawn.
/// Lives on the Player Prefab. On the server, spawns and re-spawns a separate
/// <see cref="Builder"/> NetworkObject owned by this player each time the Match
/// scene loads. The builder is positioned at the centroid of the player's zone.
/// </summary>
/// <remarks>
/// <para><b>Why a separate NetworkObject?</b> Multi-builder races (Path E) become "spawn
@ -20,31 +23,79 @@ namespace TD.Gameplay
/// no-ops; they just receive the resulting Builder NetworkObject like any other
/// replicated spawn.</para>
///
/// <para><b>Lifetime.</b> The spawned builder is destroyed when the player NetworkObject
/// despawns (e.g., disconnect). NGO does this automatically because we set
/// <c>destroyWithScene</c> and store no other references — the builder's despawn cleans
/// up the static registry in <see cref="Builder.OnNetworkDespawn"/>.</para>
/// <para><b>Lifetime &amp; scene flow.</b> The player NetworkObject persists across
/// scenes (MainMenu → Lobby → Match → Lobby …) because it's the
/// <c>NetworkManager.PlayerPrefab</c>. The Builder is spawned with
/// <c>destroyWithScene: true</c> so it's torn down on every scene unload — we
/// only want a builder in the Match scene. <see cref="HandleSceneLoadCompleted"/>
/// re-spawns when entering Match; <see cref="OnNetworkDespawn"/> handles
/// disconnect cleanup.</para>
///
/// <para><b>Race-driven builder prefab.</b> If a <see cref="RaceRegistry"/> exists
/// in the Match scene AND the player's selected <see cref="RaceDefinition"/> has
/// a <c>BuilderPrefab</c> assigned, that prefab is used. Otherwise this falls back
/// to the inspector-assigned <see cref="builderPrefab"/> (the universal default).
/// Phase 1.8 races just need to fill in their <c>BuilderPrefab</c> field; no code
/// change required.</para>
/// </remarks>
public class PlayerBuilderSpawner : NetworkBehaviour
{
[Tooltip("Builder prefab to instantiate. Must be registered with the NetworkManager " +
"as a network prefab.")]
[Tooltip("Default Builder prefab. Used when the player's race has no BuilderPrefab " +
"assigned (or when no RaceRegistry is present in the scene). Must be " +
"registered with the NetworkManager as a network prefab.")]
[SerializeField] private GameObject builderPrefab;
// Cached reference so we can despawn the builder if needed (e.g., player disconnects).
private NetworkObject spawnedBuilder;
// Track our scene-load subscription state so we can clean up correctly.
private bool sceneSubscribed;
public override void OnNetworkSpawn()
{
if (!IsServer) return;
if (builderPrefab == null)
{
Debug.LogError("[PlayerBuilderSpawner] No Builder prefab assigned. " +
Debug.LogError("[PlayerBuilderSpawner] No default Builder prefab assigned. " +
"Cannot spawn builder for client " + OwnerClientId + ".");
return;
}
// Subscribe to NGO's scene-load completion so we re-spawn the builder
// every time the Match scene comes up (initial match start, Retry, etc.).
if (NetworkManager != null && NetworkManager.SceneManager != null)
{
NetworkManager.SceneManager.OnLoadEventCompleted += HandleSceneLoadCompleted;
sceneSubscribed = true;
}
// Edge case: the player connected while a match is already in progress.
// The Match scene is already loaded, so OnLoadEventCompleted won't fire
// again until the next transition. Spawn now.
if (SceneManager.GetActiveScene().name == SceneNames.Match)
TrySpawnBuilder();
}
// NGO fires this on the server once a scene load is acknowledged complete
// by every connected client (or timed out). We only act when the Match
// scene loads; Lobby / MainMenu loads are no-ops here.
private void HandleSceneLoadCompleted(string sceneName,
LoadSceneMode loadSceneMode,
List<ulong> clientsCompleted,
List<ulong> clientsTimedOut)
{
if (!IsServer) return;
if (sceneName != SceneNames.Match) return;
TrySpawnBuilder();
}
// Spawns the builder if it doesn't already exist. Defers to a SlotReady
// event if PlayerMatchState hasn't finished assigning the slot yet.
private void TrySpawnBuilder()
{
if (spawnedBuilder != null && spawnedBuilder.IsSpawned) return;
var pms = GetComponent<PlayerMatchState>();
if (pms == null)
{
@ -53,8 +104,6 @@ namespace TD.Gameplay
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
@ -65,11 +114,21 @@ namespace TD.Gameplay
{
var pms = GetComponent<PlayerMatchState>();
if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
SpawnBuilderForOwner(slot);
// Only spawn if we're in the Match scene. SlotReady can fire in MainMenu
// (during initial connection) — we don't want a builder there.
if (SceneManager.GetActiveScene().name == SceneNames.Match)
SpawnBuilderForOwner(slot);
}
public override void OnNetworkDespawn()
{
if (sceneSubscribed && NetworkManager != null && NetworkManager.SceneManager != null)
{
NetworkManager.SceneManager.OnLoadEventCompleted -= HandleSceneLoadCompleted;
sceneSubscribed = false;
}
// When the player despawns (disconnect), also despawn their builder if it still exists.
if (IsServer && spawnedBuilder != null && spawnedBuilder.IsSpawned)
{
@ -84,7 +143,19 @@ namespace TD.Gameplay
// Falls back to origin if loader/zone data isn't available.
Vector3 spawnPos = ComputeZoneCentroid(slot);
var go = Instantiate(builderPrefab, spawnPos, Quaternion.identity);
// Pick the prefab: race-specific takes priority, default falls back.
// Falling back is what lets all races share the default builder
// during Phase 1.7 — each RaceDefinition just needs the same default
// assigned, and the spawner picks it up automatically.
GameObject prefab = ResolveBuilderPrefab();
if (prefab == null)
{
Debug.LogError("[PlayerBuilderSpawner] No builder prefab available. " +
"Set the default in the inspector or assign one to the player's race.");
return;
}
var go = Instantiate(prefab, spawnPos, Quaternion.identity);
var netObj = go.GetComponent<NetworkObject>();
if (netObj == null)
{
@ -114,6 +185,22 @@ namespace TD.Gameplay
// ----- Helpers ----------------------------------------------------
// Picks the builder prefab to spawn for this player. Race-specific takes
// priority when (a) RaceRegistry is in the scene, (b) the player picked
// a race, and (c) that race's RaceDefinition has a BuilderPrefab assigned.
// Otherwise falls back to the inspector-assigned default.
private GameObject ResolveBuilderPrefab()
{
var pms = GetComponent<PlayerMatchState>();
if (pms != null && pms.RaceSelection != RaceId.None && RaceRegistry.Instance != null)
{
var raceDef = RaceRegistry.Instance.Get(pms.RaceSelection);
if (raceDef != null && raceDef.BuilderPrefab != null)
return raceDef.BuilderPrefab;
}
return builderPrefab;
}
private static Vector3 ComputeZoneCentroid(PlayerSlot slot)
{
var loader = LevelLoader.Instance;

View file

@ -0,0 +1,75 @@
// Assets/_Project/Scripts/Gameplay/RaceDefinition.cs
using UnityEngine;
using TD.Core;
using TD.Towers;
namespace TD.Gameplay
{
/// <summary>
/// One asset per playable race. Holds the race's identity (the
/// <see cref="RaceId"/> binding for networked selection), the visual + lore
/// content shown in the lobby's race-selection UI, and stub fields for the
/// Phase 1.8 gameplay payload (race-specific builder + tower roster).
/// </summary>
/// <remarks>
/// <para><b>Creating a new race.</b> Right-click in the project window →
/// <c>Create → TD → Race Definition</c>. Fill in the inspector:
/// <list type="bullet">
/// <item><b>Id</b> — pick an unused <see cref="RaceId"/> value (Race1..Race16).</item>
/// <item><b>Display Name</b> — what shows in the grid + detail header.</item>
/// <item><b>Icon</b> — square sprite, ~256x256, drawn in the grid + detail.</item>
/// <item><b>Builder Name / Description</b> — text in the detail panel.</item>
/// <item><b>Lore Text</b> — longer description in the detail panel.</item>
/// <item><b>Builder Prefab / Towers</b> — <i>stubs</i>, wired in Phase 1.8.</item>
/// </list>
/// Then drag the asset into the <c>RaceRegistry</c>'s <c>Definitions</c>
/// array on the scene's <c>RaceRegistry</c> GameObject.</para>
///
/// <para><b>Why <see cref="Id"/> is serialized rather than inferred from the
/// asset name.</b> Race selection is networked via a
/// <see cref="UnityEngine.SerializeField"/>-backed enum on
/// <c>PlayerMatchState</c>. The enum byte value is the wire identity; the
/// asset is just the local lookup. Keeping the binding explicit on the
/// asset prevents accidental drift if assets get renamed.</para>
/// </remarks>
[CreateAssetMenu(fileName = "RaceDefinition", menuName = "TD/Race Definition", order = 5)]
public class RaceDefinition : ScriptableObject
{
[Header("Identity")]
[Tooltip("Enum value used by PlayerMatchState.RaceSelection on the network. " +
"Pick an unused RaceId (Race1..Race16). Each asset must use a unique value.")]
public RaceId Id = RaceId.None;
[Tooltip("Race name shown in the lobby grid and detail panel header.")]
public string DisplayName;
[Tooltip("Square icon shown in the grid cell and as the larger image in the detail panel. " +
"~256x256 PNG works well; the UI scales to fit.")]
public Sprite Icon;
[Header("Builder")]
[Tooltip("Builder name shown in the detail panel (the builder is the in-match avatar " +
"that gates tower placement by proximity).")]
public string BuilderName;
[Tooltip("Short description of the builder shown beneath the name in the detail panel.")]
[TextArea(2, 4)]
public string BuilderDescription;
[Header("Lore")]
[Tooltip("Race lore / background shown in the detail panel. Lorem ipsum is fine " +
"for placeholder races; replace when actual writing is ready.")]
[TextArea(5, 15)]
public string LoreText;
[Header("Gameplay payload (Phase 1.8 — not wired yet)")]
[Tooltip("STUB (Phase 1.8): race-specific builder prefab. Currently every race spawns " +
"the default builder. When Phase 1.8 lands, PlayerBuilderSpawner will pick " +
"the prefab based on the player's RaceSelection.")]
public GameObject BuilderPrefab;
[Tooltip("STUB (Phase 1.8): tower roster available to this race. TowerRegistry will " +
"filter to this list when the active player belongs to this race.")]
public TowerDefinition[] Towers;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 98b7d5d116870f94da912007f6aa5cbb

View file

@ -0,0 +1,152 @@
// Assets/_Project/Scripts/Gameplay/RaceRegistry.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// Persistent (DontDestroyOnLoad) singleton that holds every
/// <see cref="RaceDefinition"/> available in the current build and lets
/// any code look one up by <see cref="RaceId"/>.
/// </summary>
/// <remarks>
/// <para><b>Inspector setup.</b> Place ONE <c>RaceRegistry</c> GameObject in
/// the <b>MainMenu scene</b> only. Drag every <c>RaceDefinition</c> asset
/// into the <c>Definitions</c> array. The registry marks itself
/// <c>DontDestroyOnLoad</c> on Awake, so it survives the scene transitions
/// MainMenu → Lobby → Match → back to Lobby and is available to all of
/// them through <see cref="Instance"/>.</para>
///
/// <para><b>Why not also in Lobby/Match scenes?</b> Maintaining the
/// <c>Definitions</c> array in multiple places is a designer trap — update
/// one, forget the other, runtime mismatch. Single source of truth in
/// MainMenu eliminates that class of bug. Duplicate instances in other
/// scenes are detected in <see cref="Awake"/> and self-destruct, so an
/// accidental copy doesn't break anything but does log a warning.</para>
///
/// <para><b>Editor-only standalone testing.</b> If you open the Lobby or
/// Match scene directly from the editor (without going through MainMenu
/// first), no RaceRegistry will exist and race-dependent code falls back
/// gracefully (<see cref="Get"/> returns null; UI shows "Coming Soon" or
/// the default builder is used). For standalone-scene testing, you can
/// temporarily add a registry to whatever scene you're testing — but don't
/// commit it as part of normal play flow.</para>
///
/// <para><b>Slot model.</b> The lobby grid shows 16 slots (one per
/// <see cref="RaceId"/> value 1-16) regardless of how many are filled.
/// <see cref="Get"/> returns null for unregistered slots, which the UI
/// renders as a "Coming Soon" placeholder.</para>
///
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — the registry
/// is identical on every peer (same ScriptableObject assets), so nothing
/// to sync. Network state tracks only the chosen <see cref="RaceId"/> on
/// <c>PlayerMatchState</c>; the rest is local lookup.</para>
/// </remarks>
public class RaceRegistry : MonoBehaviour
{
// ----- Singleton -------------------------------------------------
public static RaceRegistry Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("All RaceDefinition assets available in this build. Drag each asset " +
"into the array. Duplicate Ids are rejected with a warning; null entries " +
"are skipped.")]
[SerializeField] private RaceDefinition[] definitions;
// ----- Internal lookup -------------------------------------------
private readonly Dictionary<RaceId, RaceDefinition> byId
= new Dictionary<RaceId, RaceDefinition>();
// ----- Lifecycle --------------------------------------------------
private void Awake()
{
// Persistent-singleton pattern: the FIRST instance to wake up wins
// and survives scene loads. Subsequent instances (e.g. a stale
// copy left over in the Lobby or Match scene) are self-destroyed,
// not just ignored — we want the scene to "self-heal" if someone
// accidentally drops a second copy in.
if (Instance != null && Instance != this)
{
Debug.LogWarning(
$"[RaceRegistry] Persistent instance already exists. " +
$"Destroying duplicate in scene '{gameObject.scene.name}'. " +
$"Keep RaceRegistry in the MainMenu scene only.");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
BuildLookup();
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ----- Public API -------------------------------------------------
/// <summary>
/// Returns the <see cref="RaceDefinition"/> for the given id, or null
/// if no asset is registered for that id (e.g. "Coming Soon" slots).
/// </summary>
public RaceDefinition Get(RaceId id)
{
byId.TryGetValue(id, out var def);
return def;
}
/// <summary>
/// Iterates the canonical 16 lobby slots (Race1..Race16). For each
/// slot returns either the registered <see cref="RaceDefinition"/> or
/// null. UI consumers use this to render a stable 16-cell grid where
/// unfilled slots show a placeholder.
/// </summary>
public IEnumerable<(RaceId id, RaceDefinition def)> AllSlots()
{
for (int i = (int)RaceId.Race1; i <= (int)RaceId.Race16; i++)
{
var id = (RaceId)i;
yield return (id, Get(id));
}
}
// ----- Private ----------------------------------------------------
private void BuildLookup()
{
byId.Clear();
if (definitions == null || definitions.Length == 0)
{
Debug.LogWarning("[RaceRegistry] No RaceDefinition assets assigned. " +
"Drag assets into the Definitions array.");
return;
}
foreach (var def in definitions)
{
if (def == null) continue;
if (def.Id == RaceId.None)
{
Debug.LogWarning($"[RaceRegistry] '{def.name}' has Id=None — set it to " +
"an unused Race1..Race16 value.");
continue;
}
if (byId.ContainsKey(def.Id))
{
Debug.LogWarning($"[RaceRegistry] Duplicate Id '{def.Id}' detected on " +
$"'{def.name}'. Earlier registration kept; rename one.");
continue;
}
byId[def.Id] = def;
}
Debug.Log($"[RaceRegistry] Registered {byId.Count} race(s).");
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 50ccfdaa301b6a7439b4edfc750550aa