Files
Emberwild/Assets/GAME/Script/Building/BuildManager.cs
T
2026-07-14 14:25:02 +02:00

665 lines
26 KiB
C#

using System.Collections.Generic;
using FishNet.Object;
using UnityEngine;
using Ashwild.Inventory;
using Ashwild.Player;
namespace Ashwild.Building
{
/// <summary>
/// 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 <see cref="AttachGhost"/>; 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.
/// </summary>
[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
/// <summary>
/// The single scene instance, so BuildRegistry can hand a freshly spawned ghost back here.
/// </summary>
public static BuildManager Instance { get; private set; }
/// <summary>
/// The buildables currently shown, in card order, so a picked index maps back to its data.
/// </summary>
private readonly List<BuildableData> visible = new List<BuildableData>();
/// <summary>
/// The buildable being positioned (and its network id), or null/0 when idle.
/// </summary>
private BuildableData active;
private ushort activeId;
/// <summary>
/// The live networked ghost we own and drive, or null while idle or awaiting its spawn.
/// </summary>
private GameObject ghost;
private BuildGhost ghostValidity;
private BuildSnapPoint[] ghostSnaps;
/// <summary>
/// The ghost's current yaw for grid placement, stepped by the scroll wheel.
/// </summary>
private float yaw;
/// <summary>
/// The local player's aim transform (camera), resolved lazily and shared by placement and
/// demolition.
/// </summary>
private Transform aim;
/// <summary>
/// The committed build currently under the demolition aim, or null when none is targeted.
/// </summary>
private BuiltStructure demoTarget;
/// <summary>
/// Frame the ghost was attached, so a click landing that frame never confirms instantly.
/// </summary>
private int placementBeganFrame;
/// <summary>
/// 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.
/// </summary>
private int spawnToken;
private readonly Collider[] snapResults = new Collider[16];
/// <summary>
/// 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.
/// </summary>
private readonly List<BuildCostView> costView = new List<BuildCostView>();
#endregion
#region Unity Lifecycle
/// <summary>
/// Registers the scene singleton BuildRegistry calls back into.
/// </summary>
private void Awake()
{
Instance = this;
}
/// <summary>
/// Clears the singleton — mirrors Awake.
/// </summary>
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
/// <summary>
/// Subscribes to the menu open state and the placement inputs while enabled.
/// </summary>
private void OnEnable()
{
PlayerEvents.BuildMenuOpenChanged += HandleMenuOpenChanged;
PlayerEvents.AttackPressed += HandleConfirm;
PlayerEvents.SecondaryUsePressed += HandleCancel;
PlayerEvents.HotbarScroll += HandleRotate;
PlayerEvents.BuildRotatePressed += HandleRotateKey;
PlayerEvents.DemolishModeChanged += HandleDemolishModeChanged;
PlayerEvents.InventorySlotChanged += HandleInventoryChanged;
}
/// <summary>
/// Unsubscribes and tears down any live placement — mirrors OnEnable exactly.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// Populates the menu view from the catalog when it opens; nothing to do on close.
/// </summary>
private void HandleMenuOpenChanged(bool open)
{
if (!open) return;
PopulateMenu();
}
/// <summary>
/// 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.
/// </summary>
private void PopulateMenu()
{
if (menuUI == null)
{
Debug.LogError("[BuildManager] No BuildMenuUI assigned — cannot populate the menu.", this);
return;
}
visible.Clear();
List<BuildCardView> cards = new List<BuildCardView>();
foreach (BuildableData data in catalog)
{
if (data == null) continue;
visible.Add(data);
cards.Add(new BuildCardView(data.Icon, data.DisplayName));
}
menuUI.Populate(cards, HandleCardSelected);
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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<NetworkObject>());
return;
}
ghost = go;
ghostValidity = go.GetComponentInChildren<BuildGhost>(true);
ghostSnaps = go.GetComponentsInChildren<BuildSnapPoint>(true);
ResolveAim();
placementBeganFrame = Time.frameCount;
UpdateGhostPose();
if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask);
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Right-click: cancels the current placement (even while the ghost is still spawning).
/// </summary>
private void HandleCancel()
{
if (active == null) return;
EndPlacement();
}
/// <summary>
/// Scroll wheel: yaws the ghost by one rotation step (repurposed from the hotbar, which
/// ignores the scroll while a build is being placed).
/// </summary>
private void HandleRotate(float scroll)
{
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
if (scroll > 0f) yaw += rotationStep;
else if (scroll < 0f) yaw -= rotationStep;
}
/// <summary>
/// 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.
/// </summary>
private void HandleRotateKey()
{
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
yaw += rotationStep;
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<BuildSnapPoint>();
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;
}
/// <summary>
/// Ends the current placement: despawns the networked ghost, clears state and flags the bus
/// idle. Safe to call when nothing is in flight.
/// </summary>
private void EndPlacement()
{
DespawnGhost();
if (active != null)
{
active = null;
activeId = 0;
PlayerEvents.RaiseBuildPlacingChanged(false);
PlayerEvents.RaiseBuildCostChanged(null);
}
}
/// <summary>
/// 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
/// <see cref="active"/>/<see cref="activeId"/> 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.
/// </summary>
private void RearmGhost()
{
DespawnGhost();
if (BuildRegistry.Instance == null)
{
EndPlacement();
return;
}
int token = ++spawnToken;
BuildRegistry.Instance.RequestSpawnGhost(activeId, token);
}
/// <summary>
/// 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.
/// </summary>
private void DespawnGhost()
{
if (ghost != null)
{
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestDespawnGhost(ghost.GetComponent<NetworkObject>());
ghost = null;
}
ghostValidity = null;
ghostSnaps = null;
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
private void HandleInventoryChanged(int slotIndex)
{
if (active == null) return;
PublishCostView();
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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
/// <see cref="HandleInventoryChanged"/>. No-op for a free build or when offline.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
public bool ContinuousBuild
{
get => continuousBuild;
set => continuousBuild = value;
}
/// <summary>
/// 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.
/// </summary>
public void ToggleContinuousBuild() => continuousBuild = !continuousBuild;
#endregion
#region Demolition
/// <summary>
/// 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.
/// </summary>
private void HandleDemolishModeChanged(bool on)
{
if (!on) ClearDemoTarget();
}
/// <summary>
/// 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".
/// </summary>
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<BuiltStructure>();
if (hit == demoTarget) return;
if (demoTarget != null) demoTarget.ClearHighlight();
demoTarget = hit;
if (demoTarget != null) demoTarget.Highlight(demolitionMaterial);
}
/// <summary>
/// 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.
/// </summary>
private void TryDemolish()
{
if (demoTarget == null) return;
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestDemolish(demoTarget.gameObject);
}
/// <summary>
/// Drops the current demolition target, restoring its authored materials. Safe to call when
/// nothing is targeted.
/// </summary>
private void ClearDemoTarget()
{
if (demoTarget == null) return;
demoTarget.ClearHighlight();
demoTarget = null;
}
#endregion
}
}