using UnityEngine; namespace Ashwild.Building { /// /// 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. /// public enum PlacementVerdict { /// Buildable here, right now. Valid, /// The footprint overlaps something on the obstruction layers. Blocked, /// The structure requires a socket connection (PlacementSupport.SnapOnly) and has none. Unsupported, /// The spot itself is fine, but the player cannot pay the cost. Unaffordable } /// /// The visual state of a build ghost — a pure view over a . /// /// 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. /// [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 /// /// Each renderer's original materials, so the overlay can be appended without losing them. /// private Material[][] baseMaterials; /// /// The overlay currently applied, so we only rebuild the material lists when it actually changes. /// private Material lastOverlay; private bool overlayApplied; #endregion #region Public API /// /// 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". /// public BoxCollider Footprint => footprint; #endregion #region Unity Lifecycle /// /// 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. /// private void Awake() { if (renderers == null || renderers.Length == 0) renderers = GetComponentsInChildren(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 /// /// 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. /// public void SetState(PlacementVerdict verdict) => SetOverlay(verdict == PlacementVerdict.Valid ? validOverlay : invalidOverlay); /// /// 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. /// public void SetRemoteValidity(bool valid) => SetOverlay(valid ? validOverlay : invalidOverlay); /// /// 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. /// private void SetOverlay(Material overlay) { if (overlayApplied && overlay == lastOverlay) return; ApplyOverlay(overlay); lastOverlay = overlay; overlayApplied = true; } /// /// 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. /// 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 } }