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 } }