(Feat) Add Build

This commit is contained in:
2026-07-22 12:56:13 +02:00
parent 3657b870d8
commit 0052b0d98e
244 changed files with 35752 additions and 3112 deletions
+65 -204
View File
@@ -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