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