803 lines
34 KiB
C#
803 lines
34 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;
|
|
|
|
[Tooltip("Metres shaved off each side of the footprint before testing obstructions. Connected pieces meet exactly flush, so without a small inset every snapped build would report its own neighbour as blocking. Keep it well under the thinnest structure.")]
|
|
[SerializeField] private float obstructionPadding = 0.03f;
|
|
|
|
[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;
|
|
|
|
[Header("Hammer Wear")]
|
|
[Tooltip("Durability (uses) spent on the equipped hammer for each successful placement or demolition. Requires the hammer's ItemData to be uses-tracked (Has Uses + Max Uses). A depleted hammer blocks building/demolishing until repaired. Set to 0 for a hammer that never wears.")]
|
|
[SerializeField] private int hammerUsesPerAction = 1;
|
|
|
|
#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 ghostView;
|
|
private BuildSnapPoint[] ghostSnaps;
|
|
|
|
/// <summary>
|
|
/// Whether the ghost's current pose came from a socket connection rather than the free/grid aim.
|
|
/// Kept as state rather than recomputed because the pose and the validity check happen in two
|
|
/// steps, and because a frame whose aim ray hits nothing leaves the ghost — and so its
|
|
/// connection — exactly as it was.
|
|
/// </summary>
|
|
private bool ghostIsConnected;
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// Last spot validity pushed to teammates (-1 = none sent yet, 0 = invalid, 1 = valid), so the
|
|
/// networked ghost overlay is only replicated when it actually flips — never every frame.
|
|
/// </summary>
|
|
private int lastSentValidity = -1;
|
|
|
|
private readonly Collider[] snapResults = new Collider[16];
|
|
private readonly Collider[] obstructionResults = new Collider[8];
|
|
|
|
/// <summary>
|
|
/// Floor for the padded half-extents, so a very thin piece (a floor slab a few centimetres deep)
|
|
/// cannot have the inset collapse its footprint to zero — or invert it — and stop testing at all.
|
|
/// </summary>
|
|
private const float MinHalfExtent = 0.01f;
|
|
|
|
/// <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.BuildCancelRequested += 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.BuildCancelRequested -= 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();
|
|
EvaluateGhost();
|
|
}
|
|
|
|
#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;
|
|
ghostView = go.GetComponentInChildren<BuildGhost>(true);
|
|
ghostSnaps = go.GetComponentsInChildren<BuildSnapPoint>(true);
|
|
ResolveAim();
|
|
|
|
lastSentValidity = -1;
|
|
ghostIsConnected = false;
|
|
placementBeganFrame = Time.frameCount;
|
|
UpdateGhostPose();
|
|
EvaluateGhost();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the current verdict into the ghost's look each frame. No-op when no ghost is live.
|
|
/// </summary>
|
|
private void EvaluateGhost()
|
|
{
|
|
if (ghostView == null) return;
|
|
|
|
PlacementVerdict verdict = EvaluatePlacement();
|
|
ghostView.SetState(verdict);
|
|
ReplicateValidity(verdict == PlacementVerdict.Valid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The single answer to "can the active structure be committed at the ghost's current pose?",
|
|
/// used both to tint the preview and to gate the confirm — so what the player sees and what the
|
|
/// button does can never disagree. It is recomputed on demand rather than cached, since a cached
|
|
/// verdict would be one frame stale depending on the input/Update order.
|
|
///
|
|
/// The reasons are tested most-spatial first: a structure that needs a connection and has none is
|
|
/// reported before an obstruction, and both before affordability, so the player is always told
|
|
/// about the problem that moving the mouse can actually fix.
|
|
///
|
|
/// A CONNECTED ghost skips the obstruction test entirely: the socket already decided where the
|
|
/// piece belongs, and real modular geometry interpenetrates far more than any tolerance can absorb
|
|
/// (a wall's head runs up into the ceiling it supports, a frame overlaps the slab it sits in).
|
|
/// Shrinking the footprint was tried and is not enough — the only rule that holds is that an
|
|
/// authored connection IS the validity check. Free placement still tests obstructions normally,
|
|
/// so the padding below continues to matter there.
|
|
/// </summary>
|
|
private PlacementVerdict EvaluatePlacement()
|
|
{
|
|
if (active == null) return PlacementVerdict.Blocked;
|
|
|
|
if (active.Support == PlacementSupport.SnapOnly && !ghostIsConnected)
|
|
return PlacementVerdict.Unsupported;
|
|
|
|
if (!ghostIsConnected && IsFootprintObstructed()) return PlacementVerdict.Blocked;
|
|
|
|
if (!active.CanAfford(PlayerInventory.Instance)) return PlacementVerdict.Unaffordable;
|
|
|
|
return PlacementVerdict.Valid;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Box-overlaps the ghost's footprint against the obstruction layers, ignoring triggers. The box
|
|
/// is shrunk by <see cref="obstructionPadding"/> on every side: snapped pieces meet exactly flush
|
|
/// by construction, so an un-inset test would report the very neighbour the player is building
|
|
/// against. The inset is small enough that a real intruder inside the volume — a chest, a player,
|
|
/// another structure — is still caught. A ghost without a footprint is never obstructed.
|
|
/// </summary>
|
|
private bool IsFootprintObstructed()
|
|
{
|
|
BoxCollider footprint = ghostView != null ? ghostView.Footprint : null;
|
|
if (footprint == null) return false;
|
|
|
|
Transform t = footprint.transform;
|
|
Vector3 center = t.TransformPoint(footprint.center);
|
|
Vector3 halfExtents = Vector3.Scale(footprint.size, t.lossyScale) * 0.5f;
|
|
halfExtents = Vector3.Max(halfExtents - Vector3.one * obstructionPadding, Vector3.one * MinHalfExtent);
|
|
|
|
int count = Physics.OverlapBoxNonAlloc(center, halfExtents, obstructionResults, t.rotation, obstructionMask, QueryTriggerInteraction.Ignore);
|
|
return count > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the ghost's spot validity to teammates through BuildRegistry, but only when it flips —
|
|
/// so a remote ghost shows the same green/red as here without a per-frame RPC stream. Affordability
|
|
/// stays local (the placer's private inventory), so only IsValid crosses the wire.
|
|
/// </summary>
|
|
private void ReplicateValidity(bool valid)
|
|
{
|
|
int state = valid ? 1 : 0;
|
|
if (state == lastSentValidity) return;
|
|
lastSentValidity = state;
|
|
|
|
if (BuildRegistry.Instance != null && ghost != null)
|
|
BuildRegistry.Instance.SetGhostValidity(ghost.GetComponent<NetworkObject>(), valid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Left-click: demolishes the aimed build while in demolition mode, otherwise commits the
|
|
/// ghost's pose to the registry (spawned for everyone), spends a use of the equipped hammer and
|
|
/// consumes the 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.
|
|
///
|
|
/// Placement itself is gated by the one verdict the preview is already showing, so the button can
|
|
/// never accept what the ghost paints as refused. The remaining guards are input/session
|
|
/// conditions rather than placement rules — input locked, the very frame the ghost was attached,
|
|
/// no registry in the session, and a hammer out of durability.
|
|
/// </summary>
|
|
private void HandleConfirm()
|
|
{
|
|
if (PlayerEvents.IsDemolishing)
|
|
{
|
|
TryDemolish();
|
|
return;
|
|
}
|
|
|
|
if (ghost == null) return;
|
|
if (PlayerEvents.InputLocked) return;
|
|
if (Time.frameCount == placementBeganFrame) return;
|
|
if (EvaluatePlacement() != PlacementVerdict.Valid) return;
|
|
if (BuildRegistry.Instance == null) return;
|
|
if (!TryConsumeHammerUse()) return;
|
|
|
|
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
|
|
|
|
ConsumeCost();
|
|
|
|
if (continuousBuild && active != null)
|
|
{
|
|
RearmGhost();
|
|
return;
|
|
}
|
|
|
|
EndPlacement();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The hammer's cancel request (a right-click tap while placing): cancels the current placement
|
|
/// (even while the ghost is still spawning). Routed through the bus so this stays the single
|
|
/// place that ends a placement — the hammer is the sole arbiter of the click.
|
|
/// </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 — connection included — 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));
|
|
ghostIsConnected = 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.
|
|
///
|
|
/// Returns whether a connection was actually made, which is what lets the caller trust the pose
|
|
/// and skip the obstruction test for it.
|
|
/// </summary>
|
|
private bool TrySnapToSockets()
|
|
{
|
|
if (ghostSnaps == null || ghostSnaps.Length == 0) return false;
|
|
if (snapPointMask == 0) return false;
|
|
|
|
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 false;
|
|
|
|
ghost.transform.position += bestWorld.transform.position - bestGhost.transform.position;
|
|
return true;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
ghostView = null;
|
|
ghostSnaps = null;
|
|
lastSentValidity = -1;
|
|
ghostIsConnected = false;
|
|
}
|
|
|
|
/// <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: spends a hammer use and asks the registry to remove the aimed
|
|
/// build for everyone (which refunds resources by remaining health). Blocked when the hammer is
|
|
/// out of durability. The target stays until the next frame re-evaluates, so a miss simply
|
|
/// retargets.
|
|
/// </summary>
|
|
private void TryDemolish()
|
|
{
|
|
if (demoTarget == null) return;
|
|
if (!TryConsumeHammerUse()) return;
|
|
|
|
demoTarget.RequestDemolish();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spends the configured durability on the equipped hammer for one build action, returning
|
|
/// whether the action may proceed. A depleted hammer returns false so the placement/demolition
|
|
/// is blocked (like any worn-out tool); a hammer with no durability authored, a zero cost, or no
|
|
/// inventory simply passes without wear. The hammer is the selected hotbar item while building,
|
|
/// so spending the selected tool's use targets it.
|
|
/// </summary>
|
|
private bool TryConsumeHammerUse()
|
|
{
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return true;
|
|
return inv.ConsumeSelectedToolUse(hammerUsesPerAction);
|
|
}
|
|
|
|
/// <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
|
|
}
|
|
}
|