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:
@@ -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.1–0.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
|
||||
|
||||
Reference in New Issue
Block a user