237 lines
No EOL
10 KiB
C#
237 lines
No EOL
10 KiB
C#
// 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, 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
|
|
/// N builder NetworkObjects" without restructuring the Player Prefab. See the design
|
|
/// discussion in Path D scoping.</para>
|
|
///
|
|
/// <para><b>Server-only.</b> Spawning is server-authoritative. Non-server peers are
|
|
/// no-ops; they just receive the resulting Builder NetworkObject like any other
|
|
/// replicated spawn.</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("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 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)
|
|
{
|
|
Debug.LogError("[PlayerBuilderSpawner] PlayerMatchState not found on Player Prefab. " +
|
|
"Add it as a sibling component.");
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
// 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)
|
|
{
|
|
spawnedBuilder.Despawn(destroy: true);
|
|
}
|
|
spawnedBuilder = null;
|
|
}
|
|
|
|
private void SpawnBuilderForOwner(PlayerSlot slot)
|
|
{
|
|
// Compute initial position: centroid of this player's zone.
|
|
// Falls back to origin if loader/zone data isn't available.
|
|
Vector3 spawnPos = ComputeZoneCentroid(slot);
|
|
|
|
// 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)
|
|
{
|
|
Debug.LogError("[PlayerBuilderSpawner] Builder prefab is missing a " +
|
|
"NetworkObject component. Cannot spawn.");
|
|
Destroy(go);
|
|
return;
|
|
}
|
|
|
|
netObj.SpawnWithOwnership(OwnerClientId, destroyWithScene: true);
|
|
spawnedBuilder = netObj;
|
|
|
|
// Tell NetworkTransform to teleport to the spawn position, so clients don't
|
|
// interpolate from the prefab's authoring position (typically the origin) to
|
|
// the spawn position over the first few sync ticks. Without this, clients see
|
|
// the builder smoothly drift from world origin to its spawn point — which is
|
|
// exactly what we don't want for a brand-new spawn.
|
|
//
|
|
// Teleport sets a one-frame "no interpolation" flag that NetworkTransform
|
|
// honors on its next sync, so clients snap to the position instead.
|
|
var netTransform = go.GetComponent<Unity.Netcode.Components.NetworkTransform>();
|
|
if (netTransform != null)
|
|
{
|
|
netTransform.Teleport(spawnPos, Quaternion.identity, go.transform.localScale);
|
|
}
|
|
}
|
|
|
|
// ----- 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;
|
|
if (loader == null || !loader.IsLoaded) return Vector3.zero;
|
|
if (slot == PlayerSlot.None) return Vector3.zero;
|
|
|
|
var levelData = loader.LevelData;
|
|
if (levelData == null || levelData.OwnerGrid == null) return Vector3.zero;
|
|
|
|
Vector2 sum = Vector2.zero;
|
|
int count = 0;
|
|
|
|
for (int y = 0; y < levelData.GridSize.y; y++)
|
|
{
|
|
for (int x = 0; x < levelData.GridSize.x; x++)
|
|
{
|
|
int idx = y * levelData.GridSize.x + x;
|
|
if (levelData.OwnerGrid[idx] != slot) continue;
|
|
|
|
Vector2Int tile = new Vector2Int(
|
|
levelData.GridOriginTile.x + x,
|
|
levelData.GridOriginTile.y + y);
|
|
Vector3 world = GridCoordinates.GridToWorld(tile);
|
|
sum.x += world.x;
|
|
sum.y += world.z;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
if (count == 0) return Vector3.zero;
|
|
return new Vector3(sum.x / count, GridCoordinates.BUILDABLE_PLANE_Y, sum.y / count);
|
|
}
|
|
}
|
|
} |