using System.Collections.Generic; using FishNet.Object; using UnityEngine; 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("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; #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 when the ghost is attached. /// private Transform aim; /// /// 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]; #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; } /// /// 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; EndPlacement(); } /// /// Drives the owned ghost along the aim each frame; frozen while input is locked (e.g. the /// menu was reopened over an active placement). /// private void Update() { 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); 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); 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); placementBeganFrame = Time.frameCount; UpdateGhostPose(); if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask); } /// /// Left-click: commits the ghost's pose to the registry (spawned for everyone) and ends the /// placement. Blocked on an invalid spot and on the very frame the ghost was attached. /// private void HandleConfirm() { if (ghost == null) return; if (PlayerEvents.InputLocked) return; if (Time.frameCount == placementBeganFrame) return; if (ghostValidity != null && !ghostValidity.IsValid) return; if (BuildRegistry.Instance != null) BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation); 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; } /// /// 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() { if (ghost != null) { if (BuildRegistry.Instance != null) BuildRegistry.Instance.RequestDespawnGhost(ghost.GetComponent()); ghost = null; } ghostValidity = null; ghostSnaps = null; if (active != null) { active = null; activeId = 0; PlayerEvents.RaiseBuildPlacingChanged(false); } } #endregion } }