(Feat) Add Remove Building
This commit is contained in:
@@ -62,6 +62,13 @@ namespace Ashwild.Building
|
||||
[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
|
||||
@@ -95,10 +102,16 @@ namespace Ashwild.Building
|
||||
private float yaw;
|
||||
|
||||
/// <summary>
|
||||
/// The local player's aim transform (camera), resolved when the ghost is attached.
|
||||
/// 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>
|
||||
@@ -141,6 +154,8 @@ namespace Ashwild.Building
|
||||
PlayerEvents.AttackPressed += HandleConfirm;
|
||||
PlayerEvents.SecondaryUsePressed += HandleCancel;
|
||||
PlayerEvents.HotbarScroll += HandleRotate;
|
||||
PlayerEvents.BuildRotatePressed += HandleRotateKey;
|
||||
PlayerEvents.DemolishModeChanged += HandleDemolishModeChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -152,16 +167,27 @@ namespace Ashwild.Building
|
||||
PlayerEvents.AttackPressed -= HandleConfirm;
|
||||
PlayerEvents.SecondaryUsePressed -= HandleCancel;
|
||||
PlayerEvents.HotbarScroll -= HandleRotate;
|
||||
PlayerEvents.BuildRotatePressed -= HandleRotateKey;
|
||||
PlayerEvents.DemolishModeChanged -= HandleDemolishModeChanged;
|
||||
|
||||
EndPlacement();
|
||||
ClearDemoTarget();
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// 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;
|
||||
|
||||
@@ -268,9 +294,7 @@ namespace Ashwild.Building
|
||||
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);
|
||||
ResolveAim();
|
||||
|
||||
placementBeganFrame = Time.frameCount;
|
||||
UpdateGhostPose();
|
||||
@@ -278,11 +302,18 @@ namespace Ashwild.Building
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleConfirm()
|
||||
{
|
||||
if (PlayerEvents.IsDemolishing)
|
||||
{
|
||||
TryDemolish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ghost == null) return;
|
||||
if (PlayerEvents.InputLocked) return;
|
||||
if (Time.frameCount == placementBeganFrame) return;
|
||||
@@ -316,6 +347,18 @@ namespace Ashwild.Building
|
||||
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).
|
||||
@@ -410,6 +453,76 @@ namespace Ashwild.Building
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,43 @@ namespace Ashwild.Building
|
||||
|
||||
#endregion
|
||||
|
||||
#region Demolish Build
|
||||
|
||||
/// <summary>
|
||||
/// Called by BuildManager when the local player demolishes a build they aimed at: resolves the
|
||||
/// hit instance to its index in the replicated list and asks the server to remove it. The index
|
||||
/// is a valid shared key because <see cref="spawned"/> stays aligned with <see cref="builds"/>
|
||||
/// on every machine. No-op when the object is not one of our committed builds.
|
||||
/// </summary>
|
||||
public void RequestDemolish(GameObject builtInstance)
|
||||
{
|
||||
if (builtInstance == null) return;
|
||||
|
||||
int index = spawned.IndexOf(builtInstance);
|
||||
if (index < 0)
|
||||
{
|
||||
Debug.LogWarning($"[BuildRegistry] '{builtInstance.name}' is not a committed build — nothing to demolish.", this);
|
||||
return;
|
||||
}
|
||||
RequestDemolishServerRpc(index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side: removes the record at this index, which replicates the removal (and the local
|
||||
/// teardown + occupancy refresh) to every client. Range-guarded against a stale index from a
|
||||
/// build that another player demolished during the round-trip.
|
||||
/// </summary>
|
||||
[ServerRpc(RequireOwnership = false)]
|
||||
private void RequestDemolishServerRpc(int index, NetworkConnection conn = null)
|
||||
{
|
||||
if (index < 0 || index >= builds.Count) return;
|
||||
|
||||
// TODO: refund the builder's resources (conn → PlayerInventory) once build cost lands.
|
||||
builds.RemoveAt(index);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Networked Ghost Preview
|
||||
|
||||
/// <summary>
|
||||
@@ -187,6 +224,7 @@ namespace Ashwild.Building
|
||||
case SyncListOperation.RemoveAt:
|
||||
DestroyAt(index);
|
||||
spawned.RemoveAt(index);
|
||||
RecomputeOccupancy();
|
||||
break;
|
||||
|
||||
case SyncListOperation.Clear:
|
||||
@@ -210,7 +248,10 @@ namespace Ashwild.Building
|
||||
Debug.LogWarning($"[BuildRegistry] Buildable id {record.buildableId} introuvable ou sans BuiltPrefab — rien de spawné.", this);
|
||||
return null;
|
||||
}
|
||||
return Instantiate(data.BuiltPrefab, record.position, record.rotation);
|
||||
|
||||
GameObject go = Instantiate(data.BuiltPrefab, record.position, record.rotation);
|
||||
if (go.GetComponent<BuiltStructure>() == null) go.AddComponent<BuiltStructure>();
|
||||
return go;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,6 +287,24 @@ namespace Ashwild.Building
|
||||
MarkOccupancy(go);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes occupancy from scratch after a removal: clears every socket, then re-marks the
|
||||
/// connected pairs across what remains. Without this a demolished piece would leave its former
|
||||
/// neighbours' sockets stuck "occupied", so nothing could ever be snapped back into the gap.
|
||||
/// </summary>
|
||||
private void RecomputeOccupancy()
|
||||
{
|
||||
foreach (GameObject go in spawned)
|
||||
{
|
||||
if (go == null) continue;
|
||||
foreach (BuildSnapPoint sp in go.GetComponentsInChildren<BuildSnapPoint>(true))
|
||||
sp.SetOccupied(false);
|
||||
}
|
||||
|
||||
foreach (GameObject go in spawned)
|
||||
MarkOccupancy(go);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the sockets a piece shares a position with — and the matching sockets on the pieces
|
||||
/// it touches — as occupied, so placement snapping skips them. Occupancy is derived purely
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ashwild.Building
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime marker added to every committed structure instance the BuildRegistry spawns, so the
|
||||
/// demolition aim can recognise a build from a raycast hit (GetComponentInParent) and hand its
|
||||
/// root back to the registry to remove. It also owns the demolition highlight: while it is the
|
||||
/// hammer's target the demolition material is added on top of every renderer (an extra overlay
|
||||
/// pass, like the ghost), and removed again when the aim leaves — so the player sees exactly which
|
||||
/// build will break. Pure local visual + a pure tag: nothing about it ever crosses the wire.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class BuiltStructure : MonoBehaviour
|
||||
{
|
||||
#region State
|
||||
|
||||
/// <summary>
|
||||
/// Every child renderer, cached so the highlight can swap and restore their materials.
|
||||
/// </summary>
|
||||
private Renderer[] renderers;
|
||||
|
||||
/// <summary>
|
||||
/// Each renderer's original materials, kept so the swap is fully reversible.
|
||||
/// </summary>
|
||||
private Material[][] baseMaterials;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the demolition material is currently applied, so swaps stay idempotent.
|
||||
/// </summary>
|
||||
private bool highlighted;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Caches every child renderer and its authored materials up front so highlighting is a cheap
|
||||
/// array assignment with no per-frame allocation of the base look.
|
||||
/// </summary>
|
||||
private void Awake()
|
||||
{
|
||||
renderers = GetComponentsInChildren<Renderer>(true);
|
||||
baseMaterials = new Material[renderers.Length][];
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
baseMaterials[i] = renderers[i] != null ? renderers[i].sharedMaterials : new Material[0];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Adds the demolition material on top of each renderer's authored materials (an extra draw
|
||||
/// pass), marking this build as the hammer's current target without hiding the model. Mirrors
|
||||
/// the ghost overlay. No-op when already highlighted or no material was provided.
|
||||
/// </summary>
|
||||
public void Highlight(Material demolitionMaterial)
|
||||
{
|
||||
if (highlighted || demolitionMaterial == null) return;
|
||||
highlighted = true;
|
||||
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
Renderer r = renderers[i];
|
||||
if (r == null) continue;
|
||||
|
||||
Material[] baseMats = baseMaterials[i];
|
||||
Material[] combined = new Material[baseMats.Length + 1];
|
||||
for (int j = 0; j < baseMats.Length; j++) combined[j] = baseMats[j];
|
||||
combined[baseMats.Length] = demolitionMaterial;
|
||||
|
||||
r.materials = combined;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores each renderer's authored materials. No-op when not highlighted.
|
||||
/// </summary>
|
||||
public void ClearHighlight()
|
||||
{
|
||||
if (!highlighted) return;
|
||||
highlighted = false;
|
||||
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
if (renderers[i] != null) renderers[i].materials = baseMaterials[i];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fe38331f524a7db4cb3d53381b9ea244
|
||||
Reference in New Issue
Block a user