Files
Emberwild/Assets/GAME/Script/Building/BuildGhost.cs
T
2026-07-22 12:56:13 +02:00

168 lines
7.2 KiB
C#

using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// 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
{
#region Serialized Fields
[Header("Footprint")]
[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("Added on top of the base materials when the spot is buildable (green).")]
[SerializeField] private Material validOverlay;
[Tooltip("Added for every reason the build is refused — blocked, unsupported or unaffordable.")]
[SerializeField] private Material invalidOverlay;
#endregion
#region State
/// <summary>
/// Each renderer's original materials, so the overlay can be appended without losing them.
/// </summary>
private Material[][] baseMaterials;
/// <summary>
/// The overlay currently applied, so we only rebuild the material lists when it actually changes.
/// </summary>
private Material lastOverlay;
private bool overlayApplied;
#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, 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()
{
if (renderers == null || renderers.Length == 0)
renderers = GetComponentsInChildren<Renderer>(true);
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 Display
/// <summary>
/// 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 void SetState(PlacementVerdict verdict)
=> SetOverlay(verdict == PlacementVerdict.Valid ? validOverlay : invalidOverlay);
/// <summary>
/// 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>
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 (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.
/// </summary>
private void ApplyOverlay(Material overlay)
{
if (overlay == null) return;
for (int i = 0; i < renderers.Length; i++)
{
Renderer r = renderers[i];
if (r == null) continue;
Material[] baseMats = baseMaterials[i];
Material[] combined = new Material[baseMats.Length + 1];
for (int j = 0; j < baseMats.Length; j++)
combined[j] = baseMats[j];
combined[baseMats.Length] = overlay;
r.materials = combined;
}
}
#endregion
}
}