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
This commit is contained in:
2026-07-25 19:42:26 +02:00
954 changed files with 999772 additions and 26193 deletions
+221 -56
View File
@@ -2,18 +2,25 @@ 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, …). A WorldObject backed by the HarvestableRegistry:
/// 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 feedback (squash/stretch on hit, blocked "clunk", fall on depletion).
/// 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
public class Harvestable : WorldObject, IInteractable
{
#region Serialized Fields
@@ -21,23 +28,31 @@ namespace Ashwild.Harvesting
[SerializeField] private HarvestType harvestType = HarvestType.Tree;
[SerializeField] private float maxHealth = 100f;
[Header("Drops (per tool)")]
[Tooltip("Each entry is the loot for one tool. Add an entry with no tool as a fallback for unlisted tools.")]
[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("Squash & stretch amount on impact (0.10.2 reads well).")]
[SerializeField] private float punchScale = 0.15f;
[Tooltip("Bend angle (degrees) the object tips away from the hit.")]
[SerializeField] private float punchAngle = 8f;
[SerializeField] private float punchDuration = 0.3f;
[SerializeField] private int punchVibrato = 6;
[SerializeField, Range(0f, 1f)] private float punchElasticity = 0.5f;
[Header("Blocked Feedback (wrong / no tool)")]
[Tooltip("Tiny wobble played when the hit deals no damage (no proper tool).")]
[SerializeField] private float blockedAngle = 2.5f;
[SerializeField] private float blockedDuration = 0.2f;
[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;
@@ -49,6 +64,39 @@ namespace Ashwild.Harvesting
#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>
@@ -72,12 +120,13 @@ namespace Ashwild.Harvesting
public float RespawnDelay => respawnDelay;
/// <summary>
/// Rolls the loot for a hit with the given tool (server-side). Returns the items + quantities
/// to grant; the registry does the actual granting. No inventory access here.
/// 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 = GetDropsFor(tool);
ResourceDrop[] table = tool == null ? bareHandDrops : GetToolDrops(tool);
if (table == null) yield break;
for (int i = 0; i < table.Length; i++)
@@ -93,39 +142,158 @@ namespace Ashwild.Harvesting
#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>
/// Squash & stretch + bend away from the hit. Played on every client (via the registry) and
/// locally on the hitter for instant response.
/// 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(Vector3 hitDirection)
public void PlayHitEffect()
{
transform.DOComplete();
Vector3 tiltAxis = TiltAxisFor(hitDirection);
transform.DOPunchRotation(tiltAxis * punchAngle, punchDuration, punchVibrato, punchElasticity);
Vector3 squash = new Vector3(punchScale, -punchScale, punchScale);
transform.DOPunchScale(squash, punchDuration, punchVibrato, punchElasticity);
PlayScalePop(hitStartScaleFactor, hitBumpDuration, hitBumpEase);
onHit?.Invoke();
}
/// <summary>
/// Small "clunk" when a hit deals no damage (player lacks the proper tool). Local only.
/// A subtler, non-springy pop when a hit deals no damage (player lacks the proper tool). Local only.
/// </summary>
public void PlayBlockedFeedback(Vector3 hitDirection)
public void PlayBlockedFeedback()
{
transform.DOComplete();
Vector3 tiltAxis = TiltAxisFor(hitDirection);
transform.DOPunchRotation(tiltAxis * blockedAngle, blockedDuration, punchVibrato, punchElasticity);
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>
@@ -141,43 +309,40 @@ namespace Ashwild.Harvesting
}
/// <summary>
/// Brings the object back on respawn.
/// 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>
/// Drop table matching the given tool, or the fallback entry (tool == null) when the tool
/// isn't listed explicitly. Returns null if nothing matches.
/// The drop table listed for the given tool, or null when that tool is not listed (no drops).
/// </summary>
private ResourceDrop[] GetDropsFor(ItemData tool)
private ResourceDrop[] GetToolDrops(ItemData tool)
{
ResourceDrop[] fallback = null;
if (toolDrops == null) return null;
for (int i = 0; i < toolDrops.Length; i++)
{
if (toolDrops[i].tool == tool) return toolDrops[i].drops;
if (toolDrops[i].tool == null) fallback = toolDrops[i].drops;
}
return fallback;
}
/// <summary>
/// Rotation axis that tips the object's top in the direction of the hit. Falls back to a fixed
/// axis when the hit is vertical or has no horizontal component.
/// </summary>
private Vector3 TiltAxisFor(Vector3 hitDirection)
{
Vector3 horizontal = new Vector3(hitDirection.x, 0f, hitDirection.z);
if (horizontal.sqrMagnitude < 0.0001f)
return Vector3.right;
return Vector3.Cross(Vector3.up, horizontal.normalized);
return null;
}
#endregion