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 } }