// 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 { /// /// Lives on the Player Prefab. On the server, spawns and re-spawns a separate /// NetworkObject owned by this player each time the Match /// scene loads. The builder is positioned at the centroid of the player's zone. /// /// /// Why a separate NetworkObject? Multi-builder races (Path E) become "spawn /// N builder NetworkObjects" without restructuring the Player Prefab. See the design /// discussion in Path D scoping. /// /// Server-only. Spawning is server-authoritative. Non-server peers are /// no-ops; they just receive the resulting Builder NetworkObject like any other /// replicated spawn. /// /// Lifetime & scene flow. The player NetworkObject persists across /// scenes (MainMenu → Lobby → Match → Lobby …) because it's the /// NetworkManager.PlayerPrefab. The Builder is spawned with /// destroyWithScene: true so it's torn down on every scene unload — we /// only want a builder in the Match scene. /// re-spawns when entering Match; handles /// disconnect cleanup. /// /// Race-driven builder prefab. If a exists /// in the Match scene AND the player's selected has /// a BuilderPrefab assigned, that prefab is used. Otherwise this falls back /// to the inspector-assigned (the universal default). /// Phase 1.8 races just need to fill in their BuilderPrefab field; no code /// change required. /// 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 clientsCompleted, List 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(); 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(); 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(); 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(); 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(); 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); } } }