(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
+80 -52
View File
@@ -3,13 +3,41 @@ using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// Placement validity for a build ghost. Each frame BuildManager calls Evaluate with the
/// obstruction mask it owns (configured in one place, not per ghost prefab), which box-overlaps
/// the ghost's footprint against those layers (other structures, props, players — never the
/// ground it rests on) and shows the result by adding a valid or invalid overlay material on top
/// of each renderer's base materials (not by tinting them). BuildManager reads <see cref="IsValid"/>
/// to allow or block the build. The footprint collider is only a bounds source: the overlap test
/// reads its dimensions directly rather than relying on physics contacts.
/// Why a placement is or is not allowed. Produced in one place (BuildManager.EvaluatePlacement) and
/// consumed by both the ghost's look and the confirm gate, so the preview can never disagree with
/// what pressing the button actually does — the two used to be decided separately and could drift.
///
/// Splitting the failure into distinct reasons is the point: a single "invalid" state left the
/// player staring at a red ghost with no way to tell a blocked spot from an unpayable one.
/// </summary>
public enum PlacementVerdict
{
/// <summary>Buildable here, right now.</summary>
Valid,
/// <summary>The footprint overlaps something on the obstruction layers.</summary>
Blocked,
/// <summary>The structure requires a socket connection (PlacementSupport.SnapOnly) and has none.</summary>
Unsupported,
/// <summary>The spot itself is fine, but the player cannot pay the cost.</summary>
Unaffordable
}
/// <summary>
/// The visual state of a build ghost — a pure view over a <see cref="PlacementVerdict"/>.
///
/// It decides nothing: BuildManager owns the layer masks, runs the obstruction query and produces
/// the verdict, then hands it here to be rendered. (It used to run the overlap itself using a mask
/// passed in from outside, which mixed deciding with displaying and put the physics query away from
/// the data that configures it.)
///
/// The look is applied by ADDING an overlay material on top of each renderer's authored materials
/// rather than replacing them, so the model keeps its own look underneath the tint. The footprint
/// BoxCollider is exposed purely as a bounds source — it stays disabled on the prefab, because the
/// obstruction mask includes the layer the ghost itself sits on and an enabled collider would make
/// the preview report itself as blocked.
/// </summary>
[DisallowMultipleComponent]
public class BuildGhost : MonoBehaviour
@@ -17,47 +45,54 @@ namespace Ashwild.Building
#region Serialized Fields
[Header("Footprint")]
[Tooltip("Box whose bounds define the volume tested for obstructions. Sized in the prefab.")]
[Tooltip("Box whose bounds define the volume tested for obstructions. Sized in the prefab, and kept disabled — it is read as dimensions, never used for physics contacts.")]
[SerializeField] private BoxCollider footprint;
[Header("Overlay Materials")]
[Tooltip("Renderers the overlay is added onto. Auto-filled from children if left empty.")]
[SerializeField] private Renderer[] renderers;
[Tooltip("Material added on top of the base ones when the spot is buildable (green).")]
[Tooltip("Added on top of the base materials when the spot is buildable (green).")]
[SerializeField] private Material validOverlay;
[Tooltip("Material added on top of the base ones when the spot is blocked (red).")]
[Tooltip("Added for every reason the build is refused — blocked, unsupported or unaffordable.")]
[SerializeField] private Material invalidOverlay;
#endregion
#region State
/// <summary>
/// Whether the ghost is currently on a buildable spot (no obstruction overlap).
/// </summary>
public bool IsValid { get; private set; }
/// <summary>
/// Each renderer's original materials, so the overlay can be appended without losing them.
/// </summary>
private Material[][] baseMaterials;
/// <summary>
/// The validity reflected by the currently applied overlay, so we only swap on a real change.
/// The overlay currently applied, so we only rebuild the material lists when it actually changes.
/// </summary>
private bool lastValid;
private Material lastOverlay;
private bool overlayApplied;
private readonly Collider[] overlapResults = new Collider[8];
#endregion
#region Public API
/// <summary>
/// The footprint volume, for BuildManager to read its centre and size when testing obstructions.
/// Null on a ghost authored without one, which BuildManager treats as "never obstructed".
/// </summary>
public BoxCollider Footprint => footprint;
#endregion
#region Unity Lifecycle
/// <summary>
/// Auto-collects renderers when none were assigned and caches their base materials.
/// Auto-collects renderers when none were assigned, caches their base materials, then shows the
/// valid overlay immediately so the ghost reads as a semi-transparent preview from the very first
/// frame — on every client, not just the owner. On a remote copy nobody runs the evaluation, so
/// without this the ghost would sit in its opaque base materials until (and unless) a validity
/// update lands; the owner's own evaluation overrides this on its next frame anyway.
/// </summary>
private void Awake()
{
@@ -67,53 +102,46 @@ namespace Ashwild.Building
baseMaterials = new Material[renderers.Length][];
for (int i = 0; i < renderers.Length; i++)
baseMaterials[i] = renderers[i] != null ? renderers[i].sharedMaterials : new Material[0];
SetOverlay(validOverlay);
}
#endregion
#region Public API
#region Display
/// <summary>
/// Re-tests the footprint against the obstruction layers (passed in by BuildManager, which
/// owns the mask so it is configured in one place), swaps the overlay material when validity
/// changed, and returns the result. A ghost with no footprint is always considered valid.
/// Shows the look matching a verdict. The single entry point the owner drives every frame.
/// Every refusal shares the one invalid material — the verdict still distinguishes the reasons for
/// the confirm gate (and for anything that wants to surface them later), the preview just does not
/// colour-code them.
/// </summary>
public bool Evaluate(LayerMask obstructionMask)
{
IsValid = !IsObstructed(obstructionMask);
if (!overlayApplied || IsValid != lastValid)
{
ApplyOverlay(IsValid ? validOverlay : invalidOverlay);
lastValid = IsValid;
overlayApplied = true;
}
return IsValid;
}
#endregion
#region Internal
public void SetState(PlacementVerdict verdict)
=> SetOverlay(verdict == PlacementVerdict.Valid ? validOverlay : invalidOverlay);
/// <summary>
/// Box-overlaps the footprint against the obstruction layers, ignoring triggers.
/// Applies the shared valid/blocked look on a remote copy from the placer's replicated spot
/// validity (routed through BuildRegistry). Remotes only ever see these two states — the placer's
/// private "unaffordable" and "unsupported" reasons are never sent — so a teammate sees green on
/// a buildable spot and red otherwise, matching where the placer can actually build.
/// </summary>
private bool IsObstructed(LayerMask obstructionMask)
public void SetRemoteValidity(bool valid) => SetOverlay(valid ? validOverlay : invalidOverlay);
/// <summary>
/// Sets the current overlay, rebuilding the renderers' material lists only when it actually
/// changes — the single funnel every state path goes through, so the change-guard stays in one place.
/// </summary>
private void SetOverlay(Material overlay)
{
if (footprint == null) return false;
Transform t = footprint.transform;
Vector3 center = t.TransformPoint(footprint.center);
Vector3 halfExtents = Vector3.Scale(footprint.size, t.lossyScale) * 0.5f;
int count = Physics.OverlapBoxNonAlloc(center, halfExtents, overlapResults, t.rotation, obstructionMask, QueryTriggerInteraction.Ignore);
return count > 0;
if (overlayApplied && overlay == lastOverlay) return;
ApplyOverlay(overlay);
lastOverlay = overlay;
overlayApplied = true;
}
/// <summary>
/// Rebuilds each renderer's material list as "base materials + overlay", so the overlay
/// draws on top of the model instead of replacing it. Skips when no overlay is assigned.
/// Rebuilds each renderer's material list as "base materials + overlay", so the overlay draws on
/// top of the model instead of replacing it. Skips when no overlay is assigned.
/// </summary>
private void ApplyOverlay(Material overlay)
{
+165 -27
View File
@@ -67,6 +67,9 @@ namespace Ashwild.Building
[Tooltip("Layers that block a build (structures, props, players). Must NOT include the ground.")]
[SerializeField] private LayerMask obstructionMask;
[Tooltip("Metres shaved off each side of the footprint before testing obstructions. Connected pieces meet exactly flush, so without a small inset every snapped build would report its own neighbour as blocking. Keep it well under the thinnest structure.")]
[SerializeField] private float obstructionPadding = 0.03f;
[Header("Demolition")]
[Tooltip("Layers the demolition aim can hit to target a committed structure. Narrow this to the structures' layer so walls, roofs, etc. are the only demolishable hits.")]
[SerializeField] private LayerMask buildMask = ~0;
@@ -74,6 +77,10 @@ namespace Ashwild.Building
[Tooltip("Overlay material added on top of the aimed build while it is the demolition target (e.g. a translucent red 'about to break' pass). Should be a transparent/additive material since it draws over the model.")]
[SerializeField] private Material demolitionMaterial;
[Header("Hammer Wear")]
[Tooltip("Durability (uses) spent on the equipped hammer for each successful placement or demolition. Requires the hammer's ItemData to be uses-tracked (Has Uses + Max Uses). A depleted hammer blocks building/demolishing until repaired. Set to 0 for a hammer that never wears.")]
[SerializeField] private int hammerUsesPerAction = 1;
#endregion
#region State
@@ -98,9 +105,17 @@ namespace Ashwild.Building
/// The live networked ghost we own and drive, or null while idle or awaiting its spawn.
/// </summary>
private GameObject ghost;
private BuildGhost ghostValidity;
private BuildGhost ghostView;
private BuildSnapPoint[] ghostSnaps;
/// <summary>
/// Whether the ghost's current pose came from a socket connection rather than the free/grid aim.
/// Kept as state rather than recomputed because the pose and the validity check happen in two
/// steps, and because a frame whose aim ray hits nothing leaves the ghost — and so its
/// connection — exactly as it was.
/// </summary>
private bool ghostIsConnected;
/// <summary>
/// The ghost's current yaw for grid placement, stepped by the scroll wheel.
/// </summary>
@@ -128,7 +143,20 @@ namespace Ashwild.Building
/// </summary>
private int spawnToken;
/// <summary>
/// Last spot validity pushed to teammates (-1 = none sent yet, 0 = invalid, 1 = valid), so the
/// networked ghost overlay is only replicated when it actually flips — never every frame.
/// </summary>
private int lastSentValidity = -1;
private readonly Collider[] snapResults = new Collider[16];
private readonly Collider[] obstructionResults = new Collider[8];
/// <summary>
/// Floor for the padded half-extents, so a very thin piece (a floor slab a few centimetres deep)
/// cannot have the inset collapse its footprint to zero — or invert it — and stop testing at all.
/// </summary>
private const float MinHalfExtent = 0.01f;
/// <summary>
/// Reused buffer for the active build's cost lines, rebuilt and pushed to the crosshair each
@@ -163,7 +191,7 @@ namespace Ashwild.Building
{
PlayerEvents.BuildMenuOpenChanged += HandleMenuOpenChanged;
PlayerEvents.AttackPressed += HandleConfirm;
PlayerEvents.SecondaryUsePressed += HandleCancel;
PlayerEvents.BuildCancelRequested += HandleCancel;
PlayerEvents.HotbarScroll += HandleRotate;
PlayerEvents.BuildRotatePressed += HandleRotateKey;
PlayerEvents.DemolishModeChanged += HandleDemolishModeChanged;
@@ -177,7 +205,7 @@ namespace Ashwild.Building
{
PlayerEvents.BuildMenuOpenChanged -= HandleMenuOpenChanged;
PlayerEvents.AttackPressed -= HandleConfirm;
PlayerEvents.SecondaryUsePressed -= HandleCancel;
PlayerEvents.BuildCancelRequested -= HandleCancel;
PlayerEvents.HotbarScroll -= HandleRotate;
PlayerEvents.BuildRotatePressed -= HandleRotateKey;
PlayerEvents.DemolishModeChanged -= HandleDemolishModeChanged;
@@ -205,7 +233,7 @@ namespace Ashwild.Building
if (PlayerEvents.InputLocked) return;
UpdateGhostPose();
if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask);
EvaluateGhost();
}
#endregion
@@ -306,22 +334,107 @@ namespace Ashwild.Building
}
ghost = go;
ghostValidity = go.GetComponentInChildren<BuildGhost>(true);
ghostView = go.GetComponentInChildren<BuildGhost>(true);
ghostSnaps = go.GetComponentsInChildren<BuildSnapPoint>(true);
ResolveAim();
lastSentValidity = -1;
ghostIsConnected = false;
placementBeganFrame = Time.frameCount;
UpdateGhostPose();
if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask);
EvaluateGhost();
}
/// <summary>
/// Pushes the current verdict into the ghost's look each frame. No-op when no ghost is live.
/// </summary>
private void EvaluateGhost()
{
if (ghostView == null) return;
PlacementVerdict verdict = EvaluatePlacement();
ghostView.SetState(verdict);
ReplicateValidity(verdict == PlacementVerdict.Valid);
}
/// <summary>
/// The single answer to "can the active structure be committed at the ghost's current pose?",
/// used both to tint the preview and to gate the confirm — so what the player sees and what the
/// button does can never disagree. It is recomputed on demand rather than cached, since a cached
/// verdict would be one frame stale depending on the input/Update order.
///
/// The reasons are tested most-spatial first: a structure that needs a connection and has none is
/// reported before an obstruction, and both before affordability, so the player is always told
/// about the problem that moving the mouse can actually fix.
///
/// A CONNECTED ghost skips the obstruction test entirely: the socket already decided where the
/// piece belongs, and real modular geometry interpenetrates far more than any tolerance can absorb
/// (a wall's head runs up into the ceiling it supports, a frame overlaps the slab it sits in).
/// Shrinking the footprint was tried and is not enough — the only rule that holds is that an
/// authored connection IS the validity check. Free placement still tests obstructions normally,
/// so the padding below continues to matter there.
/// </summary>
private PlacementVerdict EvaluatePlacement()
{
if (active == null) return PlacementVerdict.Blocked;
if (active.Support == PlacementSupport.SnapOnly && !ghostIsConnected)
return PlacementVerdict.Unsupported;
if (!ghostIsConnected && IsFootprintObstructed()) return PlacementVerdict.Blocked;
if (!active.CanAfford(PlayerInventory.Instance)) return PlacementVerdict.Unaffordable;
return PlacementVerdict.Valid;
}
/// <summary>
/// Box-overlaps the ghost's footprint against the obstruction layers, ignoring triggers. The box
/// is shrunk by <see cref="obstructionPadding"/> on every side: snapped pieces meet exactly flush
/// by construction, so an un-inset test would report the very neighbour the player is building
/// against. The inset is small enough that a real intruder inside the volume — a chest, a player,
/// another structure — is still caught. A ghost without a footprint is never obstructed.
/// </summary>
private bool IsFootprintObstructed()
{
BoxCollider footprint = ghostView != null ? ghostView.Footprint : null;
if (footprint == null) return false;
Transform t = footprint.transform;
Vector3 center = t.TransformPoint(footprint.center);
Vector3 halfExtents = Vector3.Scale(footprint.size, t.lossyScale) * 0.5f;
halfExtents = Vector3.Max(halfExtents - Vector3.one * obstructionPadding, Vector3.one * MinHalfExtent);
int count = Physics.OverlapBoxNonAlloc(center, halfExtents, obstructionResults, t.rotation, obstructionMask, QueryTriggerInteraction.Ignore);
return count > 0;
}
/// <summary>
/// Pushes the ghost's spot validity to teammates through BuildRegistry, but only when it flips —
/// so a remote ghost shows the same green/red as here without a per-frame RPC stream. Affordability
/// stays local (the placer's private inventory), so only IsValid crosses the wire.
/// </summary>
private void ReplicateValidity(bool valid)
{
int state = valid ? 1 : 0;
if (state == lastSentValidity) return;
lastSentValidity = state;
if (BuildRegistry.Instance != null && ghost != null)
BuildRegistry.Instance.SetGhostValidity(ghost.GetComponent<NetworkObject>(), valid);
}
/// <summary>
/// Left-click: demolishes the aimed build while in demolition mode, otherwise commits the
/// ghost's pose to the registry (spawned for everyone) and consumes its resource cost from the
/// local inventory (client-authoritative, exactly like crafting). In continuous build mode it
/// then re-arms a fresh ghost of the same structure (keeping the current yaw, so pieces chain
/// edge-to-edge); otherwise it ends the placement. Blocked on an invalid spot, on the very frame
/// the ghost was attached, and when the player cannot afford the cost.
/// ghost's pose to the registry (spawned for everyone), spends a use of the equipped hammer and
/// consumes the resource cost from the local inventory (client-authoritative, exactly like
/// crafting). In continuous build mode it then re-arms a fresh ghost of the same structure
/// (keeping the current yaw, so pieces chain edge-to-edge); otherwise it ends the placement.
///
/// Placement itself is gated by the one verdict the preview is already showing, so the button can
/// never accept what the ghost paints as refused. The remaining guards are input/session
/// conditions rather than placement rules — input locked, the very frame the ghost was attached,
/// no registry in the session, and a hammer out of durability.
/// </summary>
private void HandleConfirm()
{
@@ -334,11 +447,11 @@ namespace Ashwild.Building
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
if (Time.frameCount == placementBeganFrame) return;
if (ghostValidity != null && !ghostValidity.IsValid) return;
if (active != null && !active.CanAfford(PlayerInventory.Instance)) return;
if (EvaluatePlacement() != PlacementVerdict.Valid) return;
if (BuildRegistry.Instance == null) return;
if (!TryConsumeHammerUse()) return;
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
ConsumeCost();
@@ -352,7 +465,9 @@ namespace Ashwild.Building
}
/// <summary>
/// Right-click: cancels the current placement (even while the ghost is still spawning).
/// The hammer's cancel request (a right-click tap while placing): cancels the current placement
/// (even while the ghost is still spawning). Routed through the bus so this stays the single
/// place that ends a placement — the hammer is the sole arbiter of the click.
/// </summary>
private void HandleCancel()
{
@@ -388,7 +503,7 @@ namespace Ashwild.Building
/// <summary>
/// Poses the ghost each frame: quantise the aim hit to the grid with the current yaw, then
/// let a nearby matching socket pull it into an exact connection (sockets win over the grid).
/// Leaves the ghost where it was when the ray hits nothing in range.
/// Leaves the ghost where it was — connection included — when the ray hits nothing in range.
/// </summary>
private void UpdateGhostPose()
{
@@ -397,7 +512,7 @@ namespace Ashwild.Building
return;
ghost.transform.SetPositionAndRotation(SnapToGrid(hit.point), Quaternion.Euler(0f, yaw, 0f));
TrySnapToSockets();
ghostIsConnected = TrySnapToSockets();
}
/// <summary>
@@ -419,11 +534,14 @@ namespace Ashwild.Building
/// coincides. Because it commits to the single nearest ghost-socket↔world-socket pair (not the
/// socket nearest the aim), the ghost picks one connection instead of hovering between two.
/// Rotation stays the player's grid yaw, so pieces meet edge-to-edge once oriented.
///
/// Returns whether a connection was actually made, which is what lets the caller trust the pose
/// and skip the obstruction test for it.
/// </summary>
private void TrySnapToSockets()
private bool TrySnapToSockets()
{
if (ghostSnaps == null || ghostSnaps.Length == 0) return;
if (snapPointMask == 0) return;
if (ghostSnaps == null || ghostSnaps.Length == 0) return false;
if (snapPointMask == 0) return false;
BuildSnapPoint bestGhost = null;
BuildSnapPoint bestWorld = null;
@@ -451,9 +569,10 @@ namespace Ashwild.Building
}
}
if (bestGhost == null || bestWorld == null) return;
if (bestGhost == null || bestWorld == null) return false;
ghost.transform.position += bestWorld.transform.position - bestGhost.transform.position;
return true;
}
/// <summary>
@@ -505,8 +624,10 @@ namespace Ashwild.Building
BuildRegistry.Instance.RequestDespawnGhost(ghost.GetComponent<NetworkObject>());
ghost = null;
}
ghostValidity = null;
ghostView = null;
ghostSnaps = null;
lastSentValidity = -1;
ghostIsConnected = false;
}
/// <summary>
@@ -638,14 +759,31 @@ namespace Ashwild.Building
}
/// <summary>
/// Left-click while demolishing: asks the registry to remove the aimed build for everyone. The
/// target stays until the next frame re-evaluates, so a miss simply retargets.
/// Left-click while demolishing: spends a hammer use and asks the registry to remove the aimed
/// build for everyone (which refunds resources by remaining health). Blocked when the hammer is
/// out of durability. The target stays until the next frame re-evaluates, so a miss simply
/// retargets.
/// </summary>
private void TryDemolish()
{
if (demoTarget == null) return;
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestDemolish(demoTarget.gameObject);
if (!TryConsumeHammerUse()) return;
demoTarget.RequestDemolish();
}
/// <summary>
/// Spends the configured durability on the equipped hammer for one build action, returning
/// whether the action may proceed. A depleted hammer returns false so the placement/demolition
/// is blocked (like any worn-out tool); a hammer with no durability authored, a zero cost, or no
/// inventory simply passes without wear. The hammer is the selected hotbar item while building,
/// so spending the selected tool's use targets it.
/// </summary>
private bool TryConsumeHammerUse()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return true;
return inv.ConsumeSelectedToolUse(hammerUsesPerAction);
}
/// <summary>
@@ -1,16 +0,0 @@
using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// A committed structure in the world, replicated in the BuildRegistry's SyncList: which
/// buildable (network id) sits at which pose. Every machine instantiates the matching
/// BuiltPrefab locally from this record — the structures themselves are never NetworkObjects (§3).
/// </summary>
public struct BuildRecord
{
public ushort buildableId;
public Vector3 position;
public Quaternion rotation;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1554bdf5a966a1947bbe0d2855967590
+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
+31 -7
View File
@@ -24,8 +24,9 @@ namespace Ashwild.Building
/// of the ghost, so the ghost never snaps to itself.
///
/// Once another piece connects here the socket is marked occupied — the placement snapping skips
/// occupied sockets (so two pieces never stack on the same connection) and its gizmo turns from
/// blue (free) to red (occupied) in the Scene view.
/// occupied sockets (so two pieces never stack on the same connection) and its gizmo turns red
/// in the Scene view; while free it takes the colour of its category, so a glance at a prefab
/// tells which sockets can ever link together.
/// </summary>
[DisallowMultipleComponent]
public class BuildSnapPoint : MonoBehaviour
@@ -61,19 +62,42 @@ namespace Ashwild.Building
#region Gizmos
private static readonly Color OccupiedColor = new Color(1f, 0.3f, 0.3f, 0.9f);
private static readonly Color FloorColor = new Color(0.3f, 0.8f, 1f, 0.9f);
private static readonly Color WallColor = new Color(0.4f, 1f, 0.5f, 0.9f);
private static readonly Color RoofColor = new Color(1f, 0.75f, 0.25f, 0.9f);
private static readonly Color PillarColor = new Color(0.8f, 0.5f, 1f, 0.9f);
private static readonly Color CustomColor = new Color(1f, 1f, 1f, 0.9f);
/// <summary>
/// Draws the socket in the editor: a sphere (blue when free, red once occupied) plus a ray
/// along its forward (Z) axis — the direction the connecting piece attaches. Two sockets link
/// only when their forwards face each other, so orient each empty's blue arrow outward toward
/// where the neighbour should sit.
/// Draws the socket in the editor: a sphere plus a ray along its forward (Z) axis — the
/// direction the connecting piece attaches. Two sockets link only when their forwards face
/// each other, so orient each empty's arrow outward toward where the neighbour should sit.
/// </summary>
private void OnDrawGizmos()
{
Gizmos.color = occupied ? new Color(1f, 0.3f, 0.3f, 0.9f) : new Color(0.3f, 0.8f, 1f, 0.9f);
Gizmos.color = occupied ? OccupiedColor : GetCategoryColor(category);
Gizmos.DrawWireSphere(transform.position, 0.12f);
Gizmos.DrawRay(transform.position, transform.forward * 0.35f);
}
/// <summary>
/// Maps a connection category to its Scene-view colour. Sockets that can link share a
/// category, so same-coloured gizmos are exactly the ones that may ever snap together —
/// mismatched colours on two pieces mean they will never connect.
/// </summary>
private static Color GetCategoryColor(SnapCategory value)
{
switch (value)
{
case SnapCategory.Floor: return FloorColor;
case SnapCategory.Wall: return WallColor;
case SnapCategory.Roof: return RoofColor;
case SnapCategory.Pillar: return PillarColor;
default: return CustomColor;
}
}
#endregion
}
}
@@ -3,6 +3,25 @@ using Ashwild.Inventory;
namespace Ashwild.Building
{
/// <summary>
/// What a buildable is allowed to rest on. This is the one placement rule that genuinely differs
/// per structure — a foundation sits on terrain, a wall or a ceiling only makes sense attached to
/// something — so it is authored here rather than configured globally on BuildManager (which keeps
/// owning the project-wide layer masks: what blocks a build does not vary per build).
///
/// <see cref="GroundOrSnap"/> is deliberately the zero value: Unity deserializes a missing field to
/// 0, so buildables authored before this rule existed keep their old, permissive behaviour instead
/// of silently becoming unplaceable.
/// </summary>
public enum PlacementSupport
{
/// <summary>Free placement anywhere the aim lands, or connected to a socket. Foundations, floors.</summary>
GroundOrSnap,
/// <summary>Only valid while connected to a matching socket. Walls, ceilings, roofs.</summary>
SnapOnly
}
/// <summary>
/// Authoring data for one buildable structure shown in the construction menu (a wall, a
/// floor, a door, ...). Mirrors the ScriptableObject-for-data / MonoBehaviour-for-logic
@@ -35,6 +54,14 @@ namespace Ashwild.Building
[Tooltip("Resources consumed from the builder's inventory on each placement. Leave empty for a free build.")]
[SerializeField] private BuildCost[] cost;
[Header("Placement")]
[Tooltip("Where this structure may be committed. Walls, ceilings and roofs should be Snap Only so they cannot be dropped in mid-air; foundations and floors stay Ground Or Snap.")]
[SerializeField] private PlacementSupport support = PlacementSupport.GroundOrSnap;
[Header("Health")]
[Tooltip("Hit points the placed structure starts with. Damage subtracts from this (server-authoritative in BuildRegistry); at 0 the build is destroyed. A manual demolition refunds the cost in proportion to the health left.")]
[SerializeField] private float maxHealth = 100f;
#endregion
#region Public API
@@ -45,6 +72,8 @@ namespace Ashwild.Building
public GameObject GhostPrefab => ghostPrefab;
public GameObject BuiltPrefab => builtPrefab;
public BuildCost[] Cost => cost;
public float MaxHealth => maxHealth;
public PlacementSupport Support => support;
/// <summary>
/// Whether the given inventory holds enough of every cost line to place this structure once.
+310 -18
View File
@@ -1,34 +1,106 @@
using System.Collections.Generic;
using Ashwild.Inventory;
using DG.Tweening;
using FishNet.Connection;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// Runtime marker added to every committed structure instance the BuildRegistry spawns, so the
/// demolition aim can recognise a build from a raycast hit (GetComponentInParent) and hand its
/// root back to the registry to remove. It also owns the demolition highlight: while it is the
/// hammer's target the demolition material is added on top of every renderer (an extra overlay
/// pass, like the ghost), and removed again when the aim leaves — so the player sees exactly which
/// build will break. Pure local visual + a pure tag: nothing about it ever crosses the wire.
/// The one component every committed structure carries — wall, floor, chest, campfire alike. Since
/// builds are spawned as real NetworkObjects, each one is self-contained: it knows what it is, owns
/// its own server-authoritative health, and handles its own damage and demolition. There is no
/// central list of builds and no index to keep aligned; the object IS the record.
///
/// It groups everything that changes together when a placed build's behaviour changes:
/// 1. Identity — <see cref="BuildableId"/>, replicated so any machine can resolve the BuildableData
/// behind this instance (the refund needs its cost, UI needs its name).
/// 2. Health and damage — server-authoritative. <see cref="TakeDamage"/> is what an attacker calls;
/// the server applies it and despawns the build for everyone when it dies (no refund — it broke).
/// 3. Demolition — <see cref="RequestDemolish"/> refunds a share of the cost proportional to the
/// health left, then despawns.
/// 4. Presentation — the appear bump and the demolition highlight, both purely local.
/// 5. Snap occupancy — every live instance registers here, so connected sockets can be marked
/// without anyone holding a master list of builds.
///
/// Health never crosses the wire: only its observable effects do (the build disappearing, resources
/// refunded), which replicate through the despawn and the inventory RPC respectively.
/// </summary>
[DisallowMultipleComponent]
public class BuiltStructure : MonoBehaviour
[RequireComponent(typeof(NetworkObject))]
public class BuiltStructure : NetworkBehaviour
{
#region Serialized Fields
[Header("Appear Bump")]
[Tooltip("How long the pop lasts — short and snappy reads as an 'appear' effect.")]
[SerializeField] private float bumpDuration = DefaultBumpDuration;
[Tooltip("Fraction of the authored scale the bump starts from (0.7 = starts at 70%, then springs up).")]
[SerializeField] private float startScaleFactor = DefaultStartScaleFactor;
[Tooltip("OutBack gives the springy overshoot that sells the 'boom'.")]
[SerializeField] private Ease bumpEase = Ease.OutBack;
#endregion
#region Constants
/// <summary>
/// Fallback bump values, used both as the field initializers and as the guard defaults in
/// <see cref="PlaySpawnBump"/> — so a build whose prefab predates these serialized fields
/// (Unity deserializes the missing values to 0) still pops instead of snapping in.
/// </summary>
private const float DefaultBumpDuration = 0.22f;
private const float DefaultStartScaleFactor = 0.7f;
/// <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 or radius has to be configured.
/// </summary>
private const float OccupancyThresholdSqr = 0.05f * 0.05f;
#endregion
#region State
/// <summary>
/// Every child renderer, cached so the highlight can swap and restore their materials.
/// Every live built structure on this machine. Maintained here rather than in a central registry
/// so occupancy works off the objects themselves — the network spawns and despawns them, and the
/// list follows automatically.
/// </summary>
private static readonly List<BuiltStructure> Live = new List<BuiltStructure>();
/// <summary>
/// Which buildable this instance was placed from, so the refund can look up its cost. Written by
/// the server before the spawn, so it is already correct on the first frame everywhere.
/// </summary>
private readonly SyncVar<ushort> buildableId = new SyncVar<ushort>();
/// <summary>
/// Server-only remaining health, seeded from the buildable's authored max at spawn.
/// </summary>
private float health;
private Renderer[] renderers;
/// <summary>
/// Each renderer's original materials, kept so the swap is fully reversible.
/// </summary>
private Material[][] baseMaterials;
private bool highlighted;
private Tween bumpTween;
#endregion
#region Public API
public ushort BuildableId => buildableId.Value;
/// <summary>
/// Whether the demolition material is currently applied, so swaps stay idempotent.
/// The authoring data behind this instance, or null when the database cannot resolve its id.
/// </summary>
private bool highlighted;
public BuildableData Data => BuildableDatabase.Instance != null
? BuildableDatabase.Instance.GetBuildable(buildableId.Value)
: null;
#endregion
@@ -46,14 +118,234 @@ namespace Ashwild.Building
baseMaterials[i] = renderers[i] != null ? renderers[i].sharedMaterials : new Material[0];
}
/// <summary>
/// Kills the appear tween so it never targets a destroyed transform (e.g. the build was
/// demolished mid-pop).
/// </summary>
private void OnDestroy() => bumpTween?.Kill();
#endregion
#region Public API
#region Network Lifecycle
/// <summary>
/// Adds the demolition material on top of each renderer's authored materials (an extra draw
/// pass), marking this build as the hammer's current target without hiding the model. Mirrors
/// the ghost overlay. No-op when already highlighted or no material was provided.
/// Joins the live set and marks the sockets this piece connects to. Runs on every machine as the
/// object spawns — including on a late joiner receiving builds placed long ago, which is exactly
/// when occupancy has to be rebuilt for them too.
/// </summary>
public override void OnStartNetwork()
{
base.OnStartNetwork();
Live.Add(this);
MarkOccupancy();
}
/// <summary>
/// Leaves the live set and recomputes occupancy across what remains. Without the recompute a
/// demolished piece would leave its former neighbours' sockets stuck "occupied", so nothing could
/// ever be snapped back into the gap.
/// </summary>
public override void OnStopNetwork()
{
base.OnStopNetwork();
Live.Remove(this);
RecomputeOccupancy();
}
#endregion
#region Server Setup
/// <summary>
/// Seeds identity and health on the server, before the object is spawned, so both are already in
/// place when clients first see it. Called by BuildRegistry as part of committing a placement.
/// </summary>
public void InitialiseOnServer(ushort id, float maxHealth)
{
buildableId.Value = id;
health = maxHealth > 0f ? maxHealth : 1f;
}
#endregion
#region Appear Bump
/// <summary>
/// Tells everyone currently watching to play the appear pop. Sent by the server right after a
/// fresh placement. Because it only reaches present observers, a late joiner streaming in dozens
/// of existing structures never receives it — so their base does not pop into existence all at
/// once, which is precisely the distinction the old "only on Add, never on rebuild" rule made.
/// </summary>
[ObserversRpc]
public void PlaySpawnBumpForObservers() => PlaySpawnBump();
/// <summary>
/// Runs the appear bump from the shrunk start scale up to the transform's current (authored)
/// scale. Captures the target at call time so a non-uniform or non-unit prefab scale is preserved.
/// Non-positive serialized values fall back to the constants so the pop still plays.
/// </summary>
public void PlaySpawnBump()
{
bumpTween?.Kill();
float duration = bumpDuration > 0f ? bumpDuration : DefaultBumpDuration;
float factor = (startScaleFactor > 0f && startScaleFactor < 1f) ? startScaleFactor : DefaultStartScaleFactor;
Vector3 targetScale = transform.localScale;
transform.localScale = targetScale * factor;
bumpTween = transform.DOScale(targetScale, duration).SetEase(bumpEase);
}
#endregion
#region Damage
/// <summary>
/// Called by an attacker (weapon, explosion, ...) to hurt this build. Health is server-
/// authoritative, so this only forwards the hit; the server applies it and despawns the structure
/// for everyone when it dies. A build destroyed by damage is NOT refunded, unlike a manual
/// demolition — it broke, its resources are lost. Safe to call from any client.
/// </summary>
public void TakeDamage(float amount)
{
if (amount <= 0f) return;
TakeDamageServerRpc(amount);
}
/// <summary>
/// Server-side: subtracts the damage and despawns the build once it reaches zero.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void TakeDamageServerRpc(float amount)
{
if (amount <= 0f) return;
health -= amount;
if (health <= 0f) Despawn();
}
#endregion
#region Demolition
/// <summary>
/// Called by BuildManager when the local player demolishes this build: asks the server to refund
/// and remove it. Any client may request it — co-op is friendly, not anti-cheat.
/// </summary>
public void RequestDemolish() => DemolishServerRpc();
/// <summary>
/// Server-side: refunds the demolisher a share of the cost proportional to the health left, then
/// despawns the structure for everyone.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void DemolishServerRpc(NetworkConnection conn = null)
{
RefundResources(conn);
Despawn();
}
/// <summary>
/// Grants the demolishing player back a share of this build's cost proportional to its remaining
/// health: a full-health build refunds its whole cost, a half-health one refunds half, and so on
/// (per line, floored — a partial unit is not returned). No-op for a free build, when the player's
/// inventory cannot be resolved, or when the fraction rounds every line down to nothing.
/// </summary>
private void RefundResources(NetworkConnection conn)
{
BuildableData data = Data;
if (data == null || data.Cost == null || data.Cost.Length == 0) return;
PlayerInventory inventory = ResolveInventory(conn);
if (inventory == null) return;
float max = data.MaxHealth;
float fraction = max > 0f ? Mathf.Clamp01(health / max) : 1f;
foreach (BuildCost line in data.Cost)
{
if (line.item == null) continue;
int refund = Mathf.FloorToInt(line.quantity * fraction);
if (refund > 0) inventory.GrantItemFromServer(line.item, refund);
}
}
/// <summary>
/// Returns the PlayerInventory on the player object owned by the given connection, mirroring
/// CookingStation so refunds land in the requester's own inventory.
/// </summary>
private static PlayerInventory ResolveInventory(NetworkConnection conn)
{
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
}
/// <summary>
/// Removes this structure for every player. Server-side only.
/// </summary>
private void Despawn()
{
if (base.IsServerInitialized) base.ServerManager.Despawn(base.NetworkObject, DespawnType.Destroy);
}
#endregion
#region Snap Occupancy
/// <summary>
/// Marks the sockets this 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()
{
BuildSnapPoint[] mine = GetComponentsInChildren<BuildSnapPoint>(true);
if (mine.Length == 0) return;
foreach (BuiltStructure other in Live)
{
if (other == null || other == this) continue;
foreach (BuildSnapPoint os in other.GetComponentsInChildren<BuildSnapPoint>(true))
{
foreach (BuildSnapPoint ms in mine)
{
if (ms.Category != os.Category) continue;
if ((ms.transform.position - os.transform.position).sqrMagnitude > OccupancyThresholdSqr) continue;
ms.SetOccupied(true);
os.SetOccupied(true);
}
}
}
}
/// <summary>
/// Recomputes occupancy from scratch across every live structure: clears every socket, then
/// re-marks the connected pairs across what remains. Run after a removal.
/// </summary>
private static void RecomputeOccupancy()
{
foreach (BuiltStructure structure in Live)
{
if (structure == null) continue;
foreach (BuildSnapPoint sp in structure.GetComponentsInChildren<BuildSnapPoint>(true))
sp.SetOccupied(false);
}
foreach (BuiltStructure structure in Live)
if (structure != null) structure.MarkOccupancy();
}
#endregion
#region Demolition Highlight
/// <summary>
/// Adds the demolition material on top of each renderer's authored materials (an extra draw pass),
/// marking this build as the hammer's current target without hiding the model. Mirrors the ghost
/// overlay. No-op when already highlighted or no material was provided.
/// </summary>
public void Highlight(Material demolitionMaterial)
{