(Feat) Add Build
This commit is contained in:
@@ -1,34 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using Ashwild.Inventory;
|
||||
using DG.Tweening;
|
||||
using FishNet.Connection;
|
||||
using FishNet.Object;
|
||||
using FishNet.Object.Synchronizing;
|
||||
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.
|
||||
/// The one component every committed structure carries — wall, floor, chest, campfire alike. Since
|
||||
/// builds are spawned as real NetworkObjects, each one is self-contained: it knows what it is, owns
|
||||
/// its own server-authoritative health, and handles its own damage and demolition. There is no
|
||||
/// central list of builds and no index to keep aligned; the object IS the record.
|
||||
///
|
||||
/// It groups everything that changes together when a placed build's behaviour changes:
|
||||
/// 1. Identity — <see cref="BuildableId"/>, replicated so any machine can resolve the BuildableData
|
||||
/// behind this instance (the refund needs its cost, UI needs its name).
|
||||
/// 2. Health and damage — server-authoritative. <see cref="TakeDamage"/> is what an attacker calls;
|
||||
/// the server applies it and despawns the build for everyone when it dies (no refund — it broke).
|
||||
/// 3. Demolition — <see cref="RequestDemolish"/> refunds a share of the cost proportional to the
|
||||
/// health left, then despawns.
|
||||
/// 4. Presentation — the appear bump and the demolition highlight, both purely local.
|
||||
/// 5. Snap occupancy — every live instance registers here, so connected sockets can be marked
|
||||
/// without anyone holding a master list of builds.
|
||||
///
|
||||
/// Health never crosses the wire: only its observable effects do (the build disappearing, resources
|
||||
/// refunded), which replicate through the despawn and the inventory RPC respectively.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class BuiltStructure : MonoBehaviour
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class BuiltStructure : NetworkBehaviour
|
||||
{
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("Appear Bump")]
|
||||
[Tooltip("How long the pop lasts — short and snappy reads as an 'appear' effect.")]
|
||||
[SerializeField] private float bumpDuration = DefaultBumpDuration;
|
||||
|
||||
[Tooltip("Fraction of the authored scale the bump starts from (0.7 = starts at 70%, then springs up).")]
|
||||
[SerializeField] private float startScaleFactor = DefaultStartScaleFactor;
|
||||
|
||||
[Tooltip("OutBack gives the springy overshoot that sells the 'boom'.")]
|
||||
[SerializeField] private Ease bumpEase = Ease.OutBack;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
/// <summary>
|
||||
/// Fallback bump values, used both as the field initializers and as the guard defaults in
|
||||
/// <see cref="PlaySpawnBump"/> — so a build whose prefab predates these serialized fields
|
||||
/// (Unity deserializes the missing values to 0) still pops instead of snapping in.
|
||||
/// </summary>
|
||||
private const float DefaultBumpDuration = 0.22f;
|
||||
private const float DefaultStartScaleFactor = 0.7f;
|
||||
|
||||
/// <summary>
|
||||
/// How close two sockets must be to count as connected — they snap to the exact same point, so a
|
||||
/// tiny threshold is enough and no layer or radius has to be configured.
|
||||
/// </summary>
|
||||
private const float OccupancyThresholdSqr = 0.05f * 0.05f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
/// <summary>
|
||||
/// Every child renderer, cached so the highlight can swap and restore their materials.
|
||||
/// Every live built structure on this machine. Maintained here rather than in a central registry
|
||||
/// so occupancy works off the objects themselves — the network spawns and despawns them, and the
|
||||
/// list follows automatically.
|
||||
/// </summary>
|
||||
private static readonly List<BuiltStructure> Live = new List<BuiltStructure>();
|
||||
|
||||
/// <summary>
|
||||
/// Which buildable this instance was placed from, so the refund can look up its cost. Written by
|
||||
/// the server before the spawn, so it is already correct on the first frame everywhere.
|
||||
/// </summary>
|
||||
private readonly SyncVar<ushort> buildableId = new SyncVar<ushort>();
|
||||
|
||||
/// <summary>
|
||||
/// Server-only remaining health, seeded from the buildable's authored max at spawn.
|
||||
/// </summary>
|
||||
private float health;
|
||||
|
||||
private Renderer[] renderers;
|
||||
|
||||
/// <summary>
|
||||
/// Each renderer's original materials, kept so the swap is fully reversible.
|
||||
/// </summary>
|
||||
private Material[][] baseMaterials;
|
||||
private bool highlighted;
|
||||
private Tween bumpTween;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
public ushort BuildableId => buildableId.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the demolition material is currently applied, so swaps stay idempotent.
|
||||
/// The authoring data behind this instance, or null when the database cannot resolve its id.
|
||||
/// </summary>
|
||||
private bool highlighted;
|
||||
public BuildableData Data => BuildableDatabase.Instance != null
|
||||
? BuildableDatabase.Instance.GetBuildable(buildableId.Value)
|
||||
: null;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -46,14 +118,234 @@ namespace Ashwild.Building
|
||||
baseMaterials[i] = renderers[i] != null ? renderers[i].sharedMaterials : new Material[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kills the appear tween so it never targets a destroyed transform (e.g. the build was
|
||||
/// demolished mid-pop).
|
||||
/// </summary>
|
||||
private void OnDestroy() => bumpTween?.Kill();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
#region Network Lifecycle
|
||||
|
||||
/// <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.
|
||||
/// Joins the live set and marks the sockets this piece connects to. Runs on every machine as the
|
||||
/// object spawns — including on a late joiner receiving builds placed long ago, which is exactly
|
||||
/// when occupancy has to be rebuilt for them too.
|
||||
/// </summary>
|
||||
public override void OnStartNetwork()
|
||||
{
|
||||
base.OnStartNetwork();
|
||||
Live.Add(this);
|
||||
MarkOccupancy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaves the live set and recomputes occupancy across what remains. Without the recompute a
|
||||
/// demolished piece would leave its former neighbours' sockets stuck "occupied", so nothing could
|
||||
/// ever be snapped back into the gap.
|
||||
/// </summary>
|
||||
public override void OnStopNetwork()
|
||||
{
|
||||
base.OnStopNetwork();
|
||||
Live.Remove(this);
|
||||
RecomputeOccupancy();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Server Setup
|
||||
|
||||
/// <summary>
|
||||
/// Seeds identity and health on the server, before the object is spawned, so both are already in
|
||||
/// place when clients first see it. Called by BuildRegistry as part of committing a placement.
|
||||
/// </summary>
|
||||
public void InitialiseOnServer(ushort id, float maxHealth)
|
||||
{
|
||||
buildableId.Value = id;
|
||||
health = maxHealth > 0f ? maxHealth : 1f;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Appear Bump
|
||||
|
||||
/// <summary>
|
||||
/// Tells everyone currently watching to play the appear pop. Sent by the server right after a
|
||||
/// fresh placement. Because it only reaches present observers, a late joiner streaming in dozens
|
||||
/// of existing structures never receives it — so their base does not pop into existence all at
|
||||
/// once, which is precisely the distinction the old "only on Add, never on rebuild" rule made.
|
||||
/// </summary>
|
||||
[ObserversRpc]
|
||||
public void PlaySpawnBumpForObservers() => PlaySpawnBump();
|
||||
|
||||
/// <summary>
|
||||
/// Runs the appear bump from the shrunk start scale up to the transform's current (authored)
|
||||
/// scale. Captures the target at call time so a non-uniform or non-unit prefab scale is preserved.
|
||||
/// Non-positive serialized values fall back to the constants so the pop still plays.
|
||||
/// </summary>
|
||||
public void PlaySpawnBump()
|
||||
{
|
||||
bumpTween?.Kill();
|
||||
|
||||
float duration = bumpDuration > 0f ? bumpDuration : DefaultBumpDuration;
|
||||
float factor = (startScaleFactor > 0f && startScaleFactor < 1f) ? startScaleFactor : DefaultStartScaleFactor;
|
||||
|
||||
Vector3 targetScale = transform.localScale;
|
||||
transform.localScale = targetScale * factor;
|
||||
bumpTween = transform.DOScale(targetScale, duration).SetEase(bumpEase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Damage
|
||||
|
||||
/// <summary>
|
||||
/// Called by an attacker (weapon, explosion, ...) to hurt this build. Health is server-
|
||||
/// authoritative, so this only forwards the hit; the server applies it and despawns the structure
|
||||
/// for everyone when it dies. A build destroyed by damage is NOT refunded, unlike a manual
|
||||
/// demolition — it broke, its resources are lost. Safe to call from any client.
|
||||
/// </summary>
|
||||
public void TakeDamage(float amount)
|
||||
{
|
||||
if (amount <= 0f) return;
|
||||
TakeDamageServerRpc(amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side: subtracts the damage and despawns the build once it reaches zero.
|
||||
/// </summary>
|
||||
[ServerRpc(RequireOwnership = false)]
|
||||
private void TakeDamageServerRpc(float amount)
|
||||
{
|
||||
if (amount <= 0f) return;
|
||||
|
||||
health -= amount;
|
||||
if (health <= 0f) Despawn();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Demolition
|
||||
|
||||
/// <summary>
|
||||
/// Called by BuildManager when the local player demolishes this build: asks the server to refund
|
||||
/// and remove it. Any client may request it — co-op is friendly, not anti-cheat.
|
||||
/// </summary>
|
||||
public void RequestDemolish() => DemolishServerRpc();
|
||||
|
||||
/// <summary>
|
||||
/// Server-side: refunds the demolisher a share of the cost proportional to the health left, then
|
||||
/// despawns the structure for everyone.
|
||||
/// </summary>
|
||||
[ServerRpc(RequireOwnership = false)]
|
||||
private void DemolishServerRpc(NetworkConnection conn = null)
|
||||
{
|
||||
RefundResources(conn);
|
||||
Despawn();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grants the demolishing player back a share of this build's cost proportional to its remaining
|
||||
/// health: a full-health build refunds its whole cost, a half-health one refunds half, and so on
|
||||
/// (per line, floored — a partial unit is not returned). No-op for a free build, when the player's
|
||||
/// inventory cannot be resolved, or when the fraction rounds every line down to nothing.
|
||||
/// </summary>
|
||||
private void RefundResources(NetworkConnection conn)
|
||||
{
|
||||
BuildableData data = Data;
|
||||
if (data == null || data.Cost == null || data.Cost.Length == 0) return;
|
||||
|
||||
PlayerInventory inventory = ResolveInventory(conn);
|
||||
if (inventory == null) return;
|
||||
|
||||
float max = data.MaxHealth;
|
||||
float fraction = max > 0f ? Mathf.Clamp01(health / max) : 1f;
|
||||
|
||||
foreach (BuildCost line in data.Cost)
|
||||
{
|
||||
if (line.item == null) continue;
|
||||
int refund = Mathf.FloorToInt(line.quantity * fraction);
|
||||
if (refund > 0) inventory.GrantItemFromServer(line.item, refund);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PlayerInventory on the player object owned by the given connection, mirroring
|
||||
/// CookingStation so refunds land in the requester's own inventory.
|
||||
/// </summary>
|
||||
private static PlayerInventory ResolveInventory(NetworkConnection conn)
|
||||
{
|
||||
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
|
||||
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes this structure for every player. Server-side only.
|
||||
/// </summary>
|
||||
private void Despawn()
|
||||
{
|
||||
if (base.IsServerInitialized) base.ServerManager.Despawn(base.NetworkObject, DespawnType.Destroy);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Snap Occupancy
|
||||
|
||||
/// <summary>
|
||||
/// Marks the sockets this 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 from
|
||||
/// geometry (two connected sockets sit on the exact same point), so it stays consistent on every
|
||||
/// client without anything extra crossing the wire.
|
||||
/// </summary>
|
||||
private void MarkOccupancy()
|
||||
{
|
||||
BuildSnapPoint[] mine = GetComponentsInChildren<BuildSnapPoint>(true);
|
||||
if (mine.Length == 0) return;
|
||||
|
||||
foreach (BuiltStructure other in Live)
|
||||
{
|
||||
if (other == null || other == this) continue;
|
||||
|
||||
foreach (BuildSnapPoint os in other.GetComponentsInChildren<BuildSnapPoint>(true))
|
||||
{
|
||||
foreach (BuildSnapPoint ms in mine)
|
||||
{
|
||||
if (ms.Category != os.Category) continue;
|
||||
if ((ms.transform.position - os.transform.position).sqrMagnitude > OccupancyThresholdSqr) continue;
|
||||
|
||||
ms.SetOccupied(true);
|
||||
os.SetOccupied(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes occupancy from scratch across every live structure: clears every socket, then
|
||||
/// re-marks the connected pairs across what remains. Run after a removal.
|
||||
/// </summary>
|
||||
private static void RecomputeOccupancy()
|
||||
{
|
||||
foreach (BuiltStructure structure in Live)
|
||||
{
|
||||
if (structure == null) continue;
|
||||
foreach (BuildSnapPoint sp in structure.GetComponentsInChildren<BuildSnapPoint>(true))
|
||||
sp.SetOccupied(false);
|
||||
}
|
||||
|
||||
foreach (BuiltStructure structure in Live)
|
||||
if (structure != null) structure.MarkOccupancy();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Demolition Highlight
|
||||
|
||||
/// <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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user