342 lines
14 KiB
C#
342 lines
14 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 Demolish Build
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="spawned"/> stays aligned with <see cref="builds"/>
|
|
/// on every machine. No-op when the object is not one of our committed builds.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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
|
|
|
|
/// <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);
|
|
RecomputeOccupancy();
|
|
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;
|
|
}
|
|
|
|
GameObject go = Instantiate(data.BuiltPrefab, record.position, record.rotation);
|
|
if (go.GetComponent<BuiltStructure>() == null) go.AddComponent<BuiltStructure>();
|
|
return go;
|
|
}
|
|
|
|
/// <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>
|
|
/// 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.
|
|
/// </summary>
|
|
private void RecomputeOccupancy()
|
|
{
|
|
foreach (GameObject go in spawned)
|
|
{
|
|
if (go == null) continue;
|
|
foreach (BuildSnapPoint sp in go.GetComponentsInChildren<BuildSnapPoint>(true))
|
|
sp.SetOccupied(false);
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|