using System.Collections.Generic; using FishNet.Connection; using FishNet.Object; using FishNet.Object.Synchronizing; using UnityEngine; namespace Ashwild.Building { /// /// Owns the whole networked side of building, grouped here by cohesion rather than split into /// two tiny scripts: /// /// 1. Committed structures — a SyncList of BuildRecords (id + pose). They accumulate to high /// counts, so they are data-driven, not NetworkObjects (§3): every machine (late joiners /// included) instantiates the matching BuiltPrefab locally from the synced list. /// 2. The live ghost preview — spawned as a real NetworkObject owned by the placer, so FishNet's /// NetworkTransform replicates its motion to everyone smoothly. A ghost is one-per-player and /// transient, the case where a NetworkObject beats a data registry — the deliberate exception /// to §3's "no NetworkObjects for world state" (which targets high-count scatter, not this). /// /// BuildManager (the local build UX) drives the owned ghost; on confirm this registry commits the /// record and despawns the ghost. One instance per scene, a scene NetworkObject like the others. /// [RequireComponent(typeof(NetworkObject))] public class BuildRegistry : NetworkBehaviour { #region State /// /// The single scene instance, so BuildManager can request ghost spawns/despawns and build /// commits. /// public static BuildRegistry Instance { get; private set; } /// /// Every committed structure, server-written and replicated to all clients. /// private readonly SyncList builds = new SyncList(); /// /// The local instances rendered from , kept index-aligned with it. /// private readonly List spawned = new List(); /// /// How close two sockets must be to count as connected — they snap to the exact same point, /// so a tiny threshold is enough and no layer/radius has to be configured. /// private const float OccupancyThresholdSqr = 0.05f * 0.05f; #endregion #region Network Lifecycle /// /// Starts listening to the replicated build list on every machine. /// public override void OnStartNetwork() { base.OnStartNetwork(); Instance = this; builds.OnChange += OnBuildsChanged; } /// /// Unsubscribes and tears down the local instances when the session ends. /// public override void OnStopNetwork() { base.OnStopNetwork(); if (Instance == this) Instance = null; builds.OnChange -= OnBuildsChanged; ClearAll(); } #endregion #region Commit Build /// /// Called by BuildManager when the local player confirms a placement: commit this buildable /// at this pose so the real structure appears for every player. /// public void RequestBuild(ushort buildableId, Vector3 position, Quaternion rotation) => RequestBuildServerRpc(buildableId, position, rotation); /// /// Server-side: validates the id and appends the record, which replicates to all clients. /// [ServerRpc(RequireOwnership = false)] private void RequestBuildServerRpc(ushort buildableId, Vector3 position, Quaternion rotation, NetworkConnection conn = null) { if (BuildableDatabase.Instance == null || BuildableDatabase.Instance.GetBuildable(buildableId) == null) return; // TODO: validate + consume the requester's resources (conn → PlayerInventory) before committing. builds.Add(new BuildRecord { buildableId = buildableId, position = position, rotation = rotation }); } #endregion #region Demolish Build /// /// Called by BuildManager when the local player demolishes a build they aimed at: resolves the /// hit instance to its index in the replicated list and asks the server to remove it. The index /// is a valid shared key because stays aligned with /// on every machine. No-op when the object is not one of our committed builds. /// public void RequestDemolish(GameObject builtInstance) { if (builtInstance == null) return; int index = spawned.IndexOf(builtInstance); if (index < 0) { Debug.LogWarning($"[BuildRegistry] '{builtInstance.name}' is not a committed build — nothing to demolish.", this); return; } RequestDemolishServerRpc(index); } /// /// Server-side: removes the record at this index, which replicates the removal (and the local /// teardown + occupancy refresh) to every client. Range-guarded against a stale index from a /// build that another player demolished during the round-trip. /// [ServerRpc(RequireOwnership = false)] private void RequestDemolishServerRpc(int index, NetworkConnection conn = null) { if (index < 0 || index >= builds.Count) return; // TODO: refund the builder's resources (conn → PlayerInventory) once build cost lands. builds.RemoveAt(index); } #endregion #region Networked Ghost Preview /// /// Client → asks the server to spawn a ghost of this buildable, owned by us. The spawned /// object is handed back to BuildManager through , carrying /// the request token so a superseded spawn can be discarded. /// public void RequestSpawnGhost(ushort buildableId, int token) => RequestSpawnGhostServerRpc(buildableId, token); /// /// Client → asks the server to despawn a ghost we own (on confirm, cancel or switch). /// public void RequestDespawnGhost(NetworkObject ghost) { if (ghost != null) RequestDespawnGhostServerRpc(ghost); } /// /// Server-side: spawns the buildable's ghost prefab owned by the requester, then tells that /// client which object to drive. The prefab must be a NetworkObject registered in /// DefaultPrefabObjects with a client-authoritative NetworkTransform. /// [ServerRpc(RequireOwnership = false)] private void RequestSpawnGhostServerRpc(ushort buildableId, int token, NetworkConnection conn = null) { if (conn == null) return; BuildableData data = BuildableDatabase.Instance != null ? BuildableDatabase.Instance.GetBuildable(buildableId) : null; if (data == null || data.GhostPrefab == null) return; GameObject go = Instantiate(data.GhostPrefab); base.ServerManager.Spawn(go, conn); TargetAttachGhost(conn, go.GetComponent(), token); } /// /// Owner-side: hands the freshly spawned ghost (and its request token) to BuildManager to drive. /// [TargetRpc] private void TargetAttachGhost(NetworkConnection conn, NetworkObject ghost, int token) { if (ghost == null || BuildManager.Instance == null) return; BuildManager.Instance.AttachGhost(ghost.gameObject, token); } /// /// Server-side: destroys a ghost (never pools it — a ghost is single-use). Ownership guards /// that a client only despawns its own. /// [ServerRpc(RequireOwnership = false)] private void RequestDespawnGhostServerRpc(NetworkObject ghost, NetworkConnection conn = null) { if (ghost == null) return; if (ghost.Owner != conn) return; base.ServerManager.Despawn(ghost, DespawnType.Destroy); } #endregion #region Sync → Local Instances /// /// Mirrors the replicated build list into local instances. The host processes only the /// server callback (asServer) so a build is never instantiated twice on the same machine. /// private void OnBuildsChanged(SyncListOperation op, int index, BuildRecord oldItem, BuildRecord newItem, bool asServer) { if (!asServer && base.IsServerInitialized) return; switch (op) { case SyncListOperation.Add: case SyncListOperation.Insert: { GameObject go = CreateInstance(newItem); spawned.Insert(index, go); MarkOccupancy(go); break; } case SyncListOperation.Set: DestroyAt(index); spawned[index] = CreateInstance(newItem); break; case SyncListOperation.RemoveAt: DestroyAt(index); spawned.RemoveAt(index); RecomputeOccupancy(); break; case SyncListOperation.Clear: ClearAll(); break; case SyncListOperation.Complete: RebuildAll(); break; } } /// /// Instantiates a record's BuiltPrefab at its pose, or null when the id or prefab is missing. /// private GameObject CreateInstance(BuildRecord record) { BuildableData data = BuildableDatabase.Instance != null ? BuildableDatabase.Instance.GetBuildable(record.buildableId) : null; if (data == null || data.BuiltPrefab == null) { Debug.LogWarning($"[BuildRegistry] Buildable id {record.buildableId} introuvable ou sans BuiltPrefab — rien de spawné.", this); return null; } GameObject go = Instantiate(data.BuiltPrefab, record.position, record.rotation); if (go.GetComponent() == null) go.AddComponent(); return go; } /// /// Destroys the local instance at an index without touching the list bookkeeping. /// private void DestroyAt(int index) { if (index < 0 || index >= spawned.Count) return; if (spawned[index] != null) Destroy(spawned[index]); } /// /// Destroys every local instance and clears the list. /// private void ClearAll() { foreach (GameObject go in spawned) if (go != null) Destroy(go); spawned.Clear(); } /// /// Rebuilds all local instances from the current list — used for the late-join catch-up — /// then recomputes which sockets are connected so late joiners see the same occupancy. /// private void RebuildAll() { ClearAll(); for (int i = 0; i < builds.Count; i++) spawned.Add(CreateInstance(builds[i])); foreach (GameObject go in spawned) MarkOccupancy(go); } /// /// Recomputes occupancy from scratch after a removal: clears every socket, then re-marks the /// connected pairs across what remains. Without this a demolished piece would leave its former /// neighbours' sockets stuck "occupied", so nothing could ever be snapped back into the gap. /// private void RecomputeOccupancy() { foreach (GameObject go in spawned) { if (go == null) continue; foreach (BuildSnapPoint sp in go.GetComponentsInChildren(true)) sp.SetOccupied(false); } foreach (GameObject go in spawned) MarkOccupancy(go); } /// /// Marks the sockets a piece shares a position with — and the matching sockets on the pieces /// it touches — as occupied, so placement snapping skips them. Occupancy is derived purely /// from geometry (two connected sockets sit on the exact same point), so it stays consistent /// on every client without anything extra crossing the wire. /// private void MarkOccupancy(GameObject instance) { if (instance == null) return; BuildSnapPoint[] newSockets = instance.GetComponentsInChildren(true); if (newSockets.Length == 0) return; foreach (GameObject other in spawned) { if (other == null || other == instance) continue; foreach (BuildSnapPoint os in other.GetComponentsInChildren(true)) { foreach (BuildSnapPoint ns in newSockets) { if (ns.Category != os.Category) continue; if ((ns.transform.position - os.transform.position).sqrMagnitude > OccupancyThresholdSqr) continue; ns.SetOccupied(true); os.SetOccupied(true); } } } } #endregion } }