78bfdf2828
# 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
351 lines
14 KiB
C#
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
|
|
}
|
|
}
|