using System.Collections.Generic;
using FishNet.Object;
using UnityEngine;
using Ashwild.Inventory;
using Ashwild.Player;
namespace Ashwild.Building
{
///
/// The whole local build experience in one place: it owns the buildable catalog, populates the
/// menu view when it opens, and drives placement once a card is picked — aim, grid quantise,
/// socket snapping, validity and confirm/cancel. This is the deliberate cohesive boundary: all of
/// "what the local player does to build" lives here, while the shared, server-authoritative side
/// (committed structures + the networked ghost object) is the separate BuildRegistry. One clean
/// seam between local UX and networked world state — not a split per micro-responsibility.
///
/// The ghost is a real NetworkObject: on a card pick this asks BuildRegistry to spawn one owned by
/// us, which hands it back through ; we then drive its transform locally
/// (client-authoritative NetworkTransform replicates it to teammates). On confirm we commit the
/// pose to BuildRegistry and despawn the ghost. A single scene instance, driven by the local-only
/// bus — no per-player copies, no owner gating. Menu view stays a dumb, data-agnostic view.
///
[DisallowMultipleComponent]
public class BuildManager : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private BuildMenuUI menuUI;
[Header("Catalog")]
[Tooltip("Every structure the player can build, shown as cards in this order.")]
[SerializeField] private BuildableData[] catalog;
[Header("Aim")]
[Tooltip("Optional camera whose forward is the aim ray. Leave empty to use Camera.main — the local player's FPS camera at runtime.")]
[SerializeField] private Camera aimCamera;
[Tooltip("Maximum distance, in metres, at which the ghost can be placed.")]
[SerializeField] private float placeRange = 8f;
[Tooltip("Surfaces the ghost is allowed to snap onto (ground, existing structures).")]
[SerializeField] private LayerMask groundMask = ~0;
[Header("Grid Fallback")]
[Tooltip("Quantise the ghost to a world grid when no socket is in range. Off = free placement.")]
[SerializeField] private bool useGrid = true;
[Tooltip("World cell size the ghost quantises to when the grid is on and no socket is in range.")]
[SerializeField] private float gridSize = 1f;
[Tooltip("Degrees the ghost yaws per scroll notch while placing.")]
[SerializeField] private float rotationStep = 90f;
[Header("Placement Mode")]
[Tooltip("Continuous build: after confirming a placement, immediately re-arm another ghost of the same structure (to chain walls/floors) instead of returning to idle. Off = place one at a time.")]
[SerializeField] private bool continuousBuild = false;
[Header("Snapping")]
[Tooltip("Radius around the aim in which a matching existing socket pulls the ghost in.")]
[SerializeField] private float snapRadius = 1.25f;
[Tooltip("Layer of the snap-point trigger colliders on built structures.")]
[SerializeField] private LayerMask snapPointMask;
[Header("Validity")]
[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;
[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
///
/// The single scene instance, so BuildRegistry can hand a freshly spawned ghost back here.
///
public static BuildManager Instance { get; private set; }
///
/// The buildables currently shown, in card order, so a picked index maps back to its data.
///
private readonly List visible = new List();
///
/// The buildable being positioned (and its network id), or null/0 when idle.
///
private BuildableData active;
private ushort activeId;
///
/// The live networked ghost we own and drive, or null while idle or awaiting its spawn.
///
private GameObject ghost;
private BuildGhost ghostView;
private BuildSnapPoint[] ghostSnaps;
///
/// 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.
///
private bool ghostIsConnected;
///
/// The ghost's current yaw for grid placement, stepped by the scroll wheel.
///
private float yaw;
///
/// The local player's aim transform (camera), resolved lazily and shared by placement and
/// demolition.
///
private Transform aim;
///
/// The committed build currently under the demolition aim, or null when none is targeted.
///
private BuiltStructure demoTarget;
///
/// Frame the ghost was attached, so a click landing that frame never confirms instantly.
///
private int placementBeganFrame;
///
/// Bumped on every spawn request so a ghost from a superseded request (picked again during
/// the spawn round-trip) is rejected and despawned instead of leaking into the scene.
///
private int spawnToken;
///
/// 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.
///
private int lastSentValidity = -1;
private readonly Collider[] snapResults = new Collider[16];
private readonly Collider[] obstructionResults = new Collider[8];
///
/// 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.
///
private const float MinHalfExtent = 0.01f;
///
/// Reused buffer for the active build's cost lines, rebuilt and pushed to the crosshair each
/// time the price or its affordability changes — so no per-refresh allocation.
///
private readonly List costView = new List();
#endregion
#region Unity Lifecycle
///
/// Registers the scene singleton BuildRegistry calls back into.
///
private void Awake()
{
Instance = this;
}
///
/// Clears the singleton — mirrors Awake.
///
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
///
/// Subscribes to the menu open state and the placement inputs while enabled.
///
private void OnEnable()
{
PlayerEvents.BuildMenuOpenChanged += HandleMenuOpenChanged;
PlayerEvents.AttackPressed += HandleConfirm;
PlayerEvents.BuildCancelRequested += HandleCancel;
PlayerEvents.HotbarScroll += HandleRotate;
PlayerEvents.BuildRotatePressed += HandleRotateKey;
PlayerEvents.DemolishModeChanged += HandleDemolishModeChanged;
PlayerEvents.InventorySlotChanged += HandleInventoryChanged;
}
///
/// Unsubscribes and tears down any live placement — mirrors OnEnable exactly.
///
private void OnDisable()
{
PlayerEvents.BuildMenuOpenChanged -= HandleMenuOpenChanged;
PlayerEvents.AttackPressed -= HandleConfirm;
PlayerEvents.BuildCancelRequested -= HandleCancel;
PlayerEvents.HotbarScroll -= HandleRotate;
PlayerEvents.BuildRotatePressed -= HandleRotateKey;
PlayerEvents.DemolishModeChanged -= HandleDemolishModeChanged;
PlayerEvents.InventorySlotChanged -= HandleInventoryChanged;
EndPlacement();
ClearDemoTarget();
}
///
/// Each frame, in exactly one mode: in demolition mode it tracks which build the player aims
/// at; otherwise it drives the owned ghost along the aim. Both are frozen while input is locked
/// (e.g. the menu reopened over a placement), and leaving demolition mode clears the target.
///
private void Update()
{
if (PlayerEvents.IsDemolishing && !PlayerEvents.InputLocked)
{
UpdateDemolitionTarget();
return;
}
ClearDemoTarget();
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
UpdateGhostPose();
EvaluateGhost();
}
#endregion
#region Menu
///
/// Populates the menu view from the catalog when it opens; nothing to do on close.
///
private void HandleMenuOpenChanged(bool open)
{
if (!open) return;
PopulateMenu();
}
///
/// Builds one card payload per non-null catalog entry and pushes it into the menu view,
/// keeping a parallel list so a picked index maps back to its buildable.
///
private void PopulateMenu()
{
if (menuUI == null)
{
Debug.LogError("[BuildManager] No BuildMenuUI assigned — cannot populate the menu.", this);
return;
}
visible.Clear();
List cards = new List();
foreach (BuildableData data in catalog)
{
if (data == null) continue;
visible.Add(data);
cards.Add(new BuildCardView(data.Icon, data.DisplayName));
}
menuUI.Populate(cards, HandleCardSelected);
}
///
/// A card was picked: close the menu (which unlocks input) and start positioning the chosen
/// structure — its ghost is spawned networked and handed back via AttachGhost.
///
private void HandleCardSelected(int index)
{
if (index < 0 || index >= visible.Count) return;
BuildableData data = visible[index];
if (data == null || data.GhostPrefab == null)
{
Debug.LogError("[BuildManager] Picked buildable is null or has no Ghost Prefab.", this);
return;
}
ushort id = BuildableDatabase.Instance != null ? BuildableDatabase.Instance.GetId(data) : (ushort)0;
if (id == 0)
{
Debug.LogError($"[BuildManager] '{data.DisplayName}' absent de la BuildableDatabase — lance Tools ▸ Ashwild ▸ Rebuild Buildable Database.", this);
return;
}
if (BuildRegistry.Instance == null)
{
Debug.LogError("[BuildManager] No BuildRegistry in the scene / session — cannot spawn the ghost.", this);
return;
}
EndPlacement();
active = data;
activeId = id;
yaw = 0f;
int token = ++spawnToken;
PlayerEvents.RaiseBuildMenuToggleRequested();
PlayerEvents.RaiseBuildPlacingChanged(true);
PublishCostView();
BuildRegistry.Instance.RequestSpawnGhost(id, token);
}
#endregion
#region Placement
///
/// Receives the networked ghost BuildRegistry spawned for us: caches its validity/snap
/// components, resolves the aim and starts driving it. A ghost from a superseded or already
/// cancelled request (its token no longer matches) is despawned immediately, so no orphan
/// lingers even when the player re-picks during the spawn round-trip.
///
public void AttachGhost(GameObject go, int token)
{
if (go == null) return;
if (token != spawnToken || active == null)
{
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestDespawnGhost(go.GetComponent());
return;
}
ghost = go;
ghostView = go.GetComponentInChildren(true);
ghostSnaps = go.GetComponentsInChildren(true);
ResolveAim();
lastSentValidity = -1;
ghostIsConnected = false;
placementBeganFrame = Time.frameCount;
UpdateGhostPose();
EvaluateGhost();
}
///
/// Pushes the current verdict into the ghost's look each frame. No-op when no ghost is live.
///
private void EvaluateGhost()
{
if (ghostView == null) return;
PlacementVerdict verdict = EvaluatePlacement();
ghostView.SetState(verdict);
ReplicateValidity(verdict == PlacementVerdict.Valid);
}
///
/// 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.
///
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;
}
///
/// Box-overlaps the ghost's footprint against the obstruction layers, ignoring triggers. The box
/// is shrunk by 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.
///
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;
}
///
/// 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.
///
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(), valid);
}
///
/// Left-click: demolishes the aimed build while in demolition mode, otherwise commits the
/// 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.
///
private void HandleConfirm()
{
if (PlayerEvents.IsDemolishing)
{
TryDemolish();
return;
}
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
if (Time.frameCount == placementBeganFrame) return;
if (EvaluatePlacement() != PlacementVerdict.Valid) return;
if (BuildRegistry.Instance == null) return;
if (!TryConsumeHammerUse()) return;
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
ConsumeCost();
if (continuousBuild && active != null)
{
RearmGhost();
return;
}
EndPlacement();
}
///
/// 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.
///
private void HandleCancel()
{
if (active == null) return;
EndPlacement();
}
///
/// Scroll wheel: yaws the ghost by one rotation step (repurposed from the hotbar, which
/// ignores the scroll while a build is being placed).
///
private void HandleRotate(float scroll)
{
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
if (scroll > 0f) yaw += rotationStep;
else if (scroll < 0f) yaw -= rotationStep;
}
///
/// R key: yaws the ghost by one rotation step in the same direction as a scroll-up, so the
/// build can be turned without the mouse wheel. Ignored when no ghost is being placed.
///
private void HandleRotateKey()
{
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
yaw += rotationStep;
}
///
/// 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 — connection included — when the ray hits nothing in range.
///
private void UpdateGhostPose()
{
if (aim == null) return;
if (!Physics.Raycast(aim.position, aim.forward, out RaycastHit hit, placeRange, groundMask))
return;
ghost.transform.SetPositionAndRotation(SnapToGrid(hit.point), Quaternion.Euler(0f, yaw, 0f));
ghostIsConnected = TrySnapToSockets();
}
///
/// Rounds the X/Z of a point to the grid, keeping its height. No-op when the grid is
/// toggled off or its cell size is non-positive (free placement).
///
private Vector3 SnapToGrid(Vector3 point)
{
if (!useGrid || gridSize <= 0f) return point;
float x = Mathf.Round(point.x / gridSize) * gridSize;
float z = Mathf.Round(point.z / gridSize) * gridSize;
return new Vector3(x, point.y, z);
}
///
/// Snaps the ghost to a single existing socket: across all of the ghost's own sockets, finds
/// the closest free compatible world socket within snap range and translates so that one pair
/// 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.
///
private bool TrySnapToSockets()
{
if (ghostSnaps == null || ghostSnaps.Length == 0) return false;
if (snapPointMask == 0) return false;
BuildSnapPoint bestGhost = null;
BuildSnapPoint bestWorld = null;
float bestSqr = snapRadius * snapRadius;
foreach (BuildSnapPoint gs in ghostSnaps)
{
if (gs == null) continue;
int count = Physics.OverlapSphereNonAlloc(gs.transform.position, snapRadius, snapResults, snapPointMask, QueryTriggerInteraction.Collide);
for (int i = 0; i < count; i++)
{
BuildSnapPoint world = snapResults[i].GetComponentInParent();
if (world == null) continue;
if (world.IsOccupied) continue;
if (world.Category != gs.Category) continue;
if (world.transform.IsChildOf(ghost.transform)) continue;
// Only connect sockets that face each other (opposite forwards), so a piece lands
// adjacent instead of overlapping the one it snaps to.
if (Vector3.Dot(gs.transform.forward, world.transform.forward) > -0.5f) continue;
float sqr = (gs.transform.position - world.transform.position).sqrMagnitude;
if (sqr < bestSqr) { bestSqr = sqr; bestGhost = gs; bestWorld = world; }
}
}
if (bestGhost == null || bestWorld == null) return false;
ghost.transform.position += bestWorld.transform.position - bestGhost.transform.position;
return true;
}
///
/// Ends the current placement: despawns the networked ghost, clears state and flags the bus
/// idle. Safe to call when nothing is in flight.
///
private void EndPlacement()
{
DespawnGhost();
if (active != null)
{
active = null;
activeId = 0;
PlayerEvents.RaiseBuildPlacingChanged(false);
PlayerEvents.RaiseBuildCostChanged(null);
}
}
///
/// Continuous build: after committing a piece, drops the spent ghost and asks the registry for a
/// fresh one of the same structure so the player keeps placing without reopening the menu. Keeps
/// / and the current yaw, and stays "placing" on the
/// bus. The token bump makes any in-flight ghost from the old request reject itself in AttachGhost.
///
private void RearmGhost()
{
DespawnGhost();
if (BuildRegistry.Instance == null)
{
EndPlacement();
return;
}
int token = ++spawnToken;
BuildRegistry.Instance.RequestSpawnGhost(activeId, token);
}
///
/// Despawns the networked ghost we own and clears its cached components, without touching the
/// active buildable or the bus. Safe to call when no ghost is live.
///
private void DespawnGhost()
{
if (ghost != null)
{
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestDespawnGhost(ghost.GetComponent());
ghost = null;
}
ghostView = null;
ghostSnaps = null;
lastSentValidity = -1;
ghostIsConnected = false;
}
///
/// Resolves and caches the local player's aim transform — the assigned camera, else the
/// runtime Camera.main (the FPS camera). Logs once when neither is found.
///
private Transform ResolveAim()
{
if (aim != null) return aim;
aim = aimCamera != null ? aimCamera.transform : (Camera.main != null ? Camera.main.transform : null);
if (aim == null)
Debug.LogError("[BuildManager] Pas de caméra d'aim (Camera.main introuvable — la caméra du joueur est-elle taguée MainCamera ?).", this);
return aim;
}
#endregion
#region Build Cost
///
/// Refreshes the crosshair cost display when the inventory changes mid-placement (a resource was
/// spent or picked up), so its affordability colouring stays live. No-op while idle.
///
private void HandleInventoryChanged(int slotIndex)
{
if (active == null) return;
PublishCostView();
}
///
/// Rebuilds the active build's cost lines into the reused buffer — each with the local player's
/// current affordability — and pushes them to the crosshair via the bus. Clears the display when
/// idle or when the build is free (no cost lines).
///
private void PublishCostView()
{
BuildCost[] cost = active != null ? active.Cost : null;
if (cost == null || cost.Length == 0)
{
PlayerEvents.RaiseBuildCostChanged(null);
return;
}
PlayerInventory inv = PlayerInventory.Instance;
costView.Clear();
foreach (BuildCost line in cost)
{
if (line.item == null) continue;
int owned = inv != null ? inv.CountItem(line.item) : 0;
bool affordable = owned >= line.quantity;
costView.Add(new BuildCostView(line.item.Icon, owned, line.quantity, affordable));
}
PlayerEvents.RaiseBuildCostChanged(costView);
}
///
/// Spends the active build's cost from the local inventory after a committed placement. Client-
/// authoritative like crafting — the inventory change refreshes the cost view through
/// . No-op for a free build or when offline.
///
private void ConsumeCost()
{
BuildCost[] cost = active != null ? active.Cost : null;
if (cost == null || cost.Length == 0) return;
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
foreach (BuildCost line in cost)
{
if (line.item == null) continue;
inv.RemoveItemByData(line.item, line.quantity);
}
}
#endregion
#region Public API
///
/// Whether confirming a placement immediately re-arms another ghost of the same structure
/// (continuous build) instead of returning to idle. Settable so a key or menu toggle can flip it.
///
public bool ContinuousBuild
{
get => continuousBuild;
set => continuousBuild = value;
}
///
/// Flips continuous build on/off — wire this to a key or a menu toggle so the player can switch
/// between chaining placements and placing one at a time.
///
public void ToggleContinuousBuild() => continuousBuild = !continuousBuild;
#endregion
#region Demolition
///
/// Leaving demolition mode drops any current target so the crosshair returns to normal; entering
/// it needs nothing here — Update starts tracking on the next frame.
///
private void HandleDemolishModeChanged(bool on)
{
if (!on) ClearDemoTarget();
}
///
/// Casts the aim ray for a committed build and moves the demolition highlight when the target
/// changes: the previous build removes its overlay and the new one gets the demolition material
/// added on top, so exactly the aimed build reads as "about to break".
///
private void UpdateDemolitionTarget()
{
Transform a = ResolveAim();
if (a == null) { ClearDemoTarget(); return; }
BuiltStructure hit = null;
if (Physics.Raycast(a.position, a.forward, out RaycastHit info, placeRange, buildMask, QueryTriggerInteraction.Ignore))
hit = info.collider.GetComponentInParent();
if (hit == demoTarget) return;
if (demoTarget != null) demoTarget.ClearHighlight();
demoTarget = hit;
if (demoTarget != null) demoTarget.Highlight(demolitionMaterial);
}
///
/// 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.
///
private void TryDemolish()
{
if (demoTarget == null) return;
if (!TryConsumeHammerUse()) return;
demoTarget.RequestDemolish();
}
///
/// 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.
///
private bool TryConsumeHammerUse()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return true;
return inv.ConsumeSelectedToolUse(hammerUsesPerAction);
}
///
/// Drops the current demolition target, restoring its authored materials. Safe to call when
/// nothing is targeted.
///
private void ClearDemoTarget()
{
if (demoTarget == null) return;
demoTarget.ClearHighlight();
demoTarget = null;
}
#endregion
}
}