416 lines
16 KiB
C#
416 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using FishNet.Object;
|
|
using UnityEngine;
|
|
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("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
|
|
|
|
/// <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 when the ghost is attached.
|
|
/// </summary>
|
|
private Transform aim;
|
|
|
|
/// <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];
|
|
|
|
#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;
|
|
}
|
|
|
|
/// <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;
|
|
|
|
EndPlacement();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drives the owned ghost along the aim each frame; frozen while input is locked (e.g. the
|
|
/// menu was reopened over an active placement).
|
|
/// </summary>
|
|
private void Update()
|
|
{
|
|
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);
|
|
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);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <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>
|
|
/// 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()
|
|
{
|
|
if (ghost != null)
|
|
{
|
|
if (BuildRegistry.Instance != null)
|
|
BuildRegistry.Instance.RequestDespawnGhost(ghost.GetComponent<NetworkObject>());
|
|
ghost = null;
|
|
}
|
|
ghostValidity = null;
|
|
ghostSnaps = null;
|
|
|
|
if (active != null)
|
|
{
|
|
active = null;
|
|
activeId = 0;
|
|
PlayerEvents.RaiseBuildPlacingChanged(false);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|