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;