// Assets/_Project/Scripts/Gameplay/PlayerBuilderSpawner.cs using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Levels; namespace TD.Gameplay { /// /// Lives on the Player Prefab. On the server, when the player NetworkObject spawns, /// instantiates and spawns a separate NetworkObject owned by that /// player. The builder is positioned at the centroid of the player's zone before spawn. /// /// /// 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. The spawned builder is destroyed when the player NetworkObject /// despawns (e.g., disconnect). NGO does this automatically because we set /// destroyWithScene and store no other references — the builder's despawn cleans /// up the static registry in . /// public class PlayerBuilderSpawner : NetworkBehaviour { [Tooltip("Builder prefab to instantiate. 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; public override void OnNetworkSpawn() { if (!IsServer) return; if (builderPrefab == null) { Debug.LogError("[PlayerBuilderSpawner] No Builder prefab assigned. " + "Cannot spawn builder for client " + OwnerClientId + "."); return; } var pms = GetComponent(); if (pms == null) { Debug.LogError("[PlayerBuilderSpawner] PlayerMatchState not found on Player Prefab. " + "Add it as a sibling component."); 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 pms.SlotReady += OnOwnerSlotReady; } private void OnOwnerSlotReady(PlayerSlot slot) { var pms = GetComponent(); if (pms != null) pms.SlotReady -= OnOwnerSlotReady; SpawnBuilderForOwner(slot); } public override void OnNetworkDespawn() { // 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); var go = Instantiate(builderPrefab, 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 ---------------------------------------------------- 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); } } }