(Feat) Add Build
This commit is contained in:
@@ -1,25 +1,29 @@
|
||||
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:
|
||||
/// The networked side of building: it commits placements and owns the live ghost preview.
|
||||
///
|
||||
/// 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).
|
||||
/// Every committed structure — a wall as much as a chest or a campfire — is spawned as a real
|
||||
/// NetworkObject owned by the server. That is the whole design: FishNet already replicates spawns,
|
||||
/// catches late joiners up, and tears objects down on despawn, so there is no build list to keep,
|
||||
/// no index to align, and no rebuild path to write. Each build carries its own state on its own
|
||||
/// <see cref="BuiltStructure"/>, which is what lets a stateful structure (chest, cooking station)
|
||||
/// be placed by exactly the same code as a plain wall instead of needing a parallel mechanism.
|
||||
///
|
||||
/// 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.
|
||||
/// This replaced an earlier data-driven registry (a SyncList of pose records that every client
|
||||
/// instantiated locally). That saved FishNet's per-object bookkeeping but not the GameObjects —
|
||||
/// they were instantiated locally anyway — while forcing a second, incompatible path for anything
|
||||
/// with real state. One mechanism is worth the bookkeeping.
|
||||
///
|
||||
/// The ghost is likewise a NetworkObject, but owned by the placer so FishNet's NetworkTransform
|
||||
/// replicates its motion smoothly; it is transient and one per player.
|
||||
///
|
||||
/// BuildManager (the local build UX) drives the owned ghost; on confirm this registry spawns the
|
||||
/// structure and despawns the ghost. One instance per scene, a scene NetworkObject like the others.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class BuildRegistry : NetworkBehaviour
|
||||
@@ -27,50 +31,31 @@ namespace Ashwild.Building
|
||||
#region State
|
||||
|
||||
/// <summary>
|
||||
/// The single scene instance, so BuildManager can request ghost spawns/despawns and build
|
||||
/// commits.
|
||||
/// 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.
|
||||
/// Claims the singleton 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.
|
||||
/// Releases the singleton when the session ends. Committed structures are NetworkObjects, so
|
||||
/// FishNet despawns them itself — there is nothing to tear down here.
|
||||
/// </summary>
|
||||
public override void OnStopNetwork()
|
||||
{
|
||||
base.OnStopNetwork();
|
||||
if (Instance == this) Instance = null;
|
||||
builds.OnChange -= OnBuildsChanged;
|
||||
ClearAll();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -78,60 +63,45 @@ namespace Ashwild.Building
|
||||
#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.
|
||||
/// 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.
|
||||
/// Server-side: validates the id, instantiates the built prefab, seeds its identity and health,
|
||||
/// then spawns it for everyone. The appear pop is sent separately to current observers only, so a
|
||||
/// late joiner streaming in an existing base does not watch all of it pop at once.
|
||||
///
|
||||
/// The structure is spawned server-owned (no connection passed): builds are shared world state
|
||||
/// and several players interact with the same chest or fire, so handing ownership to whoever
|
||||
/// placed it would tie everyone's access to that player's connection.
|
||||
/// </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)
|
||||
BuildableData data = BuildableDatabase.Instance != null ? BuildableDatabase.Instance.GetBuildable(buildableId) : null;
|
||||
if (data == null || data.BuiltPrefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[BuildRegistry] '{builtInstance.name}' is not a committed build — nothing to demolish.", this);
|
||||
Debug.LogWarning($"[BuildRegistry] Buildable id {buildableId} is unknown or has no BuiltPrefab — nothing spawned.", 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: validate + consume the requester's resources (conn → PlayerInventory) before committing.
|
||||
GameObject go = Instantiate(data.BuiltPrefab, position, rotation);
|
||||
|
||||
// TODO: refund the builder's resources (conn → PlayerInventory) once build cost lands.
|
||||
builds.RemoveAt(index);
|
||||
BuiltStructure structure = go.GetComponent<BuiltStructure>();
|
||||
if (structure == null)
|
||||
{
|
||||
Debug.LogError($"[BuildRegistry] BuiltPrefab '{data.BuiltPrefab.name}' has no BuiltStructure — add one (it carries the build's identity, health and demolition).", this);
|
||||
Destroy(go);
|
||||
return;
|
||||
}
|
||||
|
||||
structure.InitialiseOnServer(buildableId, data.MaxHealth);
|
||||
base.ServerManager.Spawn(go);
|
||||
structure.PlaySpawnBumpForObservers();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -193,147 +163,38 @@ namespace Ashwild.Building
|
||||
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.
|
||||
/// Owner → replicates the ghost's current spot validity to every other client so teammates see
|
||||
/// the same green (clear) / red (blocked) preview. Only the owner drives the evaluation locally,
|
||||
/// so without this a remote ghost stays on its default look; BuildManager calls this only when the
|
||||
/// value actually changes, so the traffic is a rare per-ghost blip, not a per-frame stream.
|
||||
/// </summary>
|
||||
private void OnBuildsChanged(SyncListOperation op, int index, BuildRecord oldItem, BuildRecord newItem, bool asServer)
|
||||
public void SetGhostValidity(NetworkObject ghost, bool valid)
|
||||
{
|
||||
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;
|
||||
}
|
||||
if (ghost != null) SetGhostValidityServerRpc(ghost, valid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a record's BuiltPrefab at its pose, or null when the id or prefab is missing.
|
||||
/// Server-side relay: forwards the placer's validity to all observers.
|
||||
/// </summary>
|
||||
private GameObject CreateInstance(BuildRecord record)
|
||||
[ServerRpc(RequireOwnership = false)]
|
||||
private void SetGhostValidityServerRpc(NetworkObject ghost, bool valid, NetworkConnection conn = null)
|
||||
{
|
||||
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;
|
||||
if (ghost == null) return;
|
||||
ObserversSetGhostValidity(ghost, valid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the local instance at an index without touching the list bookkeeping.
|
||||
/// Applies the replicated validity on every client except the placer, whose own local evaluation
|
||||
/// already drives the richer verdict (and knows about affordability, which is never sent). A
|
||||
/// missing ghost is ignored — it may have been despawned mid-flight.
|
||||
/// </summary>
|
||||
private void DestroyAt(int index)
|
||||
[ObserversRpc]
|
||||
private void ObserversSetGhostValidity(NetworkObject ghost, bool valid)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ghost == null || ghost.IsOwner) return;
|
||||
BuildGhost bg = ghost.GetComponent<BuildGhost>();
|
||||
if (bg != null) bg.SetRemoteValidity(valid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Reference in New Issue
Block a user