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
@@ -0,0 +1,43 @@
using UnityEngine;
namespace Ashwild.Player
{
/// <summary>
/// Turns the arms rig's Animation Events into bus events, so gameplay reacts on the frame the
/// animation says it should instead of on the frame the input arrived. That distinction is what
/// makes a swing read as a swing: without it the axe deals its damage while it is still travelling
/// backwards, and the tree loses a chunk before the blade has touched it.
///
/// Must sit on the same GameObject as the Animator — Unity resolves Animation Events by method name
/// against the components of the animated object. Every method is prefixed "Anim" on purpose: the
/// PlayerInput component uses Send Messages, so any method named after an input action would be
/// invoked by the Input System too and crash on the signature mismatch (see CLAUDE.md §5.6).
///
/// This is owner-only local feedback — the arms exist only on the owning client — so it belongs in
/// PlayerNetworkController.ownerOnlyBehaviours alongside the rest of the arms rig.
/// </summary>
[DisallowMultipleComponent]
public class ArmsAnimationEvents : MonoBehaviour
{
#region Animation Events
/// <summary>
/// The frame the held tool or weapon actually connects. Place it on the contact pose of every
/// attack clip; the swinging item resolves its hit here.
/// </summary>
public void AnimAttackImpact() => PlayerEvents.RaiseAttackImpact();
/// <summary>
/// The draw animation has finished and the item is fully in hand.
/// </summary>
public void AnimEquipComplete() => PlayerEvents.RaiseEquipAnimComplete();
/// <summary>
/// The put-away animation has finished and the item has left the frame — the cue for
/// HotbarController to actually destroy the old prefab and draw the next one.
/// </summary>
public void AnimUnequipComplete() => PlayerEvents.RaiseUnequipAnimComplete();
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f541ac054ea4fc40976871e6e445941
@@ -0,0 +1,127 @@
using UnityEngine;
namespace Ashwild.Player
{
/// <summary>
/// Which of the player's two rigs a set of clips is authored for. The same item needs two
/// different sets: the owner's first-person arms play hand-only clips, while the third-person
/// body everyone else sees plays full-body clips with legs and torso.
/// </summary>
public enum PlayerAnimationRig
{
Arms,
Body
}
/// <summary>
/// The clips one rig plays for one family of held item (bare hands, axe, pickaxe, sword…).
/// This is the asset that makes the animation system extensible without code: the master
/// Animator Controller authors the state machine once with placeholder clips, and each set
/// merely swaps which clip sits in each slot. Adding a new weapon is two assets and one field
/// on its ItemData — never a new controller, never a new state, never a new script.
///
/// Slots are matched by the *name* of the placeholder clip in the master controller, so an
/// authored state named "Run" pulls this set's run clip. A slot left empty falls back to the
/// binder's default set and then to the master's own placeholder, which is why a minimal set
/// (a torch with only an idle and a run) is perfectly valid and never leaves a rig in T-pose.
/// </summary>
[CreateAssetMenu(fileName = "NewAnimationSet", menuName = "Ashwild/Player Animation Set")]
public class PlayerAnimationSet : ScriptableObject
{
#region Slot Names
public const string SlotIdle = "Idle";
public const string SlotWalk = "Walk";
public const string SlotRun = "Run";
public const string SlotCrouchIdle = "CrouchIdle";
public const string SlotCrouchWalk = "CrouchWalk";
public const string SlotJumpStart = "JumpStart";
public const string SlotJumpLoop = "JumpLoop";
public const string SlotJumpLand = "JumpLand";
public const string SlotGrab = "Grab";
public const string SlotEquip = "Equip";
public const string SlotUnequip = "Unequip";
/// <summary>
/// Prefix of the attack slots ("Attack1", "Attack2", …). Numbered rather than enumerated so a
/// three-hit sword combo and a single pickaxe swing share the exact same master controller.
/// </summary>
public const string SlotAttackPrefix = "Attack";
#endregion
#region Serialized Fields
[Header("Locomotion")]
[SerializeField] private AnimationClip idle;
[SerializeField] private AnimationClip walk;
[SerializeField] private AnimationClip run;
[SerializeField] private AnimationClip crouchIdle;
[SerializeField] private AnimationClip crouchWalk;
[Header("Air")]
[SerializeField] private AnimationClip jumpStart;
[SerializeField] private AnimationClip jumpLoop;
[SerializeField] private AnimationClip jumpLand;
[Header("Actions")]
[SerializeField] private AnimationClip grab;
[SerializeField] private AnimationClip equip;
[SerializeField] private AnimationClip unequip;
[Header("Attack")]
[Tooltip("Swing variants, in combo order. One entry for a plain tool, several for a weapon combo.")]
[SerializeField] private AnimationClip[] attackVariants;
#endregion
#region Public API
/// <summary>
/// How many swing variants this set offers, so the swinging item can cycle through a combo
/// without hard-coding a count it cannot see.
/// </summary>
public int AttackVariantCount => attackVariants != null ? attackVariants.Length : 0;
/// <summary>
/// Resolves the clip authored for a slot, or null when this set does not cover it (the caller
/// then falls back). Attack slots are resolved by their trailing number, which is 1-based in the
/// controller ("Attack1" is the first variant) so designers read the states in combo order.
/// </summary>
public AnimationClip GetClip(string slotName)
{
switch (slotName)
{
case SlotIdle: return idle;
case SlotWalk: return walk;
case SlotRun: return run;
case SlotCrouchIdle: return crouchIdle;
case SlotCrouchWalk: return crouchWalk;
case SlotJumpStart: return jumpStart;
case SlotJumpLoop: return jumpLoop;
case SlotJumpLand: return jumpLand;
case SlotGrab: return grab;
case SlotEquip: return equip;
case SlotUnequip: return unequip;
}
if (slotName != null && slotName.StartsWith(SlotAttackPrefix) &&
int.TryParse(slotName.Substring(SlotAttackPrefix.Length), out int variant))
return GetAttackVariant(variant - 1);
return null;
}
/// <summary>
/// The swing clip at a combo index, or null when out of range — an unauthored variant simply
/// falls back instead of throwing mid-fight.
/// </summary>
public AnimationClip GetAttackVariant(int index)
{
if (attackVariants == null || index < 0 || index >= attackVariants.Length) return null;
return attackVariants[index];
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dc23e353ca2e8fb45ae7cbad52c55d9b
@@ -0,0 +1,152 @@
using System.Collections.Generic;
using UnityEngine;
using Ashwild.Inventory;
namespace Ashwild.Player
{
/// <summary>
/// Keeps a rig's Animator loaded with the clips of whatever item the player is holding. It owns
/// exactly one concern — *which clips are in the controller right now* — while PlayerAnimatorDriver
/// owns *which parameters are set*. Splitting them is what lets a new weapon ship without touching
/// either: the driver keeps firing the same "Attack" trigger, the binder just points that slot at a
/// different clip.
///
/// It never assigns a fresh controller per item. Assigning runtimeAnimatorController rebinds the
/// Animator, which resets the state machine to Entry and wipes the parameters — the player would
/// visibly snap to idle every time he scrolled the hotbar. Instead one AnimatorOverrideController is
/// built in Awake and only its overrides are mutated afterwards, which leaves the current state and
/// all parameters untouched. That same property is what keeps FishNet's NetworkAnimator working on
/// the third-person body: an override controller preserves the base controller's parameter list, so
/// replication never sees the swap.
/// </summary>
[DisallowMultipleComponent]
public class PlayerAnimationSetBinder : MonoBehaviour
{
#region Serialized Fields
[Header("Target")]
[SerializeField] private Animator animator;
[Tooltip("Which rig this binder drives — it picks the matching set off the held ItemData.")]
[SerializeField] private PlayerAnimationRig rig = PlayerAnimationRig.Arms;
[Header("Sets")]
[Tooltip("Set used with bare hands, and as the fallback for any slot the held item leaves empty.")]
[SerializeField] private PlayerAnimationSet defaultSet;
#endregion
#region State
private AnimatorOverrideController overrideController;
private AnimationClip[] placeholders;
private readonly List<KeyValuePair<AnimationClip, AnimationClip>> overrides = new List<KeyValuePair<AnimationClip, AnimationClip>>();
private PlayerAnimationSet currentSet;
private bool ready;
#endregion
#region Unity Lifecycle
/// <summary>
/// Wraps the authored controller in a single override controller and caches its placeholder
/// clips, whose names are the slot keys every set is matched against. Done in Awake so the
/// rebind happens once, before any driver seeds a parameter.
/// </summary>
private void Awake()
{
if (animator == null)
{
Debug.LogError($"[PlayerAnimationSetBinder] '{name}' has no Animator assigned — clips will never swap.", this);
return;
}
RuntimeAnimatorController master = animator.runtimeAnimatorController;
if (master == null)
{
Debug.LogError($"[PlayerAnimationSetBinder] '{name}' — the Animator has no controller assigned.", this);
return;
}
overrideController = new AnimatorOverrideController(master) { name = $"{master.name} (Runtime)" };
overrideController.GetOverrides(overrides);
placeholders = new AnimationClip[overrides.Count];
for (int i = 0; i < overrides.Count; i++)
placeholders[i] = overrides[i].Key;
animator.runtimeAnimatorController = overrideController;
ready = true;
ApplySet(defaultSet);
}
/// <summary>
/// Subscribes to the held-item bus. HotbarController re-raises HeldItemChanged on its own first
/// resolve, so a rig enabled after the player already drew an item still lands on the right set.
/// </summary>
private void OnEnable()
{
PlayerEvents.HeldItemChanged += HandleHeldItemChanged;
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.HeldItemChanged -= HandleHeldItemChanged;
}
#endregion
#region Event Handlers
/// <summary>
/// Loads the set the newly drawn item authors for this rig (or the bare-hand set when the player
/// empties his hands).
/// </summary>
private void HandleHeldItemChanged(ItemData item)
{
ApplySet(item != null ? item.GetAnimationSet(rig) : defaultSet);
}
#endregion
#region Internal Helpers
/// <summary>
/// Rewrites every slot in one ApplyOverrides call rather than one indexer assignment per clip,
/// because each individual assignment forces the controller to regenerate. Skips redundant work
/// when the set has not actually changed — several items can legitimately share one set.
/// </summary>
private void ApplySet(PlayerAnimationSet set)
{
if (!ready) return;
if (set == currentSet) return;
currentSet = set;
for (int i = 0; i < placeholders.Length; i++)
{
AnimationClip placeholder = placeholders[i];
overrides[i] = new KeyValuePair<AnimationClip, AnimationClip>(placeholder, Resolve(placeholder.name, set));
}
overrideController.ApplyOverrides(overrides);
}
/// <summary>
/// Picks the clip for a slot: the held item's set first, then the default set, then null — which
/// means "no override" and leaves the master controller's own placeholder playing. That last
/// step is the safety net: a slot nobody authored still animates instead of freezing the rig.
/// </summary>
private AnimationClip Resolve(string slotName, PlayerAnimationSet set)
{
AnimationClip clip = set != null ? set.GetClip(slotName) : null;
if (clip == null && defaultSet != null) clip = defaultSet.GetClip(slotName);
return clip;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1efb301f6b6f0674c94758684e992e0b
@@ -30,8 +30,8 @@ namespace Ashwild.Player
[SerializeField] private string speedParam = "Speed";
[SerializeField] private string moveXParam = "MoveX";
[SerializeField] private string moveYParam = "MoveY";
[Tooltip("Planar speed (m/s) that maps to 1 in the blend tree. 0 feeds raw speed unscaled.")]
[SerializeField] private float normalizeSpeed = 6f;
[Tooltip("Planar speed (m/s) above which the player counts as moving rather than idle.")]
[SerializeField] private float moveThreshold = 0.15f;
[Tooltip("Damping applied to float parameters for smooth blends.")]
[SerializeField] private float floatDampTime = 0.1f;
@@ -40,20 +40,46 @@ namespace Ashwild.Player
[SerializeField] private string crouchingParam = "Crouching";
[SerializeField] private string sprintingParam = "Sprinting";
[Header("Int parameters (leave blank to skip)")]
[Tooltip("Selects which swing variant the attack state plays — 1-based, matching the Attack1/Attack2… slots.")]
[SerializeField] private string attackIndexParam = "AttackIndex";
[Header("Trigger parameters (leave blank to skip)")]
[SerializeField] private string jumpParam = "Jump";
[SerializeField] private string attackParam = "Attack";
[SerializeField] private string landParam = "Land";
[SerializeField] private string grabParam = "Grab";
[SerializeField] private string equipParam = "Equip";
[SerializeField] private string unequipParam = "Unequip";
#endregion
#region Locomotion Levels
/// <summary>
/// The discrete gait the Speed parameter reports, and the thresholds the blend tree is built on.
/// Deliberately not a measured velocity: a metre-per-second reading makes the animation hostage to
/// movement tuning (retune sprintSpeed and every rig silently blends wrong), and it lies whenever
/// the player's actual speed drops without his intent changing — pushing uphill, against a wall,
/// through a slowing effect — where a sprint would visibly decay into a walk. Reporting intent
/// instead keeps the gait stable, and the parameter damping still eases each change.
/// </summary>
private const float IdleLevel = 0f;
private const float WalkLevel = 1f;
private const float RunLevel = 2f;
#endregion
#region State
private Vector3 planarVelocity;
private bool isSprinting;
private int speedId, moveXId, moveYId;
private int groundedId, crouchingId, sprintingId;
private int attackIndexId;
private int jumpId, attackId, landId;
private int grabId, equipId, unequipId;
#endregion
@@ -71,9 +97,13 @@ namespace Ashwild.Player
groundedId = Hash(groundedParam);
crouchingId = Hash(crouchingParam);
sprintingId = Hash(sprintingParam);
attackIndexId = Hash(attackIndexParam);
jumpId = Hash(jumpParam);
attackId = Hash(attackParam);
landId = Hash(landParam);
grabId = Hash(grabParam);
equipId = Hash(equipParam);
unequipId = Hash(unequipParam);
if (orientation == null && animator != null)
orientation = animator.transform;
@@ -92,10 +122,14 @@ namespace Ashwild.Player
PlayerEvents.Jumped += HandleJumped;
PlayerEvents.AttackSwung += HandleAttackSwung;
PlayerEvents.LandingStunStarted += HandleLandingStunStarted;
PlayerEvents.GrabPerformed += HandleGrabPerformed;
PlayerEvents.EquipStarted += HandleEquipStarted;
PlayerEvents.UnequipStarted += HandleUnequipStarted;
isSprinting = PlayerEvents.IsSprinting;
SetBool(groundedId, PlayerEvents.IsGrounded);
SetBool(crouchingId, PlayerEvents.IsCrouching);
SetBool(sprintingId, PlayerEvents.IsSprinting);
SetBool(sprintingId, isSprinting);
}
/// <summary>
@@ -110,26 +144,31 @@ namespace Ashwild.Player
PlayerEvents.Jumped -= HandleJumped;
PlayerEvents.AttackSwung -= HandleAttackSwung;
PlayerEvents.LandingStunStarted -= HandleLandingStunStarted;
PlayerEvents.GrabPerformed -= HandleGrabPerformed;
PlayerEvents.EquipStarted -= HandleEquipStarted;
PlayerEvents.UnequipStarted -= HandleUnequipStarted;
}
/// <summary>
/// Pushes the smoothed movement floats every frame so blend trees ease between poses
/// instead of snapping when velocity changes.
/// Pushes the smoothed movement floats every frame. The gait itself is a step function, but the
/// damping on the way to the Animator is what turns idle → walk → run into a blend instead of a
/// snap, so the discrete parameter still reads as continuous motion.
/// </summary>
private void Update()
{
if (animator == null) return;
float scale = normalizeSpeed > 0f ? 1f / normalizeSpeed : 1f;
SetFloat(speedId, planarVelocity.magnitude * scale);
float level = ComputeLocomotionLevel();
SetFloat(speedId, level);
if (moveXId != 0 || moveYId != 0)
{
Vector3 direction = planarVelocity.sqrMagnitude > 0.0001f ? planarVelocity.normalized : Vector3.zero;
Vector3 local = orientation != null
? orientation.InverseTransformDirection(planarVelocity)
: planarVelocity;
SetFloat(moveXId, local.x * scale);
SetFloat(moveYId, local.z * scale);
? orientation.InverseTransformDirection(direction)
: direction;
SetFloat(moveXId, local.x * level);
SetFloat(moveYId, local.z * level);
}
}
@@ -147,19 +186,54 @@ namespace Ashwild.Player
private void HandleGroundedChanged(bool grounded, float impactSpeed) => SetBool(groundedId, grounded);
private void HandleCrouchChanged(bool crouching) => SetBool(crouchingId, crouching);
private void HandleSprintChanged(bool sprinting) => SetBool(sprintingId, sprinting);
/// <summary>
/// Caches the sprint intent as well as setting the bool, because the gait reported through Speed
/// is chosen from the player's intent rather than from how fast he happens to be travelling.
/// </summary>
private void HandleSprintChanged(bool sprinting)
{
isSprinting = sprinting;
SetBool(sprintingId, sprinting);
}
private void HandleJumped() => SetTrigger(jumpId);
private void HandleAttackSwung() => SetTrigger(attackId);
/// <summary>
/// Selects the combo variant before firing the swing, so the attack state reads an index that is
/// already correct on the frame it is entered. The parameter is 1-based to match the Attack1/
/// Attack2… clip slots a designer sees in the controller.
/// </summary>
private void HandleAttackSwung(int variant)
{
SetInt(attackIndexId, variant + 1);
SetTrigger(attackId);
}
/// <summary>
/// A landing stun begins on a hard touchdown — the cue for a landing animation.
/// </summary>
private void HandleLandingStunStarted(float duration) => SetTrigger(landId);
private void HandleGrabPerformed() => SetTrigger(grabId);
private void HandleEquipStarted() => SetTrigger(equipId);
private void HandleUnequipStarted() => SetTrigger(unequipId);
#endregion
#region Internal Helpers
/// <summary>
/// Picks the gait to report. Velocity is consulted only to answer "is he moving at all" — the
/// distinction between walking and running comes from the sprint intent, never from a speed
/// reading, so the two never disagree. Crouching is not a level of its own: it is a separate bool
/// so a crouch tree can reuse the same idle/walk distinction underneath it.
/// </summary>
private float ComputeLocomotionLevel()
{
if (planarVelocity.magnitude < moveThreshold) return IdleLevel;
return isSprinting ? RunLevel : WalkLevel;
}
/// <summary>
/// Hashes a parameter name, returning 0 for a blank name so the setters can skip it.
/// </summary>
@@ -175,6 +249,11 @@ namespace Ashwild.Player
if (id != 0 && animator != null) animator.SetBool(id, value);
}
private void SetInt(int id, int value)
{
if (id != 0 && animator != null) animator.SetInteger(id, value);
}
private void SetTrigger(int id)
{
if (id != 0 && animator != null) animator.SetTrigger(id);