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;
[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];
#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;
}
///
/// 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;
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);
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 ends the placement. Placement is
/// blocked on an invalid spot and on the very frame the ghost was attached.
///
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 (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;
}
///
/// 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()
{
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);
}
}
///
/// 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 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
}
}