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; [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; #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 ghostValidity; private BuildSnapPoint[] ghostSnaps; /// /// 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; private readonly Collider[] snapResults = new Collider[16]; /// /// 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.SecondaryUsePressed += 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.SecondaryUsePressed -= 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(); if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask); } #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; ghostValidity = go.GetComponentInChildren(true); ghostSnaps = go.GetComponentsInChildren(true); ResolveAim(); placementBeganFrame = Time.frameCount; UpdateGhostPose(); if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask); } /// /// 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. /// private void HandleConfirm() { if (PlayerEvents.IsDemolishing) { TryDemolish(); return; } 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 (BuildRegistry.Instance != null) BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation); ConsumeCost(); if (continuousBuild && active != null) { RearmGhost(); return; } EndPlacement(); } /// /// Right-click: cancels the current placement (even while the ghost is still spawning). /// 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 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)); 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. /// private void TrySnapToSockets() { if (ghostSnaps == null || ghostSnaps.Length == 0) return; if (snapPointMask == 0) return; 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; ghost.transform.position += bestWorld.transform.position - bestGhost.transform.position; } /// /// 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; } ghostValidity = null; ghostSnaps = null; } /// /// 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: asks the registry to remove the aimed build for everyone. The /// target stays until the next frame re-evaluates, so a miss simply retargets. /// private void TryDemolish() { if (demoTarget == null) return; if (BuildRegistry.Instance != null) BuildRegistry.Instance.RequestDemolish(demoTarget.gameObject); } /// /// 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 } }