(Feat) Add Build
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user