Files
Emberwild/Assets/GAME/Script/Harvesting/Harvestable.cs
T
Mathew 78bfdf2828 Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
2026-07-25 19:42:26 +02:00

351 lines
14 KiB
C#

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
{
/// <summary>
/// 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 <see cref="ApplyHit"/> — the single path shared by tool swings
/// (ToolBehaviour) and, when <see cref="allowBareHandHarvest"/> 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.
/// </summary>
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
/// <summary>
/// Fallback pop values, used both as field initializers and as guard defaults in
/// <see cref="PlayScalePop"/> — so a prefab predating these fields (Unity deserializes the
/// missing values to 0) still pops instead of snapping.
/// </summary>
private const float DefaultHitBumpDuration = 0.18f;
private const float DefaultHitStartScaleFactor = 0.85f;
#endregion
#region State
/// <summary>
/// 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).
/// </summary>
private Vector3 baseScale = Vector3.one;
/// <summary>
/// The running pop tween, killed before restarting and on teardown.
/// </summary>
private Tween hitTween;
/// <summary>
/// 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.
/// </summary>
private readonly List<SlotContent> potentialDrops = new List<SlotContent>();
#endregion
#region Public Data (read by the registry, server-side)
/// <summary>
/// What kind of tool harvests this (matched against the tool's HarvestType).
/// </summary>
public HarvestType HarvestType => harvestType;
/// <summary>
/// Full health used to lazy-initialise the registry's authoritative health.
/// </summary>
public float MaxHealth => maxHealth;
/// <summary>
/// Whether this object comes back after a delay once depleted.
/// </summary>
public bool CanRespawn => canRespawn;
/// <summary>
/// Seconds before a depleted object respawns.
/// </summary>
public float RespawnDelay => respawnDelay;
/// <summary>
/// Rolls the loot for a hit (server-side). A null <paramref name="tool"/> 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.
/// </summary>
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)
/// <summary>
/// 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
/// <paramref name="tool"/> 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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)
/// <summary>
/// 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).
/// </summary>
public string InteractionPrompt => allowBareHandHarvest ? interactionVerb : null;
/// <summary>
/// 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
/// <see cref="ApplyHit"/> with a null tool, yielding the bare-hand loot table.
/// </summary>
public void Interact()
{
if (!allowBareHandHarvest) return;
if (PlayerEvents.IsBuilding) return;
ApplyHit(null, bareHandDamage);
}
#endregion
#region Feedback (client-side)
/// <summary>
/// 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.
/// </summary>
public void PlayHitEffect()
{
PlayScalePop(hitStartScaleFactor, hitBumpDuration, hitBumpEase);
onHit?.Invoke();
}
/// <summary>
/// A subtler, non-springy pop when a hit deals no damage (player lacks the proper tool). Local only.
/// </summary>
public void PlayBlockedFeedback()
{
PlayScalePop(blockedStartScaleFactor, blockedBumpDuration, Ease.OutQuad);
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// Captures the authored scale for the pop, then self-registers with the registry as usual.
/// </summary>
protected override void Start()
{
baseScale = transform.localScale;
base.Start();
}
/// <summary>
/// This harvestable belongs to the HarvestableRegistry.
/// </summary>
protected override WorldObjectRegistry GetRegistry() => HarvestableRegistry.Instance;
/// <summary>
/// Hides the object when depleted; on a real depletion (not catch-up) fires onDepleted first.
/// </summary>
public override void HideAsInactive(bool fresh)
{
if (fresh) onDepleted?.Invoke();
gameObject.SetActive(false);
}
/// <summary>
/// Brings the object back on respawn, restoring the authored scale in case a pop was interrupted.
/// </summary>
public override void ShowActive()
{
hitTween?.Kill();
transform.localScale = baseScale;
gameObject.SetActive(true);
}
#endregion
#region Unity Lifecycle
/// <summary>
/// Kills the pop tween so it never targets a destroyed transform.
/// </summary>
private void OnDestroy() => hitTween?.Kill();
#endregion
#region Internal Helpers
/// <summary>
/// The drop table listed for the given tool, or null when that tool is not listed (no drops).
/// </summary>
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
}
}