Adding new Races, Main Menu -> Lobby flow, and what's needed to set up multiplayer testing.
This commit is contained in:
parent
60fa58b07f
commit
fdada6f132
29 changed files with 2581 additions and 176 deletions
|
|
@ -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 & 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;
|
||||
|
|
|
|||
75
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs
Normal file
75
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/RaceDefinition.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 98b7d5d116870f94da912007f6aa5cbb
|
||||
152
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs
Normal file
152
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs
Normal 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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/RaceRegistry.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 50ccfdaa301b6a7439b4edfc750550aa
|
||||
Loading…
Add table
Add a link
Reference in a new issue