Files
2026-07-07 16:43:51 +02:00

283 lines
11 KiB
C#

using System.Collections.Generic;
using FishNet.Connection;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// 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.
/// </summary>
[RequireComponent(typeof(NetworkObject))]
public class BuildRegistry : NetworkBehaviour
{
#region State
/// <summary>
/// The single scene instance, so BuildManager can request ghost spawns/despawns and build
/// commits.
/// </summary>
public static BuildRegistry Instance { get; private set; }
/// <summary>
/// Every committed structure, server-written and replicated to all clients.
/// </summary>
private readonly SyncList<BuildRecord> builds = new SyncList<BuildRecord>();
/// <summary>
/// The local instances rendered from <see cref="builds"/>, kept index-aligned with it.
/// </summary>
private readonly List<GameObject> spawned = new List<GameObject>();
/// <summary>
/// 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.
/// </summary>
private const float OccupancyThresholdSqr = 0.05f * 0.05f;
#endregion
#region Network Lifecycle
/// <summary>
/// Starts listening to the replicated build list on every machine.
/// </summary>
public override void OnStartNetwork()
{
base.OnStartNetwork();
Instance = this;
builds.OnChange += OnBuildsChanged;
}
/// <summary>
/// Unsubscribes and tears down the local instances when the session ends.
/// </summary>
public override void OnStopNetwork()
{
base.OnStopNetwork();
if (Instance == this) Instance = null;
builds.OnChange -= OnBuildsChanged;
ClearAll();
}
#endregion
#region Commit Build
/// <summary>
/// Called by BuildManager when the local player confirms a placement: commit this buildable
/// at this pose so the real structure appears for every player.
/// </summary>
public void RequestBuild(ushort buildableId, Vector3 position, Quaternion rotation)
=> RequestBuildServerRpc(buildableId, position, rotation);
/// <summary>
/// Server-side: validates the id and appends the record, which replicates to all clients.
/// </summary>
[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 Networked Ghost Preview
/// <summary>
/// Client → asks the server to spawn a ghost of this buildable, owned by us. The spawned
/// object is handed back to BuildManager through <see cref="TargetAttachGhost"/>, carrying
/// the request token so a superseded spawn can be discarded.
/// </summary>
public void RequestSpawnGhost(ushort buildableId, int token) => RequestSpawnGhostServerRpc(buildableId, token);
/// <summary>
/// Client → asks the server to despawn a ghost we own (on confirm, cancel or switch).
/// </summary>
public void RequestDespawnGhost(NetworkObject ghost)
{
if (ghost != null) RequestDespawnGhostServerRpc(ghost);
}
/// <summary>
/// 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.
/// </summary>
[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<NetworkObject>(), token);
}
/// <summary>
/// Owner-side: hands the freshly spawned ghost (and its request token) to BuildManager to drive.
/// </summary>
[TargetRpc]
private void TargetAttachGhost(NetworkConnection conn, NetworkObject ghost, int token)
{
if (ghost == null || BuildManager.Instance == null) return;
BuildManager.Instance.AttachGhost(ghost.gameObject, token);
}
/// <summary>
/// Server-side: destroys a ghost (never pools it — a ghost is single-use). Ownership guards
/// that a client only despawns its own.
/// </summary>
[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
/// <summary>
/// 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.
/// </summary>
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);
break;
case SyncListOperation.Clear:
ClearAll();
break;
case SyncListOperation.Complete:
RebuildAll();
break;
}
}
/// <summary>
/// Instantiates a record's BuiltPrefab at its pose, or null when the id or prefab is missing.
/// </summary>
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;
}
return Instantiate(data.BuiltPrefab, record.position, record.rotation);
}
/// <summary>
/// Destroys the local instance at an index without touching the list bookkeeping.
/// </summary>
private void DestroyAt(int index)
{
if (index < 0 || index >= spawned.Count) return;
if (spawned[index] != null) Destroy(spawned[index]);
}
/// <summary>
/// Destroys every local instance and clears the list.
/// </summary>
private void ClearAll()
{
foreach (GameObject go in spawned)
if (go != null) Destroy(go);
spawned.Clear();
}
/// <summary>
/// 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.
/// </summary>
private void RebuildAll()
{
ClearAll();
for (int i = 0; i < builds.Count; i++)
spawned.Add(CreateInstance(builds[i]));
foreach (GameObject go in spawned)
MarkOccupancy(go);
}
/// <summary>
/// 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.
/// </summary>
private void MarkOccupancy(GameObject instance)
{
if (instance == null) return;
BuildSnapPoint[] newSockets = instance.GetComponentsInChildren<BuildSnapPoint>(true);
if (newSockets.Length == 0) return;
foreach (GameObject other in spawned)
{
if (other == null || other == instance) continue;
foreach (BuildSnapPoint os in other.GetComponentsInChildren<BuildSnapPoint>(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
}
}