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
{
///
/// 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 — , 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. is what an attacker calls;
/// the server applies it and despawns the build for everyone when it dies (no refund — it broke).
/// 3. Demolition — 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.
///
[DisallowMultipleComponent]
[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
///
/// Fallback bump values, used both as the field initializers and as the guard defaults in
/// — so a build whose prefab predates these serialized fields
/// (Unity deserializes the missing values to 0) still pops instead of snapping in.
///
private const float DefaultBumpDuration = 0.22f;
private const float DefaultStartScaleFactor = 0.7f;
///
/// 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.
///
private const float OccupancyThresholdSqr = 0.05f * 0.05f;
#endregion
#region State
///
/// 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.
///
private static readonly List Live = new List();
///
/// 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.
///
private readonly SyncVar buildableId = new SyncVar();
///
/// Server-only remaining health, seeded from the buildable's authored max at spawn.
///
private float health;
private Renderer[] renderers;
private Material[][] baseMaterials;
private bool highlighted;
private Tween bumpTween;
#endregion
#region Public API
public ushort BuildableId => buildableId.Value;
///
/// The authoring data behind this instance, or null when the database cannot resolve its id.
///
public BuildableData Data => BuildableDatabase.Instance != null
? BuildableDatabase.Instance.GetBuildable(buildableId.Value)
: null;
#endregion
#region Unity Lifecycle
///
/// 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.
///
private void Awake()
{
renderers = GetComponentsInChildren(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];
}
///
/// Kills the appear tween so it never targets a destroyed transform (e.g. the build was
/// demolished mid-pop).
///
private void OnDestroy() => bumpTween?.Kill();
#endregion
#region Network Lifecycle
///
/// 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.
///
public override void OnStartNetwork()
{
base.OnStartNetwork();
Live.Add(this);
MarkOccupancy();
}
///
/// 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.
///
public override void OnStopNetwork()
{
base.OnStopNetwork();
Live.Remove(this);
RecomputeOccupancy();
}
#endregion
#region Server Setup
///
/// 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.
///
public void InitialiseOnServer(ushort id, float maxHealth)
{
buildableId.Value = id;
health = maxHealth > 0f ? maxHealth : 1f;
}
#endregion
#region Appear Bump
///
/// 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.
///
[ObserversRpc]
public void PlaySpawnBumpForObservers() => PlaySpawnBump();
///
/// 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.
///
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
///
/// 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.
///
public void TakeDamage(float amount)
{
if (amount <= 0f) return;
TakeDamageServerRpc(amount);
}
///
/// Server-side: subtracts the damage and despawns the build once it reaches zero.
///
[ServerRpc(RequireOwnership = false)]
private void TakeDamageServerRpc(float amount)
{
if (amount <= 0f) return;
health -= amount;
if (health <= 0f) Despawn();
}
#endregion
#region Demolition
///
/// 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.
///
public void RequestDemolish() => DemolishServerRpc();
///
/// Server-side: refunds the demolisher a share of the cost proportional to the health left, then
/// despawns the structure for everyone.
///
[ServerRpc(RequireOwnership = false)]
private void DemolishServerRpc(NetworkConnection conn = null)
{
RefundResources(conn);
Despawn();
}
///
/// 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.
///
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);
}
}
///
/// Returns the PlayerInventory on the player object owned by the given connection, mirroring
/// CookingStation so refunds land in the requester's own inventory.
///
private static PlayerInventory ResolveInventory(NetworkConnection conn)
{
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
return playerObject != null ? playerObject.GetComponent() : null;
}
///
/// Removes this structure for every player. Server-side only.
///
private void Despawn()
{
if (base.IsServerInitialized) base.ServerManager.Despawn(base.NetworkObject, DespawnType.Destroy);
}
#endregion
#region Snap Occupancy
///
/// 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.
///
private void MarkOccupancy()
{
BuildSnapPoint[] mine = GetComponentsInChildren(true);
if (mine.Length == 0) return;
foreach (BuiltStructure other in Live)
{
if (other == null || other == this) continue;
foreach (BuildSnapPoint os in other.GetComponentsInChildren(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);
}
}
}
}
///
/// Recomputes occupancy from scratch across every live structure: clears every socket, then
/// re-marks the connected pairs across what remains. Run after a removal.
///
private static void RecomputeOccupancy()
{
foreach (BuiltStructure structure in Live)
{
if (structure == null) continue;
foreach (BuildSnapPoint sp in structure.GetComponentsInChildren(true))
sp.SetOccupied(false);
}
foreach (BuiltStructure structure in Live)
if (structure != null) structure.MarkOccupancy();
}
#endregion
#region Demolition Highlight
///
/// 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.
///
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;
}
}
///
/// Restores each renderer's authored materials. No-op when not highlighted.
///
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
}
}