using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; using DG.Tweening; using Ashwild.Interaction; using Ashwild.Inventory; using Ashwild.Network; using Ashwild.Player; namespace Ashwild.Harvesting { /// /// A harvestable world object (tree, rock, bush, …). A WorldObject backed by the HarvestableRegistry: /// the registry owns the authoritative health, loot grant, depletion and respawn, while this /// component holds the authored data (max health, drop tables, respawn settings) and plays the /// local hit feedback (the springy scale "pop", mirroring the buildable appear bump). /// /// A hit is always applied through — the single path shared by tool swings /// (ToolBehaviour) and, when is set, bare-hand gathering via the /// interact key (IInteractable). Loot is authored explicitly per source: a dedicated bare-hand table /// and one table per listed tool. Nothing is implicit — a tool that is not listed yields no drops. /// public class Harvestable : WorldObject, IInteractable { #region Serialized Fields [Header("Harvestable")] [SerializeField] private HarvestType harvestType = HarvestType.Tree; [SerializeField] private float maxHealth = 100f; [Header("Bare-Hand Harvest")] [Tooltip("When set, the player can gather this by hand with the interact key (no tool needed). Leave off for trees/rocks.")] [SerializeField] private bool allowBareHandHarvest = false; [Tooltip("Damage dealt by one bare-hand gather. Combined with Max Health this sets how many hand-pulls it takes.")] [SerializeField] private float bareHandDamage = 25f; [Tooltip("Verb shown under the crosshair for bare-hand gathering (e.g. \"Gather\").")] [SerializeField] private string interactionVerb = "Gather"; [Tooltip("Loot rolled when gathered by hand. Each row has its own drop chance and quantity range.")] [SerializeField] private ResourceDrop[] bareHandDrops; [Header("Tool Drops")] [Tooltip("Loot per tool. Every entry MUST have a tool assigned — a tool not listed here yields no drops.")] [SerializeField] private ToolDropTable[] toolDrops; [Header("Hit Feedback")] [Tooltip("Fraction of the authored scale a hit shrinks to before springing back (0.85 = squash to 85%).")] [SerializeField] private float hitStartScaleFactor = DefaultHitStartScaleFactor; [Tooltip("How long the hit pop lasts — short and snappy reads as an impact.")] [SerializeField] private float hitBumpDuration = DefaultHitBumpDuration; [Tooltip("OutBack gives the springy overshoot that sells the hit.")] [SerializeField] private Ease hitBumpEase = Ease.OutBack; [Tooltip("Shrink fraction for a blocked (wrong-tool) clunk — subtler than a real hit.")] [SerializeField] private float blockedStartScaleFactor = 0.95f; [Tooltip("Duration of the blocked clunk.")] [SerializeField] private float blockedBumpDuration = 0.1f; [Header("Respawn")] [SerializeField] private bool canRespawn = true; [SerializeField] private float respawnDelay = 60f; [Header("Events")] public UnityEvent onHit; public UnityEvent onDepleted; #endregion #region Constants /// /// Fallback pop values, used both as field initializers and as guard defaults in /// — so a prefab predating these fields (Unity deserializes the /// missing values to 0) still pops instead of snapping. /// private const float DefaultHitBumpDuration = 0.18f; private const float DefaultHitStartScaleFactor = 0.85f; #endregion #region State /// /// The authored scale captured once, so every hit pop springs back to the true size even when a /// previous pop is interrupted mid-tween (it would otherwise shrink from an already-shrunk scale). /// private Vector3 baseScale = Vector3.one; /// /// The running pop tween, killed before restarting and on teardown. /// private Tween hitTween; /// /// Reusable buffer holding the worst-case loot of one hit, so the per-swing capacity check does /// not allocate. Never holds meaningful state between calls. /// private readonly List potentialDrops = new List(); #endregion #region Public Data (read by the registry, server-side) /// /// What kind of tool harvests this (matched against the tool's HarvestType). /// public HarvestType HarvestType => harvestType; /// /// Full health used to lazy-initialise the registry's authoritative health. /// public float MaxHealth => maxHealth; /// /// Whether this object comes back after a delay once depleted. /// public bool CanRespawn => canRespawn; /// /// Seconds before a depleted object respawns. /// public float RespawnDelay => respawnDelay; /// /// Rolls the loot for a hit (server-side). A null means bare hands and /// rolls the bare-hand table; a tool rolls its own listed table, or nothing when it is not listed. /// Returns the items + quantities to grant; the registry does the actual granting. /// public IEnumerable<(ItemData item, int quantity)> RollDrops(ItemData tool) { ResourceDrop[] table = tool == null ? bareHandDrops : GetToolDrops(tool); if (table == null) yield break; for (int i = 0; i < table.Length; i++) { if (table[i].item == null) continue; if (Random.value > table[i].dropChance) continue; int qty = Random.Range(table[i].minQuantity, table[i].maxQuantity + 1); if (qty > 0) yield return (table[i].item, qty); } } #endregion #region Hit Application (shared) /// /// The single path for applying a harvest hit — used by tool swings (ToolBehaviour) and by /// bare-hand gathering (Interact). Asks the registry (server) to apply the damage/loot/depletion /// authoritatively, plays the local pop for instant response, and raises the bus event. A null /// means bare hands and rolls the bare-hand table. /// /// Every reason the hit cannot land is checked *before* any feedback is played, and returns false /// so the caller skips its follow-up work (tool wear). That ordering is the whole point: the pop /// used to play unconditionally, ahead of a request the server could still refuse for half a dozen /// silent reasons — which is precisely what made a refused harvest indistinguishable from a /// successful one that rolled nothing. A hit that plays now is a hit the server will honour. /// public bool ApplyHit(ItemData tool, float damage) { if (HarvestableRegistry.Instance == null) { Debug.LogError("[Harvestable] No HarvestableRegistry in the scene — cannot harvest.", this); return false; } if (HarvestableRegistry.Instance.IsInactive(Id)) return false; if (!HarvestableRegistry.Instance.IsRegistered(Id)) { Debug.LogError($"[Harvestable] '{name}' (id {Id}) is not tracked by the registry — the server " + "would drop this hit. Check the console for an id error at startup.", this); return false; } if (!CanCarryDrops(tool)) { PlayerEvents.RaiseInteractionRefused("Inventory full"); PlayBlockedFeedback(); return false; } PlayHitEffect(); ushort toolId = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(tool) : (ushort)0; HarvestableRegistry.Instance.RequestHitServerRpc(Id, damage, toolId); PlayerEvents.RaiseHarvestableHit(this, damage); return true; } /// /// Whether the local player could store everything this source is able to roll. The loot itself is /// rolled by the server, which cannot answer this — the inventory is client-authoritative, so the /// server's copy of it is empty — hence the check runs here, on the only machine that knows, and /// mirrors the pre-check pickups already do. /// /// It deliberately tests the *worst case* (every row at its maximum quantity, taken together): the /// alternative is discovering mid-grant that the roll does not fit, at which point the resource is /// already spent. Being pessimistic costs the player a hit they can retry after making room; being /// optimistic costs them loot. Returns true when there is no inventory (offline/editor) or when /// the source has nothing to give, so an empty table never blocks a swing. /// private bool CanCarryDrops(ItemData tool) { PlayerInventory inventory = PlayerInventory.Instance; if (inventory == null) return true; ResourceDrop[] table = tool == null ? bareHandDrops : GetToolDrops(tool); if (table == null || table.Length == 0) return true; potentialDrops.Clear(); for (int i = 0; i < table.Length; i++) { if (table[i].item == null || table[i].dropChance <= 0f) continue; int quantity = Mathf.Max(table[i].minQuantity, table[i].maxQuantity); if (quantity > 0) potentialDrops.Add(SlotContent.Of(table[i].item, quantity, -1)); } return inventory.CanFitAll(potentialDrops); } #endregion #region IInteractable (bare-hand gathering) /// /// Crosshair label while aiming at this object. Only bare-hand-harvestable objects advertise a /// prompt; trees/rocks return null so the crosshair shows nothing (they need a tool swing). /// public string InteractionPrompt => allowBareHandHarvest ? interactionVerb : null; /// /// Bare-hand gather via the interact key. Ignored on non-hand-harvestable objects and while the /// player is building (so an interact press mid-placement never snatches a gather). Routes through /// with a null tool, yielding the bare-hand loot table. /// public void Interact() { if (!allowBareHandHarvest) return; if (PlayerEvents.IsBuilding) return; ApplyHit(null, bareHandDamage); } #endregion #region Feedback (client-side) /// /// The springy scale pop on impact — the same "shrink then spring back with OutBack" look as the /// buildable appear bump. Played on every client (via the registry) and locally on the hitter for /// instant response. /// public void PlayHitEffect() { PlayScalePop(hitStartScaleFactor, hitBumpDuration, hitBumpEase); onHit?.Invoke(); } /// /// A subtler, non-springy pop when a hit deals no damage (player lacks the proper tool). Local only. /// public void PlayBlockedFeedback() { PlayScalePop(blockedStartScaleFactor, blockedBumpDuration, Ease.OutQuad); } /// /// Kills any running pop, snaps to the shrunk start scale, then tweens back to the authored scale. /// Non-positive serialized values fall back to the constants so the pop still plays. /// private void PlayScalePop(float factor, float duration, Ease ease) { hitTween?.Kill(); float f = (factor > 0f && factor < 1f) ? factor : DefaultHitStartScaleFactor; float d = duration > 0f ? duration : DefaultHitBumpDuration; transform.localScale = baseScale * f; hitTween = transform.DOScale(baseScale, d).SetEase(ease); } #endregion #region WorldObject /// /// Captures the authored scale for the pop, then self-registers with the registry as usual. /// protected override void Start() { baseScale = transform.localScale; base.Start(); } /// /// This harvestable belongs to the HarvestableRegistry. /// protected override WorldObjectRegistry GetRegistry() => HarvestableRegistry.Instance; /// /// Hides the object when depleted; on a real depletion (not catch-up) fires onDepleted first. /// public override void HideAsInactive(bool fresh) { if (fresh) onDepleted?.Invoke(); gameObject.SetActive(false); } /// /// Brings the object back on respawn, restoring the authored scale in case a pop was interrupted. /// public override void ShowActive() { hitTween?.Kill(); transform.localScale = baseScale; gameObject.SetActive(true); } #endregion #region Unity Lifecycle /// /// Kills the pop tween so it never targets a destroyed transform. /// private void OnDestroy() => hitTween?.Kill(); #endregion #region Internal Helpers /// /// The drop table listed for the given tool, or null when that tool is not listed (no drops). /// private ResourceDrop[] GetToolDrops(ItemData tool) { if (toolDrops == null) return null; for (int i = 0; i < toolDrops.Length; i++) { if (toolDrops[i].tool == tool) return toolDrops[i].drops; } return null; } #endregion } }