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:
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Network;
|
||||
using DG.Tweening;
|
||||
using FishNet.Connection;
|
||||
using FishNet.Object;
|
||||
@@ -250,13 +251,17 @@ namespace Ashwild.Building
|
||||
/// health: a full-health build refunds its whole cost, a half-health one refunds half, and so on
|
||||
/// (per line, floored — a partial unit is not returned). No-op for a free build, when the player's
|
||||
/// inventory cannot be resolved, or when the fraction rounds every line down to nothing.
|
||||
///
|
||||
/// The inventory comes from the shared lookup, which scans the connection's objects rather than
|
||||
/// trusting FirstObject — the demolisher may also own a build ghost, which FirstObject can resolve
|
||||
/// to instead of the player.
|
||||
/// </summary>
|
||||
private void RefundResources(NetworkConnection conn)
|
||||
{
|
||||
BuildableData data = Data;
|
||||
if (data == null || data.Cost == null || data.Cost.Length == 0) return;
|
||||
|
||||
PlayerInventory inventory = ResolveInventory(conn);
|
||||
PlayerInventory inventory = NetworkPlayerLookup.ResolveInventory(conn);
|
||||
if (inventory == null) return;
|
||||
|
||||
float max = data.MaxHealth;
|
||||
@@ -270,16 +275,6 @@ namespace Ashwild.Building
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PlayerInventory on the player object owned by the given connection, mirroring
|
||||
/// CookingStation so refunds land in the requester's own inventory.
|
||||
/// </summary>
|
||||
private static PlayerInventory ResolveInventory(NetworkConnection conn)
|
||||
{
|
||||
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
|
||||
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes this structure for every player. Server-side only.
|
||||
/// </summary>
|
||||
|
||||
@@ -315,6 +315,7 @@ namespace Ashwild.Cooking
|
||||
if (cooked != null && !inv.CanFit(cooked, 1))
|
||||
{
|
||||
Debug.LogWarning($"[CookingStation] '{name}' cannot give '{cooked.ItemName}' — inventory full.", this);
|
||||
PlayerEvents.RaiseInteractionRefused("Inventory full");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -463,13 +464,10 @@ namespace Ashwild.Cooking
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PlayerInventory on the player object owned by the given connection.
|
||||
/// Returns the PlayerInventory owned by the given connection. Delegates to the shared lookup,
|
||||
/// which scans the connection's objects instead of trusting FirstObject (see NetworkPlayerLookup).
|
||||
/// </summary>
|
||||
private PlayerInventory ResolveInventory(NetworkConnection conn)
|
||||
{
|
||||
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
|
||||
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
|
||||
}
|
||||
private PlayerInventory ResolveInventory(NetworkConnection conn) => NetworkPlayerLookup.ResolveInventory(conn);
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -11,9 +11,23 @@ namespace Ashwild.Crafting
|
||||
/// from recipe data, hands it the player-dependent queries, and refreshes it whenever the
|
||||
/// local player's inventory changes. The UI never reaches back into the player or this class.
|
||||
/// Keep this and CraftingUI as stable scene objects so the reference can't break.
|
||||
///
|
||||
/// It also owns the crafting *context* — bare hands or a station. Opening the panel from a
|
||||
/// <see cref="CraftingStation"/> widens the visible catalog to that station's recipes for as long as
|
||||
/// the window stays open; closing it drops back to hand crafting.
|
||||
/// </summary>
|
||||
public class CraftingManager : MonoBehaviour
|
||||
{
|
||||
#region Singleton
|
||||
|
||||
/// <summary>
|
||||
/// The scene's crafting manager, so a world station can hand itself over without a
|
||||
/// FindObjectOfType. Mirrors BuildManager.
|
||||
/// </summary>
|
||||
public static CraftingManager Instance { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("Data")]
|
||||
@@ -34,6 +48,12 @@ namespace Ashwild.Crafting
|
||||
private bool bound;
|
||||
private readonly HashSet<CraftingRecipe> unlockedRecipes = new HashSet<CraftingRecipe>();
|
||||
|
||||
/// <summary>
|
||||
/// The station the panel is currently open on, or None while the player crafts bare-handed
|
||||
/// (the inventory opened with the normal key). Reset whenever the window closes.
|
||||
/// </summary>
|
||||
private CraftingStationType activeStation = CraftingStationType.None;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
@@ -43,37 +63,64 @@ namespace Ashwild.Crafting
|
||||
/// </summary>
|
||||
public CraftingRecipe[] Recipes => recipes;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the crafting panel on the given station: its recipes join the catalog and the inventory
|
||||
/// window opens straight on the craft tab. The context lasts until the window closes.
|
||||
/// Called by <see cref="CraftingStation.Interact"/> on the interacting client only.
|
||||
/// </summary>
|
||||
public void OpenStation(CraftingStation station)
|
||||
{
|
||||
if (station == null) return;
|
||||
|
||||
if (InventoryUI.Instance == null)
|
||||
{
|
||||
Debug.LogError("[CraftingManager] No InventoryUI in the scene — cannot open the crafting panel.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
activeStation = station.StationType;
|
||||
InventoryUI.Instance.OpenAtCategory(InventoryCategory.Crafting);
|
||||
|
||||
if (craftingUI != null)
|
||||
craftingUI.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the local player can currently craft the recipe the given number of times.
|
||||
/// A recipe is only craftable once every ingredient has been discovered, on top of the usual
|
||||
/// inventory check — an ingredient still shown as a mystery ("?") blocks the craft.
|
||||
/// A recipe is only craftable once every ingredient has been discovered and the right station is
|
||||
/// open, on top of the usual inventory check — an ingredient still shown as a mystery ("?")
|
||||
/// blocks the craft.
|
||||
/// </summary>
|
||||
public bool CanCraft(CraftingRecipe recipe, int count = 1)
|
||||
{
|
||||
return PlayerInventory.Instance != null
|
||||
&& IsStationAvailable(recipe)
|
||||
&& IsFullyDiscovered(recipe)
|
||||
&& recipe.CanCraft(PlayerInventory.Instance, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the ingredients and produces the result if the local player can afford it and has
|
||||
/// discovered every ingredient. The result item is added through the inventory, which in turn
|
||||
/// discovers it — so crafting an intermediate result reveals the recipes further down the chain.
|
||||
/// Consumes the ingredients and produces the result if the local player can afford it, has
|
||||
/// discovered every ingredient, and is at the station the recipe requires. The result item is
|
||||
/// added through the inventory, which in turn discovers it — so crafting an intermediate result
|
||||
/// reveals the recipes further down the chain.
|
||||
///
|
||||
/// The station is re-checked here rather than trusted from the visible list: the panel could
|
||||
/// have been left open through a context change, and a bench recipe must never be craftable
|
||||
/// bare-handed.
|
||||
/// </summary>
|
||||
public bool TryCraft(CraftingRecipe recipe, int count = 1)
|
||||
{
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
if (inv == null || !IsFullyDiscovered(recipe) || !recipe.CanCraft(inv, count))
|
||||
if (inv == null || !IsStationAvailable(recipe) || !IsFullyDiscovered(recipe) || !recipe.CanCraft(inv, count))
|
||||
return false;
|
||||
|
||||
// Consume ingredients
|
||||
for (int i = 0; i < recipe.Ingredients.Length; i++)
|
||||
{
|
||||
CraftingIngredient ingredient = recipe.Ingredients[i];
|
||||
inv.RemoveItemByData(ingredient.item, ingredient.quantity * count);
|
||||
}
|
||||
|
||||
// Produce result
|
||||
inv.AddItem(recipe.ResultItem, recipe.ResultQuantity * count);
|
||||
|
||||
onCraftSuccess?.Invoke(recipe);
|
||||
@@ -85,12 +132,22 @@ namespace Ashwild.Crafting
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the local player spawn so we can bind to its inventory.
|
||||
/// Registers the singleton so world stations can reach the manager.
|
||||
/// </summary>
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the local player spawn so we can bind to its inventory, and to the window's
|
||||
/// open state so the station context is dropped when it closes.
|
||||
/// </summary>
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayerEvents.LocalPlayerSpawned += HandleLocalPlayerSpawned;
|
||||
PlayerEvents.ItemDiscovered += HandleItemDiscovered;
|
||||
PlayerEvents.InventoryOpenChanged += HandleInventoryOpenChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,6 +157,7 @@ namespace Ashwild.Crafting
|
||||
{
|
||||
PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned;
|
||||
PlayerEvents.ItemDiscovered -= HandleItemDiscovered;
|
||||
PlayerEvents.InventoryOpenChanged -= HandleInventoryOpenChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -118,12 +176,14 @@ namespace Ashwild.Crafting
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops listening to the inventory when destroyed.
|
||||
/// Stops listening to the inventory and clears the singleton when destroyed.
|
||||
/// </summary>
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (inventory != null)
|
||||
inventory.onSlotChanged.RemoveListener(HandleInventoryChanged);
|
||||
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -157,6 +217,21 @@ namespace Ashwild.Crafting
|
||||
craftingUI.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the station context when the inventory window closes, so the next hand-opened panel is
|
||||
/// back to hand-only recipes. Tying this to the window rather than to a distance check keeps a
|
||||
/// single, unambiguous rule: the station lasts exactly as long as the panel it opened.
|
||||
/// </summary>
|
||||
private void HandleInventoryOpenChanged(bool open)
|
||||
{
|
||||
if (open || activeStation == CraftingStationType.None) return;
|
||||
|
||||
activeStation = CraftingStationType.None;
|
||||
|
||||
if (craftingUI != null)
|
||||
craftingUI.Refresh();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
@@ -187,13 +262,17 @@ namespace Ashwild.Crafting
|
||||
private void SeedUnlockedRecipes()
|
||||
{
|
||||
for (int i = 0; i < recipes.Length; i++)
|
||||
if (IsRecipeVisible(recipes[i]))
|
||||
if (IsDiscoveryRevealed(recipes[i]))
|
||||
unlockedRecipes.Add(recipes[i]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises a "NEW" notification for every recipe that has just become visible (its first
|
||||
/// Raises a "NEW" notification for every recipe that has just been revealed (its first
|
||||
/// discovered ingredient), each announced once. Called after an item is discovered.
|
||||
///
|
||||
/// Deliberately keyed on discovery alone, not on the station filter: a bench recipe must still
|
||||
/// announce itself the moment its ingredient is found — that is the cue telling the player the
|
||||
/// bench is worth building — instead of silently waiting for the bench to be opened.
|
||||
/// </summary>
|
||||
private void NotifyNewlyUnlockedRecipes()
|
||||
{
|
||||
@@ -201,7 +280,7 @@ namespace Ashwild.Crafting
|
||||
{
|
||||
CraftingRecipe recipe = recipes[i];
|
||||
if (unlockedRecipes.Contains(recipe)) continue;
|
||||
if (!IsRecipeVisible(recipe)) continue;
|
||||
if (!IsDiscoveryRevealed(recipe)) continue;
|
||||
|
||||
unlockedRecipes.Add(recipe);
|
||||
PlayerEvents.RaiseRecipeDiscovered(recipe.Icon, recipe.RecipeName);
|
||||
@@ -225,10 +304,21 @@ namespace Ashwild.Crafting
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the recipe should appear at all: revealed as soon as at least one of its ingredients
|
||||
/// has been discovered. Undiscovered ingredients then show as a mystery until found.
|
||||
/// Whether the recipe should appear in the list right now: revealed by discovery AND allowed by
|
||||
/// the station the panel is open on. The two criteria stay separate helpers on purpose —
|
||||
/// discovery is permanent player progress, the station is a transient context — and only their
|
||||
/// conjunction drives what the player sees.
|
||||
/// </summary>
|
||||
private bool IsRecipeVisible(CraftingRecipe recipe)
|
||||
{
|
||||
return IsStationAvailable(recipe) && IsDiscoveryRevealed(recipe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the recipe has been revealed by discovery: true as soon as at least one of its
|
||||
/// ingredients has been found. Undiscovered ingredients then show as a mystery until found.
|
||||
/// </summary>
|
||||
private bool IsDiscoveryRevealed(CraftingRecipe recipe)
|
||||
{
|
||||
CraftingIngredient[] ingredients = recipe.Ingredients;
|
||||
for (int i = 0; i < ingredients.Length; i++)
|
||||
@@ -236,6 +326,17 @@ namespace Ashwild.Crafting
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the recipe's required station matches the current context: a recipe requiring none is
|
||||
/// craftable everywhere (bare hands and every station alike), any other one only on a station of
|
||||
/// its exact type.
|
||||
/// </summary>
|
||||
private bool IsStationAvailable(CraftingRecipe recipe)
|
||||
{
|
||||
return recipe.RequiredStation == CraftingStationType.None
|
||||
|| recipe.RequiredStation == activeStation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether every ingredient of the recipe has been discovered — the gate for actually crafting it.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,11 @@ using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.Crafting
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring data for one craftable result: what it produces, what it costs, and where it can be
|
||||
/// crafted. Pure data — the craft itself is performed by CraftingManager, which owns the player
|
||||
/// and discovery rules on top of what is declared here.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "NewRecipe", menuName = "Items/Crafting Recipe")]
|
||||
public class CraftingRecipe : ScriptableObject
|
||||
{
|
||||
@@ -11,6 +16,10 @@ namespace Ashwild.Crafting
|
||||
[SerializeField] private ItemData resultItem;
|
||||
[SerializeField] private int resultQuantity = 1;
|
||||
|
||||
[Header("Station")]
|
||||
[Tooltip("None = craftable bare-handed AND on every station. Any other value restricts this recipe to that station.")]
|
||||
[SerializeField] private CraftingStationType requiredStation = CraftingStationType.None;
|
||||
|
||||
[Header("Ingredients")]
|
||||
[SerializeField] private CraftingIngredient[] ingredients;
|
||||
|
||||
@@ -20,6 +29,17 @@ namespace Ashwild.Crafting
|
||||
public int ResultQuantity => resultQuantity;
|
||||
public CraftingIngredient[] Ingredients => ingredients;
|
||||
|
||||
/// <summary>
|
||||
/// The station this recipe must be crafted on, or <see cref="CraftingStationType.None"/> when it
|
||||
/// needs none.
|
||||
/// </summary>
|
||||
public CraftingStationType RequiredStation => requiredStation;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the given inventory holds enough of every ingredient to craft this recipe the given
|
||||
/// number of times. Purely a resource check — discovery and station rules are enforced by
|
||||
/// CraftingManager on top of it.
|
||||
/// </summary>
|
||||
public bool CanCraft(PlayerInventory inventory, int count = 1)
|
||||
{
|
||||
for (int i = 0; i < ingredients.Length; i++)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using UnityEngine;
|
||||
using Ashwild.Interaction;
|
||||
|
||||
namespace Ashwild.Crafting
|
||||
{
|
||||
/// <summary>
|
||||
/// A world workstation the player interacts with to open the crafting panel with this station's
|
||||
/// recipes unlocked. It declares which station it is and nothing more: the recipe catalog, the
|
||||
/// filtering and the craft itself all stay in <see cref="CraftingManager"/>, which this simply hands
|
||||
/// the station type to.
|
||||
///
|
||||
/// Multiplayer: deliberately a plain MonoBehaviour, NOT a NetworkBehaviour. Unlike a chest or a
|
||||
/// cooking station it owns no shared state — a craft only consumes and produces in the crafting
|
||||
/// player's own (client-authoritative) inventory — so two players using the same bench at once can
|
||||
/// never clobber each other and there is strictly nothing to replicate. The object it sits on is
|
||||
/// still a networked build (it carries BuiltStructure like any placed structure); this component
|
||||
/// just does not participate in that.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class CraftingStation : MonoBehaviour, IInteractable
|
||||
{
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("Station")]
|
||||
[Tooltip("Recipes requiring this exact station become craftable while its panel is open.")]
|
||||
[SerializeField] private CraftingStationType stationType = CraftingStationType.Workbench;
|
||||
|
||||
[Tooltip("Name shown in the interaction prompt.")]
|
||||
[SerializeField] private string displayName = "Établi";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Which station this is — the key CraftingManager filters recipes against.
|
||||
/// </summary>
|
||||
public CraftingStationType StationType => stationType;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Warns early when the station is left on None, which would unlock nothing beyond hand crafting
|
||||
/// and read as a broken bench in game.
|
||||
/// </summary>
|
||||
private void Awake()
|
||||
{
|
||||
if (stationType == CraftingStationType.None)
|
||||
Debug.LogError($"[CraftingStation] '{name}' has its station type left on None — it will unlock no recipe.", this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IInteractable
|
||||
|
||||
/// <summary>
|
||||
/// Prompt shown while aiming at the station.
|
||||
/// </summary>
|
||||
public string InteractionPrompt => $"Utiliser {displayName}";
|
||||
|
||||
/// <summary>
|
||||
/// Opens the crafting panel bound to this station. Runs on the interacting client only — nothing
|
||||
/// here is replicated, every player browses their own panel.
|
||||
/// </summary>
|
||||
public void Interact()
|
||||
{
|
||||
if (CraftingManager.Instance == null)
|
||||
{
|
||||
Debug.LogError("[CraftingStation] No CraftingManager in the scene — cannot open the crafting panel.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
CraftingManager.Instance.OpenStation(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3323e66aad475444191d473bbba8673a
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Ashwild.Crafting
|
||||
{
|
||||
/// <summary>
|
||||
/// Which workstation a recipe needs to be crafted on. Deliberately an enum rather than a bool so a
|
||||
/// second station (forge, alchemy bench, ...) is a new value instead of a data migration on every
|
||||
/// existing recipe.
|
||||
///
|
||||
/// <see cref="None"/> is the default (value 0) so every recipe authored before this existed stays
|
||||
/// hand-craftable, and it means "no station required" — such a recipe is craftable bare-handed *and*
|
||||
/// on every station. Any other value restricts the recipe to a station of that exact type.
|
||||
/// </summary>
|
||||
public enum CraftingStationType
|
||||
{
|
||||
None = 0,
|
||||
Workbench = 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0587c6bc97def02449cfde6af1d03815
|
||||
@@ -1,121 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Profiling;
|
||||
using Ashwild.Building;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// A load test for the decision to spawn every build as a NetworkObject: places a grid of structures
|
||||
/// and reports how long it took and what it cost in memory, so the "can FishNet carry a whole base"
|
||||
/// question can be answered with numbers instead of estimates.
|
||||
///
|
||||
/// Deliberately single-instance. Measuring a second client's join bandwidth would mean two peers, and
|
||||
/// the session is wired to FishySteamworks — that costs far more setup than the risk it covers. Join
|
||||
/// traffic is a one-shot cost of roughly a spawn packet per object anyway; what actually matters day
|
||||
/// to day is the steady state, and that is exactly what this measures. Watch the profiler's frame time
|
||||
/// after the spawn, not just the numbers logged here.
|
||||
///
|
||||
/// Caveat when reading the elapsed time: this places everything in a single frame, which no player
|
||||
/// ever does. Treat it as a worst case for the spawn burst, not as a gameplay measurement.
|
||||
/// </summary>
|
||||
public static class BuildStressTest
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const float Spacing = 4f;
|
||||
private const float GroundHeight = 0f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Menu
|
||||
|
||||
/// <summary>
|
||||
/// Places 250 structures — a realistic co-op base.
|
||||
/// </summary>
|
||||
[MenuItem("Tools/Ashwild/Stress Test/Spawn 250 Builds")]
|
||||
public static void Spawn250() => SpawnGrid(250);
|
||||
|
||||
/// <summary>
|
||||
/// Places 1000 structures — an ambitious long-save base, the level the NetworkObject decision was
|
||||
/// judged against.
|
||||
/// </summary>
|
||||
[MenuItem("Tools/Ashwild/Stress Test/Spawn 1000 Builds")]
|
||||
public static void Spawn1000() => SpawnGrid(1000);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Commits <paramref name="count"/> builds of the first usable buildable on a grid around the
|
||||
/// origin, then logs the elapsed time and the memory delta. Requires play mode with a live session,
|
||||
/// since committing goes through the registry's server RPC exactly as a real placement would.
|
||||
/// </summary>
|
||||
private static void SpawnGrid(int count)
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Debug.LogError("[BuildStressTest] Enter play mode and host a session first — builds are spawned through the server.");
|
||||
return;
|
||||
}
|
||||
if (BuildRegistry.Instance == null)
|
||||
{
|
||||
Debug.LogError("[BuildStressTest] No BuildRegistry in the session — is the scene's BuildRegistry present and the session started?");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveBuildable(out ushort id, out string label)) return;
|
||||
|
||||
int side = Mathf.CeilToInt(Mathf.Sqrt(count));
|
||||
long memoryBefore = Profiler.GetTotalAllocatedMemoryLong();
|
||||
Stopwatch watch = Stopwatch.StartNew();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Vector3 position = new Vector3((i % side) * Spacing, GroundHeight, (i / side) * Spacing);
|
||||
BuildRegistry.Instance.RequestBuild(id, position, Quaternion.identity);
|
||||
}
|
||||
|
||||
watch.Stop();
|
||||
long memoryDelta = Profiler.GetTotalAllocatedMemoryLong() - memoryBefore;
|
||||
|
||||
Debug.Log($"[BuildStressTest] Requested {count}× '{label}' in {watch.ElapsedMilliseconds} ms " +
|
||||
$"({memoryDelta / 1024f / 1024f:F1} MB allocated this frame). " +
|
||||
"Spawns complete over the next frames — check the profiler's steady-state frame time now.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the first buildable that can actually be spawned, so the test never fails halfway on a
|
||||
/// half-authored asset. Reports clearly when the database is empty or unbuilt.
|
||||
/// </summary>
|
||||
private static bool TryResolveBuildable(out ushort id, out string label)
|
||||
{
|
||||
id = 0;
|
||||
label = string.Empty;
|
||||
|
||||
BuildableDatabase database = BuildableDatabase.Instance;
|
||||
if (database == null || database.Buildables == null || database.Buildables.Length == 0)
|
||||
{
|
||||
Debug.LogError("[BuildStressTest] BuildableDatabase is empty — run Tools ▸ Ashwild ▸ Rebuild Buildable Database.");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (BuildableData buildable in database.Buildables)
|
||||
{
|
||||
if (buildable == null || buildable.BuiltPrefab == null) continue;
|
||||
|
||||
id = database.GetId(buildable);
|
||||
label = buildable.name;
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.LogError("[BuildStressTest] No buildable with a BuiltPrefab found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dfaebf3f0f14ca94e803a73cc00965f7
|
||||
@@ -1,109 +0,0 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Ashwild.Building;
|
||||
using FishNet.Object;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// One-shot migration for the move to server-spawned builds: every committed structure is now a real
|
||||
/// NetworkObject, so each buildable's BuiltPrefab needs a NetworkObject and a BuiltStructure on its
|
||||
/// root. Prefabs authored before that change have neither (or only BuiltStructure, which is now a
|
||||
/// NetworkBehaviour and cannot function alone), and a missing NetworkObject makes the prefab silently
|
||||
/// unspawnable — the exact failure that used to leave a placed chest deactivated.
|
||||
///
|
||||
/// Safe to re-run: it only touches prefabs that are actually missing a component, and reports what it
|
||||
/// changed. Kept as an explicit menu action rather than an automatic postprocessor because it rewrites
|
||||
/// authored assets, which should never happen behind the designer's back.
|
||||
/// </summary>
|
||||
public static class BuiltPrefabMigrator
|
||||
{
|
||||
#region Menu
|
||||
|
||||
/// <summary>
|
||||
/// Scans every BuildableData, ensures its BuiltPrefab carries NetworkObject + BuiltStructure, and
|
||||
/// asks FishNet to rescan its spawnable prefab collection afterwards.
|
||||
/// </summary>
|
||||
[MenuItem("Tools/Ashwild/Migrate Built Prefabs To Network Objects")]
|
||||
public static void Migrate()
|
||||
{
|
||||
string[] guids = AssetDatabase.FindAssets("t:BuildableData");
|
||||
int migrated = 0;
|
||||
int alreadyFine = 0;
|
||||
int missingPrefab = 0;
|
||||
|
||||
foreach (string guid in guids)
|
||||
{
|
||||
BuildableData buildable = AssetDatabase.LoadAssetAtPath<BuildableData>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (buildable == null) continue;
|
||||
|
||||
if (buildable.BuiltPrefab == null)
|
||||
{
|
||||
Debug.LogWarning($"[BuiltPrefabMigrator] '{buildable.name}' has no BuiltPrefab — skipped.", buildable);
|
||||
missingPrefab++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (EnsureNetworked(buildable.BuiltPrefab)) migrated++;
|
||||
else alreadyFine++;
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
RefreshNetworkPrefabRegistry();
|
||||
|
||||
Debug.Log($"[BuiltPrefabMigrator] Done — {migrated} prefab(s) migrated, {alreadyFine} already correct, {missingPrefab} buildable(s) without a BuiltPrefab.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Adds whatever the prefab root is missing and saves it. The NetworkObject goes on first so that
|
||||
/// BuiltStructure's RequireComponent is already satisfied when it is added. Returns true when the
|
||||
/// prefab was actually changed.
|
||||
/// </summary>
|
||||
private static bool EnsureNetworked(GameObject prefab)
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(prefab);
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
|
||||
GameObject root = PrefabUtility.LoadPrefabContents(path);
|
||||
bool changed = false;
|
||||
|
||||
if (root.GetComponent<NetworkObject>() == null)
|
||||
{
|
||||
root.AddComponent<NetworkObject>();
|
||||
changed = true;
|
||||
}
|
||||
if (root.GetComponent<BuiltStructure>() == null)
|
||||
{
|
||||
root.AddComponent<BuiltStructure>();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
PrefabUtility.SaveAsPrefabAsset(root, path);
|
||||
Debug.Log($"[BuiltPrefabMigrator] Migrated '{path}'.", AssetDatabase.LoadAssetAtPath<GameObject>(path));
|
||||
}
|
||||
|
||||
PrefabUtility.UnloadPrefabContents(root);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces FishNet to rescan DefaultPrefabObjects so the migrated prefabs are spawnable. Invoked
|
||||
/// through the menu item because the generator API is internal to the FishNet assembly.
|
||||
/// </summary>
|
||||
private static void RefreshNetworkPrefabRegistry()
|
||||
{
|
||||
const string menu = "Tools/Fish-Networking/Utility/Refresh Default Prefabs";
|
||||
if (!EditorApplication.ExecuteMenuItem(menu))
|
||||
Debug.LogWarning($"[BuiltPrefabMigrator] Could not run '{menu}' — run it by hand so the built prefabs are registered as spawnable.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1d18a05426439d47937c24568677913
|
||||
@@ -59,11 +59,6 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ash-search {
|
||||
width: 180px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ── Body split ──────────────────────────────────────────── */
|
||||
.ash-body {
|
||||
flex-direction: row;
|
||||
@@ -81,6 +76,57 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* ── Left-pane filter header (search + type) ─────────────── */
|
||||
.ash-listheader {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgb(18, 19, 22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ash-listsearch {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background-color: rgb(46, 47, 52);
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.ash-listsearch .unity-toolbar-search-field__search-button {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.ash-listsearch .unity-base-text-field__input {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.ash-typefilter {
|
||||
margin-top: 6px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ash-typefilter .unity-base-field__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ash-typefilter .unity-base-popup-field__input {
|
||||
border-radius: 4px;
|
||||
background-color: rgb(46, 47, 52);
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.ash-right {
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
@@ -277,6 +323,15 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Small italic note clarifying a field inside a card */
|
||||
.ash-card__hint {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: rgb(130, 132, 140);
|
||||
-unity-font-style: italic;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ash-description .unity-text-field__input {
|
||||
min-height: 48px;
|
||||
white-space: normal;
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace Ashwild.EditorTools
|
||||
#region Constants
|
||||
|
||||
private const string UssPath = "Assets/GAME/Script/Editor/Database/AshwildDatabase.uss";
|
||||
private const string AllTypesLabel = "All Types";
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -38,6 +39,7 @@ namespace Ashwild.EditorTools
|
||||
|
||||
private Tab activeTab = Tab.Items;
|
||||
private string searchFilter = string.Empty;
|
||||
private string itemTypeFilter = AllTypesLabel;
|
||||
|
||||
private readonly List<Object> sourceItems = new List<Object>();
|
||||
private Object selectedAsset;
|
||||
@@ -51,6 +53,7 @@ namespace Ashwild.EditorTools
|
||||
private Button itemsTab;
|
||||
private Button recipesTab;
|
||||
private Button buildablesTab;
|
||||
private PopupField<string> typeFilterField;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -106,7 +109,8 @@ namespace Ashwild.EditorTools
|
||||
#region Toolbar
|
||||
|
||||
/// <summary>
|
||||
/// Builds the top toolbar: the two family tabs, a search field, and the database rebuild action.
|
||||
/// Builds the top toolbar: the three family tabs and the database rebuild action. The search
|
||||
/// field and type filter live in the left-pane header, directly above the list they filter.
|
||||
/// </summary>
|
||||
private VisualElement BuildToolbar()
|
||||
{
|
||||
@@ -124,15 +128,6 @@ namespace Ashwild.EditorTools
|
||||
spacer.AddToClassList("ash-toolbar-spacer");
|
||||
toolbar.Add(spacer);
|
||||
|
||||
ToolbarSearchField search = new ToolbarSearchField();
|
||||
search.AddToClassList("ash-search");
|
||||
search.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
searchFilter = evt.newValue ?? string.Empty;
|
||||
RefreshList();
|
||||
});
|
||||
toolbar.Add(search);
|
||||
|
||||
Button rebuild = new Button(ItemDatabaseBuilder.Rebuild) { text = "Rebuild DB" };
|
||||
rebuild.AddToClassList("ash-btn");
|
||||
rebuild.tooltip = "Re-scan every ItemData and rewrite the network ItemDatabase.";
|
||||
@@ -175,6 +170,8 @@ namespace Ashwild.EditorTools
|
||||
recipesTab.EnableInClassList("ash-tab--active", tab == Tab.Recipes);
|
||||
buildablesTab.EnableInClassList("ash-tab--active", tab == Tab.Buildables);
|
||||
|
||||
UpdateTypeFilterVisibility();
|
||||
|
||||
selectedAsset = null;
|
||||
RefreshList();
|
||||
ShowSelection();
|
||||
@@ -185,13 +182,16 @@ namespace Ashwild.EditorTools
|
||||
#region Left Pane
|
||||
|
||||
/// <summary>
|
||||
/// Builds the left pane: a styled list of the active family plus a footer "New" button.
|
||||
/// Builds the left pane: a filter header (search + item-type filter), a styled list of the
|
||||
/// active family, and a footer "New" button.
|
||||
/// </summary>
|
||||
private VisualElement BuildLeftPane()
|
||||
{
|
||||
VisualElement left = new VisualElement();
|
||||
left.AddToClassList("ash-left");
|
||||
|
||||
left.Add(BuildListHeader());
|
||||
|
||||
listView = new ListView(sourceItems)
|
||||
{
|
||||
fixedItemHeight = 48,
|
||||
@@ -214,6 +214,58 @@ namespace Ashwild.EditorTools
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the header sitting above the list: a search field that filters by name across every
|
||||
/// family, and an item-type dropdown that narrows the item list to a single type. The type
|
||||
/// filter is only meaningful for items, so it is hidden on the Recipes and Buildables tabs.
|
||||
/// </summary>
|
||||
private VisualElement BuildListHeader()
|
||||
{
|
||||
VisualElement header = new VisualElement();
|
||||
header.AddToClassList("ash-listheader");
|
||||
|
||||
ToolbarSearchField search = new ToolbarSearchField();
|
||||
search.AddToClassList("ash-listsearch");
|
||||
search.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
searchFilter = evt.newValue ?? string.Empty;
|
||||
RefreshList();
|
||||
});
|
||||
header.Add(search);
|
||||
|
||||
List<string> typeChoices = new List<string> { AllTypesLabel };
|
||||
typeChoices.AddRange(System.Enum.GetNames(typeof(ItemType)));
|
||||
|
||||
typeFilterField = new PopupField<string>(typeChoices, 0);
|
||||
typeFilterField.AddToClassList("ash-typefilter");
|
||||
typeFilterField.tooltip = "Show only items of the selected type.";
|
||||
typeFilterField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
itemTypeFilter = evt.newValue ?? AllTypesLabel;
|
||||
RefreshList();
|
||||
});
|
||||
header.Add(typeFilterField);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the item-type dropdown only on the Items tab and resets it to "all" when leaving, so a
|
||||
/// stale type filter never silently narrows a list it cannot apply to.
|
||||
/// </summary>
|
||||
private void UpdateTypeFilterVisibility()
|
||||
{
|
||||
if (typeFilterField == null) return;
|
||||
|
||||
bool showForItems = activeTab == Tab.Items;
|
||||
typeFilterField.style.display = showForItems ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (!showForItems)
|
||||
{
|
||||
itemTypeFilter = AllTypesLabel;
|
||||
typeFilterField.SetValueWithoutNotify(AllTypesLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the reusable visual for a single list row (icon, name, type tag).
|
||||
/// </summary>
|
||||
@@ -251,6 +303,7 @@ namespace Ashwild.EditorTools
|
||||
{
|
||||
ItemData item => AshwildUI.TypeColor(item.ItemType),
|
||||
BuildableData => AshwildUI.BuildableColor,
|
||||
CraftingRecipe recipe when recipe.RequiredStation != CraftingStationType.None => AshwildUI.StationColor,
|
||||
_ => new Color(0.5f, 0.52f, 0.58f)
|
||||
};
|
||||
|
||||
@@ -421,6 +474,9 @@ namespace Ashwild.EditorTools
|
||||
if (!string.IsNullOrEmpty(searchFilter)
|
||||
&& AssetDisplayName(asset).IndexOf(searchFilter, System.StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
if (activeTab == Tab.Items && itemTypeFilter != AllTypesLabel
|
||||
&& asset is ItemData item && item.ItemType.ToString() != itemTypeFilter)
|
||||
continue;
|
||||
sourceItems.Add(asset);
|
||||
}
|
||||
|
||||
@@ -465,12 +521,15 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The short type tag shown on the right of a list row (item type, or "Recipe").
|
||||
/// The short type tag shown on the right of a list row. A recipe reports the station it is
|
||||
/// crafted on rather than a flat "Recipe", so scanning the list tells which recipes are bench-
|
||||
/// locked; hand recipes (station None) keep reading "Recipe".
|
||||
/// </summary>
|
||||
private static string AssetTag(Object asset)
|
||||
{
|
||||
if (asset is ItemData item) return item.ItemType.ToString();
|
||||
if (asset is CraftingRecipe) return "Recipe";
|
||||
if (asset is CraftingRecipe recipe)
|
||||
return recipe.RequiredStation == CraftingStationType.None ? "Recipe" : recipe.RequiredStation.ToString();
|
||||
if (asset is BuildableData) return "Buildable";
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ namespace Ashwild.EditorTools
|
||||
/// </summary>
|
||||
public static readonly Color BuildableColor = new Color(0.38f, 0.64f, 0.86f);
|
||||
|
||||
/// <summary>
|
||||
/// The accent colour marking a recipe that is locked to a workstation, so the bench-only ones
|
||||
/// stand out from the plain (hand-craftable) rows at a glance.
|
||||
/// </summary>
|
||||
public static readonly Color StationColor = new Color(0.76f, 0.60f, 0.90f);
|
||||
|
||||
/// <summary>
|
||||
/// The accent colour that identifies an item type across the list badges, hero and cards.
|
||||
/// </summary>
|
||||
|
||||
@@ -10,11 +10,13 @@ namespace Ashwild.EditorTools
|
||||
/// <summary>
|
||||
/// Editor-only factory that creates ItemData assets and, on demand, builds the world pickup
|
||||
/// prefab (Pickable + collider on the Pickable layer) and the in-hand prefab (ToolBehaviour +
|
||||
/// HeldItemOffset on the Tools layer) by wrapping a chosen source object — a model (FBX) or an
|
||||
/// existing prefab. The source is nested as a child so the designer's authored model stays intact
|
||||
/// and editable, while the generated root carries the gameplay components and a collider auto-fit
|
||||
/// to the source's renderers. Every reference is wired back onto the ItemData. Follows §3 of the
|
||||
/// project rules: world pickups are plain WorldObjects (registry-synced), never NetworkObjects.
|
||||
/// HeldItemOffset on the Tools layer) as a Prefab Variant of a chosen source prefab. The designer
|
||||
/// authors one base prefab with its meshes and materials set up (the raw FBX comes in untextured),
|
||||
/// hands it to the generator, and each generated prefab becomes a variant that carries the gameplay
|
||||
/// components as overrides on its own root — so re-texturing the base propagates to both variants.
|
||||
/// A non-prefab source falls back to a plain duplicate. Every reference is wired back onto the
|
||||
/// ItemData. Follows §3 of the project rules: world pickups are plain WorldObjects (registry-synced),
|
||||
/// never NetworkObjects.
|
||||
/// </summary>
|
||||
public static class ItemAssetFactory
|
||||
{
|
||||
@@ -24,6 +26,9 @@ namespace Ashwild.EditorTools
|
||||
private const string WorldPrefabFolder = "Assets/GAME/Prefabs/Pickable";
|
||||
private const string HandPrefabFolder = "Assets/GAME/Prefabs/Tools";
|
||||
|
||||
private const string WorldPrefabSuffix = "_Pickable";
|
||||
private const string HandPrefabSuffix = "_Tools";
|
||||
|
||||
private const string PickableLayerName = "Pickable";
|
||||
private const string ToolsLayerName = "Tools";
|
||||
private const string HarvestableLayerName = "Harvestable";
|
||||
@@ -65,19 +70,20 @@ namespace Ashwild.EditorTools
|
||||
#region World Prefab
|
||||
|
||||
/// <summary>
|
||||
/// Builds the world pickup prefab for an item by wrapping the source object: a root on the
|
||||
/// Pickable layer carries the source as a child, an auto-fitted non-trigger BoxCollider (so the
|
||||
/// interactor ray — which reads the Pickable off the hit collider's own GameObject — has a
|
||||
/// target on the root), and the Pickable component wired to this item. The id stays -1; scene
|
||||
/// instances get a baked id later via Tools ▸ Ashwild ▸ Assign World Object IDs. The finished
|
||||
/// prefab is assigned back onto ItemData.worldPrefab. Returns the saved prefab, or null on error.
|
||||
/// Builds the world pickup prefab for an item as a variant of the source prefab: the variant root
|
||||
/// sits on the Pickable layer, gains an auto-fitted non-trigger BoxCollider (so the interactor ray
|
||||
/// — which reads the Pickable off the hit collider's own GameObject — has a target on the root),
|
||||
/// and a Pickable wired to this item. The id stays -1; scene instances get a baked id later via
|
||||
/// Tools ▸ Ashwild ▸ Setup World Object IDs. The finished prefab is assigned back onto
|
||||
/// ItemData.worldPrefab. Returns the saved prefab, or null on error.
|
||||
/// </summary>
|
||||
public static GameObject GenerateWorldPrefab(ItemData item, GameObject source)
|
||||
{
|
||||
if (!ValidateInputs(item, source, "world")) return null;
|
||||
if (!EnsureFolder(WorldPrefabFolder)) return null;
|
||||
|
||||
GameObject root = BuildRoot(item, source, ResolveLayer(PickableLayerName));
|
||||
string prefabName = item.name + WorldPrefabSuffix;
|
||||
GameObject root = InstantiateSourceRoot(source, ResolveLayer(PickableLayerName), prefabName);
|
||||
FitBoxCollider(root);
|
||||
|
||||
Pickable pickable = root.AddComponent<Pickable>();
|
||||
@@ -85,7 +91,7 @@ namespace Ashwild.EditorTools
|
||||
so.FindProperty("itemData").objectReferenceValue = item;
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
|
||||
return SaveAndAssign(root, WorldPrefabFolder, item, "worldPrefab");
|
||||
return SaveAndAssign(root, WorldPrefabFolder, item, "worldPrefab", prefabName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -93,18 +99,19 @@ namespace Ashwild.EditorTools
|
||||
#region Hand Prefab
|
||||
|
||||
/// <summary>
|
||||
/// Builds the in-hand prefab for a tool/weapon by wrapping the source object: a root on the
|
||||
/// Tools layer carries the source as a child, a ToolBehaviour (harvest ray masked to the
|
||||
/// Harvestable layer) and a HeldItemOffset so it can be posed in the holder. No collider —
|
||||
/// held items carry no physics. The prefab is assigned back onto ItemData.handPrefab.
|
||||
/// Returns the prefab, or null on error.
|
||||
/// Builds the in-hand prefab for a tool/weapon as a variant of the source prefab: the variant
|
||||
/// root sits on the Tools layer and gains a ToolBehaviour (harvest ray masked to the Harvestable
|
||||
/// layer) and a HeldItemOffset so it can be posed in the holder. No collider — held items carry
|
||||
/// no physics. The prefab is assigned back onto ItemData.handPrefab. Returns the prefab, or null
|
||||
/// on error.
|
||||
/// </summary>
|
||||
public static GameObject GenerateHandPrefab(ItemData item, GameObject source)
|
||||
{
|
||||
if (!ValidateInputs(item, source, "hand")) return null;
|
||||
if (!EnsureFolder(HandPrefabFolder)) return null;
|
||||
|
||||
GameObject root = BuildRoot(item, source, ResolveLayer(ToolsLayerName));
|
||||
string prefabName = item.name + HandPrefabSuffix;
|
||||
GameObject root = InstantiateSourceRoot(source, ResolveLayer(ToolsLayerName), prefabName);
|
||||
|
||||
ToolBehaviour tool = root.AddComponent<ToolBehaviour>();
|
||||
SerializedObject so = new SerializedObject(tool);
|
||||
@@ -113,7 +120,7 @@ namespace Ashwild.EditorTools
|
||||
|
||||
root.AddComponent<HeldItemOffset>();
|
||||
|
||||
return SaveAndAssign(root, HandPrefabFolder, item, "handPrefab");
|
||||
return SaveAndAssign(root, HandPrefabFolder, item, "handPrefab", prefabName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -139,18 +146,20 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the generated root named after the item, on the given layer, with the source object
|
||||
/// (model or prefab) nested as a child at the origin so its authored transform is preserved as
|
||||
/// a prefab link rather than flattened.
|
||||
/// Instantiates the source prefab itself as the generated root — keeping the prefab connection so
|
||||
/// SaveAndAssign turns it into a variant — then applies the target name and layer as variant
|
||||
/// overrides. A non-prefab source falls back to a plain (unlinked) copy, which SaveAndAssign then
|
||||
/// saves as a regular prefab rather than a variant.
|
||||
/// </summary>
|
||||
private static GameObject BuildRoot(ItemData item, GameObject source, int layer)
|
||||
private static GameObject InstantiateSourceRoot(GameObject source, int layer, string rootName)
|
||||
{
|
||||
GameObject root = new GameObject(item.name) { layer = layer };
|
||||
GameObject root = PrefabUtility.InstantiatePrefab(source) as GameObject;
|
||||
if (root == null) root = Object.Instantiate(source);
|
||||
|
||||
GameObject visual = (GameObject)PrefabUtility.InstantiatePrefab(source);
|
||||
if (visual == null) visual = Object.Instantiate(source);
|
||||
visual.transform.SetParent(root.transform, false);
|
||||
visual.transform.localPosition = Vector3.zero;
|
||||
root.name = rootName;
|
||||
root.layer = layer;
|
||||
root.transform.position = Vector3.zero;
|
||||
root.transform.rotation = Quaternion.identity;
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -179,12 +188,13 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a built GameObject as a prefab under a unique path, destroys the scene instance,
|
||||
/// wires the saved prefab onto the given ItemData field, and refreshes the asset database.
|
||||
/// Saves a built GameObject as a prefab under a unique path (named with the type-specific suffix
|
||||
/// so pickables and hand tools stay distinguishable), destroys the scene instance, wires the
|
||||
/// saved prefab onto the given ItemData field, and refreshes the asset database.
|
||||
/// </summary>
|
||||
private static GameObject SaveAndAssign(GameObject root, string folder, ItemData item, string itemField)
|
||||
private static GameObject SaveAndAssign(GameObject root, string folder, ItemData item, string itemField, string prefabName)
|
||||
{
|
||||
string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{item.name}.prefab");
|
||||
string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{prefabName}.prefab");
|
||||
GameObject prefab = PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
|
||||
Object.DestroyImmediate(root);
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
@@ -37,8 +40,6 @@ namespace Ashwild.EditorTools
|
||||
private VisualElement maxStackRow;
|
||||
private VisualElement fuelSecondsRow;
|
||||
private VisualElement generationCard;
|
||||
private Button worldGenButton;
|
||||
private Button handGenButton;
|
||||
private ObjectField worldField;
|
||||
private ObjectField handField;
|
||||
private IMGUIContainer iconPickerProxy;
|
||||
@@ -70,6 +71,7 @@ namespace Ashwild.EditorTools
|
||||
Root.Add(BuildCookingCard());
|
||||
Root.Add(BuildFuelCard());
|
||||
Root.Add(BuildPrefabsCard());
|
||||
Root.Add(BuildAnimationCard());
|
||||
Root.Add(BuildGenerationCard());
|
||||
|
||||
currentType = item.ItemType;
|
||||
@@ -114,6 +116,7 @@ namespace Ashwild.EditorTools
|
||||
nameField.AddToClassList("ash-hero__name");
|
||||
nameField.BindProperty(so.FindProperty("itemName"));
|
||||
nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
|
||||
nameField.RegisterCallback<FocusOutEvent>(_ => RenameAssetToItemName());
|
||||
info.Add(AshwildUI.EditableNameRow(nameField));
|
||||
|
||||
VisualElement typeRow = new VisualElement();
|
||||
@@ -201,6 +204,50 @@ namespace Ashwild.EditorTools
|
||||
|
||||
#endregion
|
||||
|
||||
#region Asset Naming
|
||||
|
||||
/// <summary>
|
||||
/// Renames the underlying asset file to match the authored item name (spaces → underscores) so
|
||||
/// the ScriptableObject on disk reads like the item it holds instead of the generic "NewItem".
|
||||
/// Runs on focus-out, not per keystroke, so the file is renamed once the name is committed; a
|
||||
/// blank name, an unchanged name, or a rename collision is skipped (the latter logged).
|
||||
/// </summary>
|
||||
private void RenameAssetToItemName()
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(item);
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
string desired = ToAssetFileName(item.ItemName);
|
||||
if (string.IsNullOrEmpty(desired)) return;
|
||||
|
||||
if (string.Equals(Path.GetFileNameWithoutExtension(path), desired, StringComparison.Ordinal)) return;
|
||||
|
||||
string error = AssetDatabase.RenameAsset(path, desired);
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
Debug.LogError($"[ItemEditorView] Could not rename asset to '{desired}': {error}", item);
|
||||
return;
|
||||
}
|
||||
|
||||
onMetaChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns an authored item name into a valid asset file name: trims, collapses every whitespace
|
||||
/// run to a single underscore, and drops characters the filesystem forbids in a file name.
|
||||
/// </summary>
|
||||
private static string ToAssetFileName(string itemName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemName)) return string.Empty;
|
||||
|
||||
string underscored = Regex.Replace(itemName.Trim(), @"\s+", "_");
|
||||
foreach (char invalid in Path.GetInvalidFileNameChars())
|
||||
underscored = underscored.Replace(invalid.ToString(), string.Empty);
|
||||
return underscored;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cards
|
||||
|
||||
/// <summary>
|
||||
@@ -330,29 +377,47 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generation card: pick a source model/prefab, then build the world pickup (always) and the
|
||||
/// in-hand prefab (tools/weapons only) wrapped around it.
|
||||
/// Animation card: the clip sets this item imposes on each rig while it is held. Both are
|
||||
/// optional — an item that leaves them empty simply keeps the bare-hand animations, which is the
|
||||
/// right answer for every material and most consumables.
|
||||
/// </summary>
|
||||
private VisualElement BuildAnimationCard()
|
||||
{
|
||||
VisualElement card = AshwildUI.Card("Animation");
|
||||
|
||||
ObjectField armsField = new ObjectField("Arms Set") { objectType = typeof(PlayerAnimationSet), allowSceneObjects = false };
|
||||
armsField.BindProperty(so.FindProperty("armsAnimationSet"));
|
||||
card.Add(armsField);
|
||||
|
||||
ObjectField bodyField = new ObjectField("Body Set") { objectType = typeof(PlayerAnimationSet), allowSceneObjects = false };
|
||||
bodyField.BindProperty(so.FindProperty("bodyAnimationSet"));
|
||||
card.Add(bodyField);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generation card: pick a source prefab, then build both the world pickup (always) and the
|
||||
/// in-hand prefab (tools/weapons only) from it with one button, creating only the ones still
|
||||
/// missing.
|
||||
/// </summary>
|
||||
private VisualElement BuildGenerationCard()
|
||||
{
|
||||
generationCard = AshwildUI.Card("Prefab Generation");
|
||||
generationCard.AddToClassList("ash-card--accent");
|
||||
|
||||
ObjectField source = new ObjectField("Source (Prefab / Model)") { objectType = typeof(GameObject), allowSceneObjects = false };
|
||||
ObjectField source = new ObjectField("Source Prefab") { objectType = typeof(GameObject), allowSceneObjects = false };
|
||||
source.tooltip = "The set-up prefab (meshes + materials). Generation saves a Prefab Variant of it with the gameplay components added.";
|
||||
source.RegisterValueChangedCallback(evt => pendingSource = evt.newValue as GameObject);
|
||||
generationCard.Add(source);
|
||||
|
||||
VisualElement buttons = new VisualElement();
|
||||
buttons.AddToClassList("ash-buttons");
|
||||
|
||||
worldGenButton = new Button(GenerateWorld) { text = "Generate World Prefab" };
|
||||
worldGenButton.AddToClassList("ash-btn");
|
||||
worldGenButton.AddToClassList("ash-btn--primary");
|
||||
buttons.Add(worldGenButton);
|
||||
|
||||
handGenButton = new Button(GenerateHand) { text = "Generate Hand Prefab" };
|
||||
handGenButton.AddToClassList("ash-btn");
|
||||
buttons.Add(handGenButton);
|
||||
Button generate = new Button(GeneratePrefabs) { text = "Generate Prefab" };
|
||||
generate.AddToClassList("ash-btn");
|
||||
generate.AddToClassList("ash-btn--primary");
|
||||
buttons.Add(generate);
|
||||
|
||||
generationCard.Add(buttons);
|
||||
return generationCard;
|
||||
@@ -363,21 +428,23 @@ namespace Ashwild.EditorTools
|
||||
#region Actions
|
||||
|
||||
/// <summary>
|
||||
/// Builds the world pickup prefab around the chosen source and re-renders on success.
|
||||
/// Builds every prefab still missing for the item from the chosen source: the world pickup when
|
||||
/// none is set, and the in-hand prefab when the item is a tool/weapon and none is set. Re-renders
|
||||
/// once if anything was generated so the view reflects the new references and hides the card when
|
||||
/// nothing is left to build.
|
||||
/// </summary>
|
||||
private void GenerateWorld()
|
||||
private void GeneratePrefabs()
|
||||
{
|
||||
if (ItemAssetFactory.GenerateWorldPrefab(item, pendingSource) != null)
|
||||
onPrefabGenerated?.Invoke();
|
||||
}
|
||||
bool generated = false;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the in-hand prefab around the chosen source and re-renders on success.
|
||||
/// </summary>
|
||||
private void GenerateHand()
|
||||
{
|
||||
if (ItemAssetFactory.GenerateHandPrefab(item, pendingSource) != null)
|
||||
onPrefabGenerated?.Invoke();
|
||||
if (worldField.value == null)
|
||||
generated |= ItemAssetFactory.GenerateWorldPrefab(item, pendingSource) != null;
|
||||
|
||||
bool isToolLike = currentType == ItemType.Tool || currentType == ItemType.Weapon;
|
||||
if (isToolLike && handField.value == null)
|
||||
generated |= ItemAssetFactory.GenerateHandPrefab(item, pendingSource) != null;
|
||||
|
||||
if (generated) onPrefabGenerated?.Invoke();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -398,21 +465,19 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides a generation button once its prefab already exists (and the hand button entirely for
|
||||
/// non-tools), and collapses the whole generation card when nothing is left to generate — so a
|
||||
/// fully wired item shows no redundant tooling. Re-evaluated when the type or a prefab field changes.
|
||||
/// Collapses the whole generation card once every prefab the item needs already exists — the
|
||||
/// world pickup for any item, plus the hand prefab for tools/weapons — so a fully wired item
|
||||
/// shows no redundant tooling. Re-evaluated when the type or a prefab field changes.
|
||||
/// </summary>
|
||||
private void UpdateGenerationVisibility()
|
||||
{
|
||||
if (worldField == null || handField == null || generationCard == null) return;
|
||||
|
||||
bool isToolLike = currentType == ItemType.Tool || currentType == ItemType.Weapon;
|
||||
bool showWorld = worldField.value == null;
|
||||
bool showHand = isToolLike && handField.value == null;
|
||||
bool needWorld = worldField.value == null;
|
||||
bool needHand = isToolLike && handField.value == null;
|
||||
|
||||
SetRowVisible(worldGenButton, showWorld);
|
||||
SetRowVisible(handGenButton, showHand);
|
||||
SetRowVisible(generationCard, showWorld || showHand);
|
||||
SetRowVisible(generationCard, needWorld || needHand);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,8 +13,8 @@ namespace Ashwild.EditorTools
|
||||
/// editor IS the recipe equation: a row of interactive ingredient cards ("+"-joined) leading to
|
||||
/// "=" and the result card. Each card's icon opens an item picker to set/change the item, a
|
||||
/// −/+ stepper adjusts its quantity, and ingredient cards carry a remove button; a dashed "+" tile
|
||||
/// appends a new ingredient. A compact name field sits above it. All edits write straight to the
|
||||
/// asset and repaint the strip.
|
||||
/// appends a new ingredient. A compact name field sits above it, and a station card below states
|
||||
/// where the recipe can be crafted. All edits write straight to the asset and repaint the strip.
|
||||
/// </summary>
|
||||
public class RecipeEditorView
|
||||
{
|
||||
@@ -51,6 +51,7 @@ namespace Ashwild.EditorTools
|
||||
Root = new VisualElement();
|
||||
Root.Add(BuildNameField());
|
||||
Root.Add(BuildEquation());
|
||||
Root.Add(BuildStationCard());
|
||||
Root.Add(BuildPickerProxy());
|
||||
|
||||
RefreshEquation();
|
||||
@@ -76,6 +77,28 @@ namespace Ashwild.EditorTools
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the card stating where the recipe can be crafted. The hint spells out the meaning of
|
||||
/// None, which reads as "no station" but actually means "craftable everywhere — bare hands and
|
||||
/// every station alike"; any other value restricts the recipe to that station only.
|
||||
/// Changing it refreshes the list row, whose tag shows the station.
|
||||
/// </summary>
|
||||
private VisualElement BuildStationCard()
|
||||
{
|
||||
VisualElement card = AshwildUI.Card("Station");
|
||||
|
||||
EnumField stationField = new EnumField("Crafted On", recipe.RequiredStation);
|
||||
stationField.BindProperty(so.FindProperty("requiredStation"));
|
||||
stationField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
|
||||
card.Add(stationField);
|
||||
|
||||
Label hint = new Label("None = craftable by hand and on every station.");
|
||||
hint.AddToClassList("ash-card__hint");
|
||||
card.Add(hint);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Equation
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor tool that builds the master Animator Controller the player rigs run on. The controller is
|
||||
/// authored once and never edited per weapon: every state points at an empty placeholder clip whose
|
||||
/// *name* is the slot key, and PlayerAnimationSetBinder swaps those clips at runtime for whatever the
|
||||
/// held item authors.
|
||||
///
|
||||
/// Generating it rather than clicking it together is not a convenience — the contract between the
|
||||
/// controller and PlayerAnimationSet is a set of exact strings (parameter names, clip names), and a
|
||||
/// single typo there fails silently: the parameter simply never moves and the rig stands still with
|
||||
/// no error to explain why. Encoding the contract in code makes it impossible to get wrong, and lets
|
||||
/// the controller be rebuilt from scratch after any manual experiment.
|
||||
///
|
||||
/// Placeholder clips are deliberately empty and stored as their own assets rather than reusing a real
|
||||
/// animation, so the master controller depends on no FBX and the slot list stays explicit. A slot no
|
||||
/// set ever fills therefore plays nothing, which reads as an obvious gap instead of a wrong pose.
|
||||
///
|
||||
/// Safe to re-run: it rewrites the controller in place, keeping the asset's GUID so every Animator
|
||||
/// already pointing at it stays wired.
|
||||
/// </summary>
|
||||
public static class PlayerAnimatorControllerBuilder
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const string AnimationsFolder = "Assets/GAME/Animations/Arms";
|
||||
private const string SlotsFolder = AnimationsFolder + "/Slots";
|
||||
private const string ArmsControllerPath = AnimationsFolder + "/PlayerArms.controller";
|
||||
|
||||
/// <summary>
|
||||
/// Blend thresholds for the ground tree. They are the gait levels PlayerAnimatorDriver reports —
|
||||
/// 0 idle, 1 walk, 2 run — not speeds in metres per second, so retuning how fast the player moves
|
||||
/// never desynchronises the animation.
|
||||
///
|
||||
/// RunClipSpeed exists only while walk and run share one authored clip: with the same motion in
|
||||
/// both slots, playing the run entry faster is the only thing that distinguishes sprinting from
|
||||
/// walking. Drop it back to 1 as soon as a real run animation fills the Run slot.
|
||||
/// </summary>
|
||||
private const float WalkThreshold = 1f;
|
||||
private const float RunThreshold = 2f;
|
||||
private const float RunClipSpeed = 1.5f;
|
||||
|
||||
private const string SpeedParam = "Speed";
|
||||
private const string GroundedParam = "Grounded";
|
||||
private const string CrouchingParam = "Crouching";
|
||||
private const string SprintingParam = "Sprinting";
|
||||
private const string AttackIndexParam = "AttackIndex";
|
||||
private const string JumpParam = "Jump";
|
||||
private const string LandParam = "Land";
|
||||
private const string AttackParam = "Attack";
|
||||
private const string GrabParam = "Grab";
|
||||
private const string EquipParam = "Equip";
|
||||
private const string UnequipParam = "Unequip";
|
||||
|
||||
/// <summary>
|
||||
/// Every clip slot the controller declares, in the order a reader should meet them. These strings
|
||||
/// are the keys PlayerAnimationSet is queried with — they must match its slot constants exactly.
|
||||
/// </summary>
|
||||
private static readonly string[] Slots =
|
||||
{
|
||||
PlayerAnimationSet.SlotIdle,
|
||||
PlayerAnimationSet.SlotWalk,
|
||||
PlayerAnimationSet.SlotRun,
|
||||
PlayerAnimationSet.SlotJumpStart,
|
||||
PlayerAnimationSet.SlotJumpLoop,
|
||||
PlayerAnimationSet.SlotJumpLand,
|
||||
PlayerAnimationSet.SlotGrab,
|
||||
PlayerAnimationSet.SlotEquip,
|
||||
PlayerAnimationSet.SlotUnequip,
|
||||
PlayerAnimationSet.SlotAttackPrefix + "1",
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Menu
|
||||
|
||||
/// <summary>
|
||||
/// Builds (or rebuilds) the first-person arms controller and the placeholder clips it references.
|
||||
/// </summary>
|
||||
[MenuItem("Tools/Ashwild/Build Player Arms Controller")]
|
||||
public static void BuildArmsController()
|
||||
{
|
||||
EnsureFolders();
|
||||
|
||||
Dictionary<string, AnimationClip> placeholders = new Dictionary<string, AnimationClip>();
|
||||
foreach (string slot in Slots)
|
||||
placeholders[slot] = GetOrCreatePlaceholder(slot);
|
||||
|
||||
AnimatorController controller = GetOrCreateController(ArmsControllerPath);
|
||||
ClearController(controller);
|
||||
AddParameters(controller);
|
||||
BuildLocomotionLayer(controller, placeholders);
|
||||
BuildActionLayer(controller, placeholders);
|
||||
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($"[PlayerAnimatorControllerBuilder] Built {ArmsControllerPath} with {Slots.Length} clip slots. " +
|
||||
"Assign it to the arms Animator, then point PlayerAnimationSetBinder at your Arms_NoItem set.",
|
||||
controller);
|
||||
|
||||
Selection.activeObject = controller;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Layers
|
||||
|
||||
/// <summary>
|
||||
/// Base layer: the movement the player is always doing. A 1D blend tree covers ground movement so
|
||||
/// idle and run ease into each other instead of snapping, and the jump chain is a straight line
|
||||
/// (start → loop → land) driven by the Grounded flag rather than by timers, so a fall the player
|
||||
/// never jumped into still enters the loop from Any State.
|
||||
/// </summary>
|
||||
private static void BuildLocomotionLayer(AnimatorController controller, Dictionary<string, AnimationClip> clips)
|
||||
{
|
||||
AnimatorControllerLayer[] layers = controller.layers;
|
||||
layers[0].name = "Locomotion";
|
||||
controller.layers = layers;
|
||||
|
||||
AnimatorStateMachine machine = controller.layers[0].stateMachine;
|
||||
|
||||
BlendTree tree;
|
||||
AnimatorState locomotion = controller.CreateBlendTreeInController("Locomotion", out tree, 0);
|
||||
tree.blendType = BlendTreeType.Simple1D;
|
||||
tree.blendParameter = SpeedParam;
|
||||
tree.useAutomaticThresholds = false;
|
||||
tree.AddChild(clips[PlayerAnimationSet.SlotIdle], 0f);
|
||||
tree.AddChild(clips[PlayerAnimationSet.SlotWalk], WalkThreshold);
|
||||
tree.AddChild(clips[PlayerAnimationSet.SlotRun], RunThreshold);
|
||||
SetChildSpeed(tree, 2, RunClipSpeed);
|
||||
|
||||
AnimatorState jumpStart = machine.AddState(PlayerAnimationSet.SlotJumpStart);
|
||||
jumpStart.motion = clips[PlayerAnimationSet.SlotJumpStart];
|
||||
|
||||
AnimatorState jumpLoop = machine.AddState(PlayerAnimationSet.SlotJumpLoop);
|
||||
jumpLoop.motion = clips[PlayerAnimationSet.SlotJumpLoop];
|
||||
|
||||
AnimatorState jumpLand = machine.AddState(PlayerAnimationSet.SlotJumpLand);
|
||||
jumpLand.motion = clips[PlayerAnimationSet.SlotJumpLand];
|
||||
|
||||
machine.defaultState = locomotion;
|
||||
|
||||
AnimatorStateTransition toJump = locomotion.AddTransition(jumpStart);
|
||||
toJump.hasExitTime = false;
|
||||
toJump.duration = 0.05f;
|
||||
toJump.AddCondition(AnimatorConditionMode.If, 0f, JumpParam);
|
||||
|
||||
AnimatorStateTransition startToLoop = jumpStart.AddTransition(jumpLoop);
|
||||
startToLoop.hasExitTime = true;
|
||||
startToLoop.exitTime = 0.8f;
|
||||
startToLoop.duration = 0.1f;
|
||||
|
||||
AnimatorStateTransition anyToLoop = machine.AddAnyStateTransition(jumpLoop);
|
||||
anyToLoop.hasExitTime = false;
|
||||
anyToLoop.duration = 0.15f;
|
||||
anyToLoop.canTransitionToSelf = false;
|
||||
anyToLoop.AddCondition(AnimatorConditionMode.IfNot, 0f, GroundedParam);
|
||||
|
||||
AnimatorStateTransition loopToLand = jumpLoop.AddTransition(jumpLand);
|
||||
loopToLand.hasExitTime = false;
|
||||
loopToLand.duration = 0.1f;
|
||||
loopToLand.AddCondition(AnimatorConditionMode.If, 0f, GroundedParam);
|
||||
|
||||
AnimatorStateTransition landToLocomotion = jumpLand.AddTransition(locomotion);
|
||||
landToLocomotion.hasExitTime = true;
|
||||
landToLocomotion.exitTime = 0.7f;
|
||||
landToLocomotion.duration = 0.15f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action layer: the one-shots that play *over* whatever the legs are doing. It sits on an empty
|
||||
/// default state at full weight, so it contributes nothing until an action fires and the player
|
||||
/// keeps running normally underneath. Every action returns to that empty state on exit time,
|
||||
/// which is what lets a swing interrupt itself cleanly on the next click.
|
||||
/// </summary>
|
||||
private static void BuildActionLayer(AnimatorController controller, Dictionary<string, AnimationClip> clips)
|
||||
{
|
||||
controller.AddLayer("Action");
|
||||
AnimatorControllerLayer[] layers = controller.layers;
|
||||
AnimatorControllerLayer action = layers[1];
|
||||
action.defaultWeight = 1f;
|
||||
action.blendingMode = AnimatorLayerBlendingMode.Override;
|
||||
controller.layers = layers;
|
||||
|
||||
AnimatorStateMachine machine = action.stateMachine;
|
||||
AnimatorState none = machine.AddState("None");
|
||||
machine.defaultState = none;
|
||||
|
||||
AddOneShot(machine, none, PlayerAnimationSet.SlotGrab, clips, GrabParam);
|
||||
AddOneShot(machine, none, PlayerAnimationSet.SlotEquip, clips, EquipParam);
|
||||
AddOneShot(machine, none, PlayerAnimationSet.SlotUnequip, clips, UnequipParam);
|
||||
AddOneShot(machine, none, PlayerAnimationSet.SlotAttackPrefix + "1", clips, AttackParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires one action: entered from Any State on its trigger so it can fire at any moment (and
|
||||
/// re-fire while already playing, which a combo needs), and released back to the empty state on
|
||||
/// exit time so the layer stops contributing as soon as the action is over.
|
||||
/// </summary>
|
||||
private static void AddOneShot(AnimatorStateMachine machine, AnimatorState none, string slot,
|
||||
Dictionary<string, AnimationClip> clips, string trigger)
|
||||
{
|
||||
AnimatorState state = machine.AddState(slot);
|
||||
state.motion = clips[slot];
|
||||
|
||||
AnimatorStateTransition enter = machine.AddAnyStateTransition(state);
|
||||
enter.hasExitTime = false;
|
||||
enter.duration = 0.05f;
|
||||
enter.canTransitionToSelf = true;
|
||||
enter.AddCondition(AnimatorConditionMode.If, 0f, trigger);
|
||||
|
||||
AnimatorStateTransition exit = state.AddTransition(none);
|
||||
exit.hasExitTime = true;
|
||||
exit.exitTime = 0.9f;
|
||||
exit.duration = 0.1f;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Sets one blend-tree child's playback rate. The children array must be reassigned wholesale
|
||||
/// because BlendTree.children hands back a copy — mutating the returned struct in place silently
|
||||
/// does nothing.
|
||||
/// </summary>
|
||||
private static void SetChildSpeed(BlendTree tree, int index, float speed)
|
||||
{
|
||||
ChildMotion[] children = tree.children;
|
||||
if (index < 0 || index >= children.Length) return;
|
||||
children[index].timeScale = speed;
|
||||
tree.children = children;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares every parameter PlayerAnimatorDriver writes. Names are the ones the driver defaults
|
||||
/// to, so a freshly added driver works with no inspector edits.
|
||||
/// </summary>
|
||||
private static void AddParameters(AnimatorController controller)
|
||||
{
|
||||
controller.AddParameter(SpeedParam, AnimatorControllerParameterType.Float);
|
||||
controller.AddParameter(GroundedParam, AnimatorControllerParameterType.Bool);
|
||||
controller.AddParameter(CrouchingParam, AnimatorControllerParameterType.Bool);
|
||||
controller.AddParameter(SprintingParam, AnimatorControllerParameterType.Bool);
|
||||
controller.AddParameter(AttackIndexParam, AnimatorControllerParameterType.Int);
|
||||
controller.AddParameter(JumpParam, AnimatorControllerParameterType.Trigger);
|
||||
controller.AddParameter(LandParam, AnimatorControllerParameterType.Trigger);
|
||||
controller.AddParameter(AttackParam, AnimatorControllerParameterType.Trigger);
|
||||
controller.AddParameter(GrabParam, AnimatorControllerParameterType.Trigger);
|
||||
controller.AddParameter(EquipParam, AnimatorControllerParameterType.Trigger);
|
||||
controller.AddParameter(UnequipParam, AnimatorControllerParameterType.Trigger);
|
||||
|
||||
SetDefaultBool(controller, GroundedParam, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a bool's authored default so the rig starts in a sane pose on the very first frame,
|
||||
/// before the driver has pushed anything — a player spawning "not grounded" would otherwise flash
|
||||
/// the fall loop.
|
||||
/// </summary>
|
||||
private static void SetDefaultBool(AnimatorController controller, string paramName, bool value)
|
||||
{
|
||||
AnimatorControllerParameter[] parameters = controller.parameters;
|
||||
for (int i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
if (parameters[i].name != paramName) continue;
|
||||
parameters[i].defaultBool = value;
|
||||
break;
|
||||
}
|
||||
controller.parameters = parameters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties an existing controller so a rebuild never stacks duplicate states or parameters on top
|
||||
/// of the previous run. The asset itself is kept so its GUID — and every Animator reference to
|
||||
/// it — survives.
|
||||
/// </summary>
|
||||
private static void ClearController(AnimatorController controller)
|
||||
{
|
||||
for (int i = controller.layers.Length - 1; i > 0; i--)
|
||||
controller.RemoveLayer(i);
|
||||
|
||||
while (controller.parameters.Length > 0)
|
||||
controller.RemoveParameter(0);
|
||||
|
||||
AnimatorStateMachine machine = controller.layers[0].stateMachine;
|
||||
|
||||
for (int i = machine.states.Length - 1; i >= 0; i--)
|
||||
machine.RemoveState(machine.states[i].state);
|
||||
|
||||
for (int i = machine.anyStateTransitions.Length - 1; i >= 0; i--)
|
||||
machine.RemoveAnyStateTransition(machine.anyStateTransitions[i]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the controller at a path, creating it on first run.
|
||||
/// </summary>
|
||||
private static AnimatorController GetOrCreateController(string path)
|
||||
{
|
||||
AnimatorController existing = AssetDatabase.LoadAssetAtPath<AnimatorController>(path);
|
||||
return existing != null ? existing : AnimatorController.CreateAnimatorControllerAtPath(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads (or creates) the empty clip that stands in for a slot. Its name is the slot key the
|
||||
/// binder matches sets against, which is the whole reason these exist as named assets.
|
||||
/// </summary>
|
||||
private static AnimationClip GetOrCreatePlaceholder(string slot)
|
||||
{
|
||||
string path = $"{SlotsFolder}/{slot}.anim";
|
||||
AnimationClip existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
|
||||
if (existing != null) return existing;
|
||||
|
||||
AnimationClip clip = new AnimationClip { name = slot };
|
||||
AssetDatabase.CreateAsset(clip, path);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes sure the target folders exist before anything is written into them.
|
||||
/// </summary>
|
||||
private static void EnsureFolders()
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(AnimationsFolder))
|
||||
{
|
||||
Debug.LogError($"[PlayerAnimatorControllerBuilder] '{AnimationsFolder}' does not exist — create it first.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(SlotsFolder))
|
||||
AssetDatabase.CreateFolder(AnimationsFolder, "Slots");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17862d3b35172c644928bc3acaebe6e5
|
||||
@@ -45,6 +45,16 @@ namespace Ashwild.EditorTools
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subject Config
|
||||
|
||||
/// <summary>
|
||||
/// The subject's own local rotation (Euler degrees), independent of the camera orbit. Persisted
|
||||
/// on the stage so it survives target swaps, and re-applied whenever a new subject loads.
|
||||
/// </summary>
|
||||
public Vector3 SubjectEuler;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lighting Config
|
||||
|
||||
public Color KeyColor = Color.white;
|
||||
@@ -187,7 +197,7 @@ namespace Ashwild.EditorTools
|
||||
|
||||
targetInstance.hideFlags = HideFlags.HideAndDontSave;
|
||||
targetInstance.transform.position = Vector3.zero;
|
||||
targetInstance.transform.rotation = Quaternion.identity;
|
||||
targetInstance.transform.rotation = Quaternion.Euler(SubjectEuler);
|
||||
SceneManager.MoveGameObjectToScene(targetInstance, scene);
|
||||
|
||||
ComputeBounds();
|
||||
@@ -253,6 +263,20 @@ namespace Ashwild.EditorTools
|
||||
/// </summary>
|
||||
public void FrameTarget() => Zoom = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Re-poses the subject to the given local Euler rotation and recomputes its framing bounds, so
|
||||
/// the camera keeps pivoting on the rotated silhouette's centre. No-op (but the value is still
|
||||
/// remembered) when no subject is loaded.
|
||||
/// </summary>
|
||||
public void SetSubjectRotation(Vector3 euler)
|
||||
{
|
||||
SubjectEuler = euler;
|
||||
if (targetInstance == null) return;
|
||||
|
||||
targetInstance.transform.rotation = Quaternion.Euler(euler);
|
||||
ComputeBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The world-space radius of the subject's bounding sphere, clamped to a small minimum so a
|
||||
/// zero-size target still frames sanely.
|
||||
|
||||
@@ -181,8 +181,8 @@ namespace Ashwild.EditorTools
|
||||
|
||||
/// <summary>
|
||||
/// Target card: a Prefab/Mesh kind selector that filters the object picker to only those assets
|
||||
/// (so materials, scripts and the like never clutter it), the object field itself, plus quick
|
||||
/// Frame and auto-rotate controls that act on the loaded subject.
|
||||
/// (so materials, scripts and the like never clutter it), the object field itself, per-axis
|
||||
/// rotation sliders that pose the subject, plus quick Frame and auto-rotate controls.
|
||||
/// </summary>
|
||||
private VisualElement BuildTargetCard()
|
||||
{
|
||||
@@ -206,6 +206,13 @@ namespace Ashwild.EditorTools
|
||||
card.Add(kind);
|
||||
card.Add(field);
|
||||
|
||||
card.Add(SliderRow("Rotate X", 0f, 360f, stage.SubjectEuler.x,
|
||||
v => stage.SetSubjectRotation(new Vector3(v, stage.SubjectEuler.y, stage.SubjectEuler.z))));
|
||||
card.Add(SliderRow("Rotate Y", 0f, 360f, stage.SubjectEuler.y,
|
||||
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, v, stage.SubjectEuler.z))));
|
||||
card.Add(SliderRow("Rotate Z", 0f, 360f, stage.SubjectEuler.z,
|
||||
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, stage.SubjectEuler.y, v))));
|
||||
|
||||
VisualElement buttons = new VisualElement();
|
||||
buttons.AddToClassList("ss-buttons");
|
||||
|
||||
@@ -213,6 +220,10 @@ namespace Ashwild.EditorTools
|
||||
frame.AddToClassList("ss-btn");
|
||||
buttons.Add(frame);
|
||||
|
||||
Button resetRotation = new Button(() => { stage.SetSubjectRotation(Vector3.zero); RebuildControls(); RenderPreview(); }) { text = "Reset Rotation" };
|
||||
resetRotation.AddToClassList("ss-btn");
|
||||
buttons.Add(resetRotation);
|
||||
|
||||
Toggle rotate = new Toggle("Auto-rotate") { value = autoRotate };
|
||||
rotate.RegisterValueChangedCallback(evt => SetAutoRotate(evt.newValue));
|
||||
buttons.Add(rotate);
|
||||
@@ -355,7 +366,10 @@ namespace Ashwild.EditorTools
|
||||
|
||||
/// <summary>
|
||||
/// Builds the output-folder row: a read-only path label and a Browse button that opens a folder
|
||||
/// picker and remembers the choice in EditorPrefs.
|
||||
/// picker and remembers the choice in EditorPrefs. The picker starts from the resolved export
|
||||
/// folder (normalised to native separators) so it opens where the label points — the native
|
||||
/// Windows dialog ignores forward-slash paths like Application.dataPath and would otherwise fall
|
||||
/// back to the shell's last-used location (often another project entirely).
|
||||
/// </summary>
|
||||
private VisualElement BuildFolderRow()
|
||||
{
|
||||
@@ -364,11 +378,12 @@ namespace Ashwild.EditorTools
|
||||
|
||||
Label path = new Label(ShortFolder());
|
||||
path.AddToClassList("ss-path");
|
||||
path.tooltip = exportFolder;
|
||||
path.tooltip = ResolveFolder();
|
||||
|
||||
Button browse = new Button(() =>
|
||||
{
|
||||
string chosen = EditorUtility.OpenFolderPanel("Export folder", exportFolder, string.Empty);
|
||||
string start = ResolveFolder().Replace('/', Path.DirectorySeparatorChar);
|
||||
string chosen = EditorUtility.OpenFolderPanel("Export folder", start, string.Empty);
|
||||
if (string.IsNullOrEmpty(chosen)) return;
|
||||
exportFolder = chosen;
|
||||
EditorPrefs.SetString(FolderPrefKey, exportFolder);
|
||||
@@ -627,8 +642,8 @@ namespace Ashwild.EditorTools
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current view at the export resolution and writes it as a PNG to the chosen folder,
|
||||
/// avoiding overwrites by auto-numbering, refreshing the AssetDatabase when the path is in-project,
|
||||
/// and revealing the file. Guards a missing target and failed writes with clear logs.
|
||||
/// avoiding overwrites by auto-numbering, importing it as a single-mode Sprite when the path is
|
||||
/// in-project, and revealing the file. Guards a missing target and failed writes with clear logs.
|
||||
/// </summary>
|
||||
private void ExportPng()
|
||||
{
|
||||
@@ -656,7 +671,7 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
EditorPrefs.SetString(FilePrefKey, exportFileName);
|
||||
RefreshIfInProject(path);
|
||||
ImportAsSprite(path);
|
||||
Debug.Log($"[ScreenshotStudio] Exported {exportWidth}×{exportHeight} PNG → {path}");
|
||||
ShowNotification(new GUIContent($"Exported {Path.GetFileName(path)}"));
|
||||
}
|
||||
@@ -676,6 +691,7 @@ namespace Ashwild.EditorTools
|
||||
string folder = ResolveFolder();
|
||||
string baseName = Path.GetFileNameWithoutExtension(ResolveFileName());
|
||||
float startYaw = stage.Yaw;
|
||||
System.Collections.Generic.List<string> written = new System.Collections.Generic.List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -691,7 +707,9 @@ namespace Ashwild.EditorTools
|
||||
|
||||
byte[] png = frame.EncodeToPNG();
|
||||
DestroyImmediate(frame);
|
||||
File.WriteAllBytes(Path.Combine(folder, $"{baseName}_{i:000}.png"), png);
|
||||
string framePath = Path.Combine(folder, $"{baseName}_{i:000}.png");
|
||||
File.WriteAllBytes(framePath, png);
|
||||
written.Add(framePath);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -702,10 +720,10 @@ namespace Ashwild.EditorTools
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
stage.Yaw = startYaw;
|
||||
RefreshIfInProject(folder);
|
||||
foreach (string framePath in written) ImportAsSprite(framePath);
|
||||
RenderPreview();
|
||||
Debug.Log($"[ScreenshotStudio] Exported {turntableFrames}-frame turntable → {folder}");
|
||||
ShowNotification(new GUIContent($"Exported {turntableFrames} frames"));
|
||||
Debug.Log($"[ScreenshotStudio] Exported {written.Count}-frame turntable → {folder}");
|
||||
ShowNotification(new GUIContent($"Exported {written.Count} frames"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,15 +770,40 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports the written file so it appears in the Project window when it lives under Assets.
|
||||
/// The project-relative "Assets/…" path for a written file, or null when it lives outside the
|
||||
/// project — so external-folder exports skip the in-project import step entirely.
|
||||
/// </summary>
|
||||
private static void RefreshIfInProject(string path)
|
||||
private static string ToAssetPath(string path)
|
||||
{
|
||||
string full = Path.GetFullPath(path).Replace('\\', '/');
|
||||
string root = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
|
||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
return "Assets" + full.Substring(root.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports the written file when it lives under Assets and retypes it as a single-mode Sprite, so
|
||||
/// studio exports drop straight into UI/inventory work without a manual texture-type change in the
|
||||
/// inspector. Files exported to an external folder are left untouched.
|
||||
/// </summary>
|
||||
private static void ImportAsSprite(string path)
|
||||
{
|
||||
string assetPath = ToAssetPath(path);
|
||||
if (assetPath == null) return;
|
||||
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||
if (importer == null)
|
||||
{
|
||||
Debug.LogError($"[ScreenshotStudio] No TextureImporter for '{assetPath}' — cannot set Sprite type.");
|
||||
return;
|
||||
}
|
||||
|
||||
importer.textureType = TextureImporterType.Sprite;
|
||||
importer.spriteImportMode = SpriteImportMode.Single;
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,47 +1,268 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using Ashwild.Network;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor tool that assigns a unique, stable id (>= 0) to every scene WorldObject in the open
|
||||
/// scene — Pickables and Harvestables alike, in one shared sequence. The ids are baked into the
|
||||
/// scene asset, so all clients load the exact same mapping (the scene is shared). Run after
|
||||
/// scattering or whenever scene objects are added/removed.
|
||||
/// The one menu item that keeps the baked ids of scene WorldObjects — Pickables and Harvestables
|
||||
/// alike — correct. The id is the object's identity across the network: every client loads the same
|
||||
/// scene, so the same id must designate the same object on every machine.
|
||||
///
|
||||
/// It does the whole job in one click, in the order the three steps have to run:
|
||||
/// strip the ids that ended up baked in prefab <i>assets</i>, give an id to every scene object that
|
||||
/// lacks a valid one, then verify the result and report it. Splitting them into separate menu items
|
||||
/// only creates ways to run them in the wrong order, or to forget one.
|
||||
///
|
||||
/// Assignment is <b>stable and additive</b>: ids already baked into the scene are kept, and only the
|
||||
/// objects that need one (never assigned, or colliding with another object) are given the next free
|
||||
/// number. An earlier version renumbered everything from zero on each run, ordered by
|
||||
/// <c>InstanceID</c> — an order that is not stable between editor sessions — so a single new bush
|
||||
/// reshuffled every id in the scene and produced an unreviewable diff.
|
||||
///
|
||||
/// Ids belong to a scene, never to a prefab asset: an id baked into a prefab is inherited by every
|
||||
/// instance placed from it, so all of them collide on that one id from the moment they are created —
|
||||
/// which reads, in game, as objects that play their interaction and give nothing.
|
||||
/// </summary>
|
||||
public static class WorldObjectIdAssigner
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const string LogPrefix = "[WorldObjectIds]";
|
||||
|
||||
/// <summary>
|
||||
/// How many fixed objects are named in the summary before it collapses into a count — a freshly
|
||||
/// scattered zone can need hundreds of ids, and an unreadable console helps nobody.
|
||||
/// </summary>
|
||||
private const int MaxNamesInSummary = 8;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Menu
|
||||
|
||||
/// <summary>
|
||||
/// Finds all WorldObjects in the open scene and assigns them sequential unique ids.
|
||||
/// Clears prefab-baked ids, assigns an id to every scene WorldObject that lacks a valid one, then
|
||||
/// validates the outcome. Reports what changed and, if anything is still wrong, logs each culprit
|
||||
/// with its object as context so clicking the message selects it in the hierarchy.
|
||||
/// </summary>
|
||||
[MenuItem("Tools/Ashwild/Assign World Object IDs")]
|
||||
public static void Assign()
|
||||
[MenuItem("Tools/Ashwild/Setup World Object IDs")]
|
||||
public static void Setup()
|
||||
{
|
||||
WorldObject[] objects = Object.FindObjectsByType<WorldObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
if (objects.Length == 0)
|
||||
if (PrefabStageUtility.GetCurrentPrefabStage() != null)
|
||||
{
|
||||
Debug.Log("[WorldObjectIdAssigner] No WorldObject found in the open scene.");
|
||||
Debug.LogError($"{LogPrefix} Ids are per scene, not per prefab. Close the prefab stage and run this " +
|
||||
"from the scene that contains the objects.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Deterministic order so re-runs are stable within an editor session.
|
||||
System.Array.Sort(objects, (a, b) => a.GetInstanceID().CompareTo(b.GetInstanceID()));
|
||||
int clearedPrefabs = ClearPrefabIds();
|
||||
|
||||
int next = 0;
|
||||
foreach (WorldObject obj in objects)
|
||||
if (!TryCollect(out List<WorldObject> objects)) return;
|
||||
|
||||
List<WorldObject> assigned = AssignMissingIds(objects);
|
||||
if (assigned.Count > 0) MarkScenesDirty(objects);
|
||||
|
||||
Report(objects, clearedPrefabs, assigned);
|
||||
Validate(objects);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Steps
|
||||
|
||||
/// <summary>
|
||||
/// Strips the id baked into WorldObject prefab <i>assets</i>, so newly placed instances start
|
||||
/// unassigned instead of all inheriting the same id. Scene instances keep their own id, which is
|
||||
/// stored as a prefab override and left untouched — but an instance that had no override now reads
|
||||
/// as unassigned, which is exactly why the assignment step has to run after this one.
|
||||
/// Returns how many prefabs were cleaned.
|
||||
/// </summary>
|
||||
private static int ClearPrefabIds()
|
||||
{
|
||||
string[] guids = AssetDatabase.FindAssets("t:Prefab");
|
||||
int cleared = 0;
|
||||
|
||||
foreach (string guid in guids)
|
||||
{
|
||||
SerializedObject so = new SerializedObject(obj);
|
||||
so.FindProperty("id").intValue = next++;
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
EditorUtility.SetDirty(obj);
|
||||
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (prefab == null) continue;
|
||||
|
||||
bool changed = false;
|
||||
foreach (WorldObject obj in prefab.GetComponentsInChildren<WorldObject>(true))
|
||||
{
|
||||
if (obj.Id < 0) continue;
|
||||
WriteId(obj, -1);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
|
||||
EditorUtility.SetDirty(prefab);
|
||||
cleared++;
|
||||
}
|
||||
|
||||
EditorSceneManager.MarkSceneDirty(objects[0].gameObject.scene);
|
||||
Debug.Log($"[WorldObjectIdAssigner] Assigned ids to {objects.Length} WorldObject(s) (pickables + harvestables). Save the scene to bake them.");
|
||||
if (cleared > 0) AssetDatabase.SaveAssets();
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the next free id to every object that has none or that duplicates an id already taken,
|
||||
/// leaving valid ids untouched. Returns the objects that were changed.
|
||||
/// </summary>
|
||||
private static List<WorldObject> AssignMissingIds(List<WorldObject> objects)
|
||||
{
|
||||
HashSet<int> taken = new HashSet<int>();
|
||||
List<WorldObject> assigned = new List<WorldObject>();
|
||||
int next = 0;
|
||||
|
||||
foreach (WorldObject obj in objects)
|
||||
{
|
||||
if (obj.Id >= 0 && taken.Add(obj.Id)) continue;
|
||||
|
||||
while (!taken.Add(next)) next++;
|
||||
WriteId(obj, next);
|
||||
assigned.Add(obj);
|
||||
}
|
||||
|
||||
return assigned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-checks the ids after the fact and logs every remaining problem against its object. Nothing
|
||||
/// should ever be reported here — it is the proof that the run actually worked, rather than a
|
||||
/// summary claiming it did.
|
||||
/// </summary>
|
||||
private static void Validate(List<WorldObject> objects)
|
||||
{
|
||||
Dictionary<int, WorldObject> byId = new Dictionary<int, WorldObject>();
|
||||
int problems = 0;
|
||||
|
||||
foreach (WorldObject obj in objects)
|
||||
{
|
||||
if (obj.Id < 0)
|
||||
{
|
||||
problems++;
|
||||
Debug.LogError($"{LogPrefix} '{GetPath(obj)}' still has no id — it will not be interactable in game.", obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byId.TryGetValue(obj.Id, out WorldObject other))
|
||||
{
|
||||
problems++;
|
||||
Debug.LogError($"{LogPrefix} '{GetPath(obj)}' still shares id {obj.Id} with '{GetPath(other)}' — " +
|
||||
"using one silently disables the other.", obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.Add(obj.Id, obj);
|
||||
}
|
||||
|
||||
if (problems > 0)
|
||||
Debug.LogError($"{LogPrefix} {problems} id problem(s) survived the setup — this should not happen; " +
|
||||
"check the errors above.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs what the run changed, in one line, and reminds to save when the scene was touched.
|
||||
/// </summary>
|
||||
private static void Report(List<WorldObject> objects, int clearedPrefabs, List<WorldObject> assigned)
|
||||
{
|
||||
string prefabs = clearedPrefabs > 0 ? $"Cleared the baked id of {clearedPrefabs} prefab(s). " : string.Empty;
|
||||
|
||||
if (assigned.Count == 0)
|
||||
{
|
||||
Debug.Log($"{LogPrefix} {prefabs}All {objects.Count} scene WorldObject(s) already have a unique id — nothing to assign.");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"{LogPrefix} {prefabs}Assigned an id to {assigned.Count} of {objects.Count} scene WorldObject(s): " +
|
||||
$"{Describe(assigned)}. Save the scene to bake them.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Gathers the WorldObjects of the open scenes, in a deterministic order (scene, then hierarchy
|
||||
/// path) so two runs walk them the same way and hand out the same ids.
|
||||
/// </summary>
|
||||
private static bool TryCollect(out List<WorldObject> objects)
|
||||
{
|
||||
objects = null;
|
||||
|
||||
WorldObject[] found = Object.FindObjectsByType<WorldObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
if (found.Length == 0)
|
||||
{
|
||||
Debug.Log($"{LogPrefix} No WorldObject found in the open scene(s).");
|
||||
return false;
|
||||
}
|
||||
|
||||
objects = new List<WorldObject>(found);
|
||||
objects.Sort((a, b) =>
|
||||
{
|
||||
int byScene = string.CompareOrdinal(a.gameObject.scene.path, b.gameObject.scene.path);
|
||||
return byScene != 0 ? byScene : string.CompareOrdinal(GetPath(a), GetPath(b));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the id through SerializedObject so the change is recorded as a prefab override on an
|
||||
/// instance, rather than silently editing the shared prefab value.
|
||||
/// </summary>
|
||||
private static void WriteId(WorldObject obj, int id)
|
||||
{
|
||||
SerializedObject so = new SerializedObject(obj);
|
||||
so.FindProperty("id").intValue = id;
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
EditorUtility.SetDirty(obj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks every scene that owns one of these objects dirty, since the open scenes may be several
|
||||
/// (additive loading) and only the touched ones need saving.
|
||||
/// </summary>
|
||||
private static void MarkScenesDirty(List<WorldObject> objects)
|
||||
{
|
||||
HashSet<Scene> scenes = new HashSet<Scene>();
|
||||
foreach (WorldObject obj in objects)
|
||||
scenes.Add(obj.gameObject.scene);
|
||||
|
||||
foreach (Scene scene in scenes)
|
||||
if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A short, readable summary of the objects that were given an id.
|
||||
/// </summary>
|
||||
private static string Describe(List<WorldObject> objects)
|
||||
{
|
||||
List<string> names = new List<string>();
|
||||
for (int i = 0; i < objects.Count && i < MaxNamesInSummary; i++)
|
||||
names.Add($"{objects[i].name} (id {objects[i].Id})");
|
||||
|
||||
if (objects.Count > MaxNamesInSummary) names.Add($"… and {objects.Count - MaxNamesInSummary} more");
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchy path of an object, used both for the deterministic ordering and for error messages
|
||||
/// that must point at one specific object among many identically named ones.
|
||||
/// </summary>
|
||||
private static string GetPath(WorldObject obj)
|
||||
{
|
||||
string path = obj.name;
|
||||
Transform current = obj.transform.parent;
|
||||
while (current != null)
|
||||
{
|
||||
path = $"{current.name}/{path}";
|
||||
current = current.parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using INab.BetterFog.URP;
|
||||
|
||||
namespace Ashwild.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Ties INab Studio's Better Fog to the day/night cycle: every frame it copies the
|
||||
/// TimeOfDayManager's current sky colors into the Better Fog volume override so the fog
|
||||
/// warms at dawn/dusk and turns cool/dark at night along with the sky. This is the ONLY
|
||||
/// script that references the Better Fog asset — the manager itself stays fog-agnostic, so
|
||||
/// swapping the fog solution later only touches this file. Runs in edit mode so the fog
|
||||
/// tracks the time slider live.
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[DisallowMultipleComponent]
|
||||
[AddComponentMenu("GAME/Environment/Better Fog Day-Night Driver")]
|
||||
public class BetterFogDayNightDriver : MonoBehaviour
|
||||
{
|
||||
#region Serialized Fields
|
||||
|
||||
[Tooltip("Global Volume whose profile holds a Better Fog override.")]
|
||||
[SerializeField] private Volume fogVolume;
|
||||
[Tooltip("Push the current horizon color into Better Fog's Fog Color.")]
|
||||
[SerializeField] private bool driveFogColor = true;
|
||||
[Tooltip("Push the current sun color into Better Fog's Sun Color (only visible when Better Fog's Sun Light is enabled).")]
|
||||
[SerializeField] private bool driveSunColor = true;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private BetterFogVolumeComponent fog;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Better Fog override once so Update can drive it cheaply, and warns if the
|
||||
/// wiring is incomplete instead of silently doing nothing.
|
||||
/// </summary>
|
||||
private void OnEnable()
|
||||
{
|
||||
ResolveFog();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the manager's current palette into the fog override. No-op until both the fog
|
||||
/// volume and the manager exist, re-resolving the override if it was lost (profile swap).
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
if (fog == null && !ResolveFog()) return;
|
||||
if (TimeOfDayManager.Instance == null) return;
|
||||
|
||||
if (driveFogColor)
|
||||
{
|
||||
fog._FogColor.overrideState = true;
|
||||
fog._FogColor.value = TimeOfDayManager.Instance.CurrentHorizonColor;
|
||||
}
|
||||
|
||||
if (driveSunColor)
|
||||
{
|
||||
fog._SunColor.overrideState = true;
|
||||
fog._SunColor.value = TimeOfDayManager.Instance.CurrentSunColor;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the Better Fog override from the assigned volume's profile, logging a clear
|
||||
/// error when it is missing so the setup mistake is obvious. Returns true on success.
|
||||
/// </summary>
|
||||
private bool ResolveFog()
|
||||
{
|
||||
if (fogVolume == null)
|
||||
{
|
||||
Debug.LogError($"[BetterFogDayNightDriver] '{name}' has no Fog Volume assigned.", this);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fogVolume.profile == null || !fogVolume.profile.TryGet(out fog))
|
||||
{
|
||||
Debug.LogError($"[BetterFogDayNightDriver] '{name}' volume profile has no Better Fog override.", this);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a126fd21c4b3064987e3214346aa8a2
|
||||
@@ -1,15 +1,21 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Ashwild.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the day/night cycle for the "GAME/StylizedSky" skybox. Rotates the sun light and
|
||||
/// pushes every time-dependent value (gradient colors, sun/moon direction, cloud params,
|
||||
/// star opacity) into shader globals — mirroring how SeasonManager broadcasts "_Season".
|
||||
/// The skybox shader itself is dumb: it only renders whatever globals this manager writes.
|
||||
/// Runs in edit mode (ExecuteAlways) so the sky updates live while scrubbing the time slider.
|
||||
/// Purely visual and local; when co-op time-sync is added, only the driving time value needs
|
||||
/// to come from the server (see the "not yet networked" note in CLAUDE.md, like SeasonManager).
|
||||
/// Drives the day/night cycle for the self-contained "GAME/StylizedSky" skybox. Rotates
|
||||
/// the sun Directional Light and evaluates one palette per moment of day (sky gradient,
|
||||
/// sun/moon color, cloud colors, star opacity) straight onto the sky MATERIAL's
|
||||
/// properties — the shader stays fully self-contained, so with no manager the material
|
||||
/// still renders its own authored look and this component only overrides the
|
||||
/// time-dependent values on top. Runs in edit mode (ExecuteAlways) so the sky updates
|
||||
/// live while scrubbing the time slider.
|
||||
///
|
||||
/// Purely visual and LOCAL for now: like SeasonManager it is not yet networked. The
|
||||
/// single seam to sync in co-op is the time value itself — feed it through
|
||||
/// <see cref="SetTimeOfDay"/> from a server world-clock (SyncVar) instead of the local
|
||||
/// auto-advance, and every client's sky and lighting follows (see CLAUDE.md §3).
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[DisallowMultipleComponent]
|
||||
@@ -27,14 +33,16 @@ namespace Ashwild.Environment
|
||||
[Header("References")]
|
||||
[Tooltip("Directional light rotated to act as the sun. Optional but recommended.")]
|
||||
[SerializeField] private Light sunLight;
|
||||
[Tooltip("Skybox material using GAME/StylizedSky. Assigned to RenderSettings on enable when set.")]
|
||||
[SerializeField] private Material skyboxMaterial;
|
||||
[Tooltip("Second directional light acting as the moon: cool, dim, lit only at night, aimed opposite the sun. Optional.")]
|
||||
[SerializeField] private Light moonLight;
|
||||
[Tooltip("Material using GAME/StylizedSky. Assigned to RenderSettings.skybox on enable.")]
|
||||
[SerializeField] private Material skyMaterial;
|
||||
|
||||
[Header("Time")]
|
||||
[Tooltip("0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float timeOfDay = 0.5f;
|
||||
[SerializeField] private bool autoAdvance = true;
|
||||
[Tooltip("Real seconds for one full 24h cycle. Only advances in Play.")]
|
||||
[Tooltip("Real seconds for one full day/night cycle. Only advances in Play.")]
|
||||
[SerializeField] private float dayLengthSeconds = 300f;
|
||||
[Tooltip("Compass offset of the sun's arc, in degrees.")]
|
||||
[Range(0f, 360f)] [SerializeField] private float sunYaw = 20f;
|
||||
@@ -45,68 +53,70 @@ namespace Ashwild.Environment
|
||||
[Tooltip("Peak directional-light intensity reached around noon.")]
|
||||
[SerializeField] private float maxSunIntensity = 1.3f;
|
||||
|
||||
[Header("Sky Gradient")]
|
||||
[Header("Sky Gradient Over Day")]
|
||||
[SerializeField] private Gradient zenithColorOverDay = new Gradient();
|
||||
[SerializeField] private Gradient horizonColorOverDay = new Gradient();
|
||||
[SerializeField] private Gradient groundColorOverDay = new Gradient();
|
||||
|
||||
[Header("Moon")]
|
||||
[SerializeField] private Color moonColor = new Color(0.85f, 0.88f, 0.96f);
|
||||
|
||||
[Header("Clouds")]
|
||||
[Header("Clouds Over Day")]
|
||||
[SerializeField] private Gradient cloudColorOverDay = new Gradient();
|
||||
[SerializeField] private Gradient cloudShadowColorOverDay = new Gradient();
|
||||
[Tooltip("Coverage threshold over the day. Higher = fewer clouds.")]
|
||||
[SerializeField] private AnimationCurve cloudCoverageOverDay = AnimationCurve.Constant(0f, 1f, 0.48f);
|
||||
[Range(0f, 1f)] [SerializeField] private float cloudOpacity = 1f;
|
||||
[SerializeField] private Vector2 windDirection = new Vector2(1f, 0.35f);
|
||||
[SerializeField] private float windSpeed = 0.01f;
|
||||
|
||||
[Header("Stars")]
|
||||
[Tooltip("Sun elevation at which stars start fading in (upper) and are fully visible (lower).")]
|
||||
[SerializeField] private float starFadeInElevation = 0.05f;
|
||||
[SerializeField] private float starFullElevation = -0.12f;
|
||||
[Header("Moon and Stars")]
|
||||
[SerializeField] private Color moonColor = new Color(0.85f, 0.88f, 0.96f);
|
||||
[Tooltip("Cool tint of the moonlight cast on the scene at night.")]
|
||||
[SerializeField] private Color moonLightColor = new Color(0.55f, 0.62f, 0.85f);
|
||||
[Tooltip("Peak moonlight intensity reached in the dead of night.")]
|
||||
[SerializeField] private float maxMoonIntensity = 0.3f;
|
||||
[Tooltip("Sun height at which stars/moon start fading in (upper) and are fully visible (lower).")]
|
||||
[SerializeField] private float starFadeInElevation = 0.08f;
|
||||
[SerializeField] private float starFullElevation = -0.15f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private Vector2 cloudOffset;
|
||||
[Header("Fog")]
|
||||
[Tooltip("Tint the built-in RenderSettings fog with the horizon color. Leave off when an external fog (e.g. Better Fog) is driven from CurrentHorizonColor instead.")]
|
||||
[SerializeField] private bool driveFogColor = true;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shader Property IDs
|
||||
|
||||
private static readonly int ZenithColorId = Shader.PropertyToID("_SkyZenithColor");
|
||||
private static readonly int HorizonColorId = Shader.PropertyToID("_SkyHorizonColor");
|
||||
private static readonly int GroundColorId = Shader.PropertyToID("_SkyGroundColor");
|
||||
private static readonly int SunDirectionId = Shader.PropertyToID("_SkySunDirection");
|
||||
private static readonly int SunColorId = Shader.PropertyToID("_SkySunColor");
|
||||
private static readonly int MoonDirectionId = Shader.PropertyToID("_SkyMoonDirection");
|
||||
private static readonly int MoonColorId = Shader.PropertyToID("_SkyMoonColor");
|
||||
private static readonly int CloudColorId = Shader.PropertyToID("_SkyCloudColor");
|
||||
private static readonly int CloudShadowColorId = Shader.PropertyToID("_SkyCloudShadowColor");
|
||||
private static readonly int CloudParamsId = Shader.PropertyToID("_SkyCloudParams");
|
||||
private static readonly int StarOpacityId = Shader.PropertyToID("_SkyStarOpacity");
|
||||
private static readonly int ZenithColorId = Shader.PropertyToID("_ZenithColor");
|
||||
private static readonly int HorizonColorId = Shader.PropertyToID("_HorizonColor");
|
||||
private static readonly int GroundColorId = Shader.PropertyToID("_GroundColor");
|
||||
private static readonly int SunDirectionId = Shader.PropertyToID("_SunDirection");
|
||||
private static readonly int SunColorId = Shader.PropertyToID("_SunColor");
|
||||
private static readonly int MoonDirectionId = Shader.PropertyToID("_MoonDirection");
|
||||
private static readonly int MoonColorId = Shader.PropertyToID("_MoonColor");
|
||||
private static readonly int CloudColorId = Shader.PropertyToID("_CloudColor");
|
||||
private static readonly int CloudShadowColorId = Shader.PropertyToID("_CloudShadowColor");
|
||||
private static readonly int StarOpacityId = Shader.PropertyToID("_StarOpacity");
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private float lastAmbientRefresh;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Registers the instance, assigns the skybox and pushes an initial state so the sky is
|
||||
/// correct the moment the component becomes active (including in the editor).
|
||||
/// Registers the instance, makes the sky material active and pushes an initial state
|
||||
/// so the sky is correct the moment the component becomes active (including in edit).
|
||||
/// </summary>
|
||||
private void OnEnable()
|
||||
{
|
||||
Instance = this;
|
||||
if (skyboxMaterial != null)
|
||||
RenderSettings.skybox = skyboxMaterial;
|
||||
if (skyMaterial != null)
|
||||
RenderSettings.skybox = skyMaterial;
|
||||
RenderSettings.ambientMode = AmbientMode.Skybox;
|
||||
RenderSettings.defaultReflectionMode = DefaultReflectionMode.Skybox;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the shared instance so a disabled manager never keeps answering as the current one.
|
||||
/// Clears the shared instance so a disabled manager never keeps answering as current.
|
||||
/// </summary>
|
||||
private void OnDisable()
|
||||
{
|
||||
@@ -124,14 +134,13 @@ namespace Ashwild.Environment
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances time (Play only) and scrolls the clouds, then re-applies every frame.
|
||||
/// Advances time (Play only) and re-applies the palette every frame.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
if (Application.isPlaying && autoAdvance)
|
||||
timeOfDay = Mathf.Repeat(timeOfDay + Time.deltaTime / dayLengthSeconds, 1f);
|
||||
|
||||
cloudOffset += windDirection.normalized * (windSpeed * Time.deltaTime);
|
||||
Apply();
|
||||
}
|
||||
|
||||
@@ -140,42 +149,70 @@ namespace Ashwild.Environment
|
||||
#region Apply
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current time of day into sun orientation and shader globals, and mirrors
|
||||
/// the resolved sun color/intensity onto the directional light so scene lighting matches
|
||||
/// the sky. This is the single place every time-dependent value is broadcast.
|
||||
/// Resolves the current time of day into sun orientation and the full sky palette,
|
||||
/// writes it onto the sky material, and mirrors the sun color/intensity onto the
|
||||
/// directional light so scene lighting matches the sky. Single source of every
|
||||
/// time-dependent value. No-op without a material to drive.
|
||||
/// </summary>
|
||||
private void Apply()
|
||||
{
|
||||
Vector3 sunDir = OrientSun();
|
||||
Vector3 moonDir = -sunDir;
|
||||
|
||||
float nightFactor = Mathf.SmoothStep(0f, 1f,
|
||||
Mathf.InverseLerp(starFadeInElevation, starFullElevation, sunDir.y));
|
||||
Color sunColor = sunColorOverDay.Evaluate(timeOfDay);
|
||||
DriveSunLight(sunColor);
|
||||
DriveMoonLight(sunDir, nightFactor);
|
||||
PromoteMainLight(nightFactor);
|
||||
|
||||
Shader.SetGlobalColor(ZenithColorId, ToRendered(zenithColorOverDay.Evaluate(timeOfDay)));
|
||||
Shader.SetGlobalColor(HorizonColorId, ToRendered(horizonColorOverDay.Evaluate(timeOfDay)));
|
||||
Shader.SetGlobalColor(GroundColorId, ToRendered(groundColorOverDay.Evaluate(timeOfDay)));
|
||||
Color horizon = horizonColorOverDay.Evaluate(timeOfDay);
|
||||
CurrentHorizonColor = horizon;
|
||||
CurrentSunColor = sunColor;
|
||||
CurrentNightFactor = nightFactor;
|
||||
if (driveFogColor)
|
||||
RenderSettings.fogColor = ToRendered(horizon);
|
||||
|
||||
Shader.SetGlobalVector(SunDirectionId, sunDir);
|
||||
Shader.SetGlobalColor(SunColorId, ToRendered(sunColor));
|
||||
Shader.SetGlobalVector(MoonDirectionId, moonDir);
|
||||
Shader.SetGlobalColor(MoonColorId, ToRendered(moonColor));
|
||||
if (skyMaterial == null)
|
||||
{
|
||||
RepaintEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
Shader.SetGlobalColor(CloudColorId, ToRendered(cloudColorOverDay.Evaluate(timeOfDay)));
|
||||
Shader.SetGlobalColor(CloudShadowColorId, ToRendered(cloudShadowColorOverDay.Evaluate(timeOfDay)));
|
||||
Shader.SetGlobalVector(CloudParamsId, new Vector4(
|
||||
cloudCoverageOverDay.Evaluate(timeOfDay), cloudOpacity, cloudOffset.x, cloudOffset.y));
|
||||
skyMaterial.SetColor(ZenithColorId, ToRendered(zenithColorOverDay.Evaluate(timeOfDay)));
|
||||
skyMaterial.SetColor(HorizonColorId, ToRendered(horizon));
|
||||
skyMaterial.SetColor(GroundColorId, ToRendered(groundColorOverDay.Evaluate(timeOfDay)));
|
||||
|
||||
Shader.SetGlobalFloat(StarOpacityId,
|
||||
Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(starFadeInElevation, starFullElevation, sunDir.y)));
|
||||
skyMaterial.SetVector(SunDirectionId, sunDir);
|
||||
skyMaterial.SetColor(SunColorId, ToRendered(sunColorOverDay.Evaluate(timeOfDay)));
|
||||
skyMaterial.SetVector(MoonDirectionId, -sunDir);
|
||||
skyMaterial.SetColor(MoonColorId, ToRendered(moonColor));
|
||||
|
||||
skyMaterial.SetColor(CloudColorId, ToRendered(cloudColorOverDay.Evaluate(timeOfDay)));
|
||||
skyMaterial.SetColor(CloudShadowColorId, ToRendered(cloudShadowColorOverDay.Evaluate(timeOfDay)));
|
||||
|
||||
skyMaterial.SetFloat(StarOpacityId, nightFactor);
|
||||
|
||||
RefreshAmbient();
|
||||
RepaintEditor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes ambient light and the skybox reflection from the current (procedural)
|
||||
/// sky so scene lighting tracks the day — Unity does NOT do this automatically for a
|
||||
/// material that changes every frame. Throttled to a few times a second because
|
||||
/// rebaking the environment probe every frame would stutter the editor.
|
||||
/// </summary>
|
||||
private void RefreshAmbient()
|
||||
{
|
||||
float now = Time.realtimeSinceStartup;
|
||||
if (now - lastAmbientRefresh < 0.15f) return;
|
||||
lastAmbientRefresh = now;
|
||||
DynamicGI.UpdateEnvironment();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the sun light so noon sits overhead and sunrise/sunset sit on the horizon,
|
||||
/// and returns the world-space direction pointing toward the sun (what the shader wants).
|
||||
/// Works from the current time even when no light is assigned.
|
||||
/// and returns the world-space direction pointing toward the sun (what the shader
|
||||
/// wants). Works from the current time even when no light is assigned.
|
||||
/// </summary>
|
||||
private Vector3 OrientSun()
|
||||
{
|
||||
@@ -198,9 +235,35 @@ namespace Ashwild.Environment
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an authored sRGB color to the value the shader must receive. Colors pushed via
|
||||
/// Shader.SetGlobalColor are NOT auto-gamma-corrected (unlike material properties), so in a
|
||||
/// linear project they must be pre-converted or the whole sky washes out to white.
|
||||
/// Aims the moon light opposite the sun (its light travels toward the sun direction, i.e.
|
||||
/// comes from the moon), tints it cool, and fades its intensity in with nightFactor so it
|
||||
/// only lights the scene once the sun is down. No-op when no moon light is wired up.
|
||||
/// </summary>
|
||||
private void DriveMoonLight(Vector3 sunDir, float nightFactor)
|
||||
{
|
||||
if (moonLight == null) return;
|
||||
moonLight.transform.rotation = Quaternion.LookRotation(sunDir);
|
||||
moonLight.color = moonLightColor;
|
||||
moonLight.intensity = nightFactor * maxMoonIntensity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points URP's main (shadow-casting) light at whichever body is currently up — sun by
|
||||
/// day, moon by night. Without this, a pinned Sun Source keeps the dark night sun as the
|
||||
/// main light and shadows vanish once it sets; swapping to the moon keeps shadows going.
|
||||
/// </summary>
|
||||
private void PromoteMainLight(float nightFactor)
|
||||
{
|
||||
Light mainLight = nightFactor > 0.5f && moonLight != null ? moonLight : sunLight;
|
||||
if (mainLight != null)
|
||||
RenderSettings.sun = mainLight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an authored sRGB color to the value the shader must receive. Colors
|
||||
/// pushed via Material.SetColor are NOT auto-gamma-corrected (unlike the inspector
|
||||
/// color field), so in a linear project they must be pre-converted or the whole sky
|
||||
/// washes out to white.
|
||||
/// </summary>
|
||||
private static Color ToRendered(Color c)
|
||||
{
|
||||
@@ -208,7 +271,8 @@ namespace Ashwild.Environment
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces the Scene view to redraw while scrubbing values outside Play, matching SeasonManager.
|
||||
/// Forces the Scene view to redraw while scrubbing values outside Play, matching
|
||||
/// SeasonManager, so the sky updates live without entering Play mode.
|
||||
/// </summary>
|
||||
private void RepaintEditor()
|
||||
{
|
||||
@@ -228,7 +292,26 @@ namespace Ashwild.Environment
|
||||
public float TimeOfDay => timeOfDay;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the time of day directly (wrapped into [0,1)) and refreshes the sky immediately.
|
||||
/// The horizon color resolved for the current time (authored/gamma space). External
|
||||
/// atmosphere systems (e.g. the Better Fog driver) read this to tint fog with the sky.
|
||||
/// </summary>
|
||||
public Color CurrentHorizonColor { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The sun color resolved for the current time (authored/gamma space) — for fog sun
|
||||
/// scattering or any effect that should match the sun tint.
|
||||
/// </summary>
|
||||
public Color CurrentSunColor { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 0 by day, 1 in the dead of night (same curve that fades in the stars and the moon).
|
||||
/// </summary>
|
||||
public float CurrentNightFactor { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the time of day directly (wrapped into [0,1)) and refreshes the sky. This is
|
||||
/// the seam a networked world-clock drives from — call it on every client with the
|
||||
/// server time instead of relying on the local auto-advance.
|
||||
/// </summary>
|
||||
public void SetTimeOfDay(float normalized)
|
||||
{
|
||||
@@ -241,9 +324,9 @@ namespace Ashwild.Environment
|
||||
#region Defaults
|
||||
|
||||
/// <summary>
|
||||
/// Seeds all gradients/curves with a bright, soft daytime palette matched to the Raygeas
|
||||
/// stylized skybox reference, so a freshly added manager looks right without hand-authoring.
|
||||
/// Invoked by the editor when the component is added or reset.
|
||||
/// Seeds every gradient/curve with a bright stylized daytime palette (blue noon,
|
||||
/// warm dawn/dusk, deep-blue night) so a freshly added manager looks right without
|
||||
/// hand-authoring. Invoked by the editor when the component is added or reset.
|
||||
/// </summary>
|
||||
private void Reset()
|
||||
{
|
||||
@@ -254,70 +337,68 @@ namespace Ashwild.Environment
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the tuned color palette WITHOUT clearing the wired references (Sun Light,
|
||||
/// Skybox Material) or the current time — unlike Reset, which nulls everything. Exposed on
|
||||
/// the component's context menu so the palette can be re-seeded during iteration.
|
||||
/// Re-applies the tuned palette WITHOUT clearing the wired references (Sun Light,
|
||||
/// Sky Material) or the current time — unlike Reset, which nulls everything. Exposed
|
||||
/// on the context menu so the palette can be re-seeded during iteration.
|
||||
/// </summary>
|
||||
[ContextMenu("Apply Stylized Sky Preset")]
|
||||
private void SeedStylizedPreset()
|
||||
{
|
||||
zenithColorOverDay = BuildGradient(
|
||||
(0.00f, new Color(0.02f, 0.03f, 0.07f)),
|
||||
(0.23f, new Color(0.18f, 0.28f, 0.40f)),
|
||||
(0.30f, new Color(0.14f, 0.40f, 0.56f)),
|
||||
(0.50f, new Color(0.13f, 0.42f, 0.58f)),
|
||||
(0.70f, new Color(0.14f, 0.38f, 0.53f)),
|
||||
(0.78f, new Color(0.22f, 0.26f, 0.40f)),
|
||||
(1.00f, new Color(0.02f, 0.03f, 0.07f)));
|
||||
(0.00f, new Color(0.03f, 0.05f, 0.12f)),
|
||||
(0.23f, new Color(0.22f, 0.30f, 0.52f)),
|
||||
(0.32f, new Color(0.20f, 0.42f, 0.80f)),
|
||||
(0.50f, new Color(0.20f, 0.45f, 0.85f)),
|
||||
(0.68f, new Color(0.20f, 0.40f, 0.78f)),
|
||||
(0.78f, new Color(0.24f, 0.28f, 0.50f)),
|
||||
(1.00f, new Color(0.03f, 0.05f, 0.12f)));
|
||||
|
||||
horizonColorOverDay = BuildGradient(
|
||||
(0.00f, new Color(0.04f, 0.05f, 0.10f)),
|
||||
(0.23f, new Color(0.86f, 0.50f, 0.36f)),
|
||||
(0.32f, new Color(0.32f, 0.56f, 0.68f)),
|
||||
(0.50f, new Color(0.26f, 0.52f, 0.66f)),
|
||||
(0.68f, new Color(0.30f, 0.50f, 0.60f)),
|
||||
(0.78f, new Color(0.86f, 0.48f, 0.32f)),
|
||||
(1.00f, new Color(0.04f, 0.05f, 0.10f)));
|
||||
(0.00f, new Color(0.05f, 0.07f, 0.14f)),
|
||||
(0.23f, new Color(0.95f, 0.58f, 0.40f)),
|
||||
(0.32f, new Color(0.75f, 0.86f, 0.95f)),
|
||||
(0.50f, new Color(0.72f, 0.85f, 0.95f)),
|
||||
(0.68f, new Color(0.78f, 0.84f, 0.92f)),
|
||||
(0.78f, new Color(0.97f, 0.52f, 0.35f)),
|
||||
(1.00f, new Color(0.05f, 0.07f, 0.14f)));
|
||||
|
||||
groundColorOverDay = BuildGradient(
|
||||
(0.00f, new Color(0.03f, 0.04f, 0.08f)),
|
||||
(0.25f, new Color(0.26f, 0.30f, 0.34f)),
|
||||
(0.50f, new Color(0.22f, 0.44f, 0.56f)),
|
||||
(0.75f, new Color(0.28f, 0.28f, 0.30f)),
|
||||
(0.25f, new Color(0.35f, 0.33f, 0.32f)),
|
||||
(0.50f, new Color(0.40f, 0.44f, 0.48f)),
|
||||
(0.75f, new Color(0.36f, 0.31f, 0.30f)),
|
||||
(1.00f, new Color(0.03f, 0.04f, 0.08f)));
|
||||
|
||||
sunColorOverDay = BuildGradient(
|
||||
(0.00f, new Color(0.40f, 0.40f, 0.50f)),
|
||||
(0.00f, new Color(0.35f, 0.40f, 0.55f)),
|
||||
(0.23f, new Color(1.00f, 0.55f, 0.30f)),
|
||||
(0.50f, new Color(1.00f, 0.95f, 0.85f)),
|
||||
(0.77f, new Color(1.00f, 0.50f, 0.28f)),
|
||||
(1.00f, new Color(0.40f, 0.40f, 0.50f)));
|
||||
(0.50f, new Color(1.00f, 0.96f, 0.88f)),
|
||||
(0.77f, new Color(1.00f, 0.52f, 0.28f)),
|
||||
(1.00f, new Color(0.35f, 0.40f, 0.55f)));
|
||||
|
||||
cloudColorOverDay = BuildGradient(
|
||||
(0.00f, new Color(0.10f, 0.11f, 0.18f)),
|
||||
(0.25f, new Color(0.98f, 0.78f, 0.66f)),
|
||||
(0.00f, new Color(0.14f, 0.17f, 0.26f)),
|
||||
(0.25f, new Color(1.00f, 0.74f, 0.58f)),
|
||||
(0.50f, new Color(1.00f, 1.00f, 1.00f)),
|
||||
(0.75f, new Color(0.98f, 0.76f, 0.64f)),
|
||||
(1.00f, new Color(0.10f, 0.11f, 0.18f)));
|
||||
(0.75f, new Color(1.00f, 0.72f, 0.55f)),
|
||||
(1.00f, new Color(0.14f, 0.17f, 0.26f)));
|
||||
|
||||
cloudShadowColorOverDay = BuildGradient(
|
||||
(0.00f, new Color(0.05f, 0.06f, 0.10f)),
|
||||
(0.25f, new Color(0.55f, 0.45f, 0.50f)),
|
||||
(0.50f, new Color(0.70f, 0.76f, 0.84f)),
|
||||
(0.75f, new Color(0.56f, 0.44f, 0.48f)),
|
||||
(1.00f, new Color(0.05f, 0.06f, 0.10f)));
|
||||
(0.00f, new Color(0.06f, 0.08f, 0.16f)),
|
||||
(0.25f, new Color(0.60f, 0.45f, 0.52f)),
|
||||
(0.50f, new Color(0.58f, 0.65f, 0.82f)),
|
||||
(0.75f, new Color(0.58f, 0.43f, 0.50f)),
|
||||
(1.00f, new Color(0.06f, 0.08f, 0.16f)));
|
||||
|
||||
sunIntensityOverDay = new AnimationCurve(
|
||||
new Keyframe(0.00f, 0f), new Keyframe(0.23f, 0.05f), new Keyframe(0.30f, 0.8f),
|
||||
new Keyframe(0.50f, 1f), new Keyframe(0.70f, 0.8f), new Keyframe(0.78f, 0.05f),
|
||||
new Keyframe(1.00f, 0f));
|
||||
|
||||
cloudCoverageOverDay = AnimationCurve.Constant(0f, 1f, 0.55f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a Gradient from (time, color) tuples with full alpha, keeping color authoring
|
||||
/// in one compact place. Alpha is unused by the sky (every color is opaque).
|
||||
/// Builds a Gradient from (time, color) tuples with full alpha, keeping color
|
||||
/// authoring in one compact place. Alpha is unused by the sky (every color opaque).
|
||||
/// </summary>
|
||||
private static Gradient BuildGradient(params (float time, Color color)[] stops)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,11 @@ namespace Ashwild.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Short label describing the action, shown under the crosshair while the
|
||||
/// player is aiming at this target (e.g. "Pick up Wood", "Open").
|
||||
/// player is aiming at this target (e.g. "Pick up Wood", "Open"). Returning
|
||||
/// null or empty means "no interaction offered right now" — PlayerInteractor
|
||||
/// skips such targets entirely (no hover, no Interact call), so a component may
|
||||
/// implement IInteractable yet stay inert until it opts in (e.g. a Harvestable
|
||||
/// that is only gatherable by hand when its bare-hand flag is set).
|
||||
/// </summary>
|
||||
string InteractionPrompt { get; }
|
||||
|
||||
|
||||
@@ -3,111 +3,225 @@ using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps the item in the player's hand in sync with the selected hotbar slot. It owns the *timing*
|
||||
/// of a swap, not its look: the put-away and draw animations live in the arms rig, and this
|
||||
/// controller simply waits for them.
|
||||
///
|
||||
/// The wait is what makes the swap read correctly. Destroying the old prefab the instant the
|
||||
/// selection changes makes the item vanish mid-motion, so instead the sequence is: ask for the
|
||||
/// put-away animation, wait for its Animation Event, then destroy, announce the new item (which
|
||||
/// lets the animation binder load its clips first), spawn it, and ask for the draw animation.
|
||||
///
|
||||
/// That wait is guarded by a timeout, because the completion event only exists if someone plays the
|
||||
/// clip. An item whose set has no put-away clip, a rig that was never wired, a disabled arms
|
||||
/// object — any of those would otherwise leave the player stuck holding an item he already
|
||||
/// switched away from, with no error to explain it. The timeout turns a silent deadlock into a
|
||||
/// slightly abrupt swap.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class HotbarController : MonoBehaviour
|
||||
{
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private Transform toolHolder;
|
||||
[SerializeField] private PlayerToolHolder toolHolderAnim;
|
||||
[Tooltip("Attachment joint in the arms rig's right hand (RightHand_Holder_JTN). Held prefabs are parented here so they follow the animated hand.")]
|
||||
[SerializeField] private Transform handHolder;
|
||||
[Tooltip("Origin and forward of the aim ray handed to held items — normally the first-person camera.")]
|
||||
[SerializeField] private Transform raycastOrigin;
|
||||
|
||||
[Header("Swap")]
|
||||
[Tooltip("Seconds to wait for the put-away animation before swapping anyway. Safety net only — a wired rig always completes first.")]
|
||||
[SerializeField] private float stowTimeout = 0.5f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private PlayerInventory inventory;
|
||||
private GameObject currentHeldObject;
|
||||
private ItemData currentHeldItem;
|
||||
private bool isSwitching;
|
||||
private HeldItemContext heldContext;
|
||||
|
||||
private GameObject currentHeldObject;
|
||||
private ItemData currentHeldItem;
|
||||
|
||||
private bool isStowing;
|
||||
private ItemData pendingItem;
|
||||
private float stowDeadline;
|
||||
private bool stowTimeoutReported;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Binds to the local inventory and draws whatever is already selected, without animation —
|
||||
/// spawning into the world should not look like the player just swapped weapons.
|
||||
/// </summary>
|
||||
private void Start()
|
||||
{
|
||||
inventory = PlayerInventory.Instance;
|
||||
inventory.onSelectedSlotChanged.AddListener(OnSelectionChanged);
|
||||
inventory.onSlotChanged.AddListener(OnSlotChanged);
|
||||
if (inventory == null)
|
||||
{
|
||||
Debug.LogError("[HotbarController] No local PlayerInventory — the hand will stay empty.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
inventory.onSelectedSlotChanged.AddListener(HandleSelectionChanged);
|
||||
inventory.onSlotChanged.AddListener(HandleSlotChanged);
|
||||
|
||||
heldContext = new HeldItemContext { RaycastOrigin = raycastOrigin };
|
||||
UpdateHeldItem(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Listens for the put-away animation finishing. Paired with OnDisable.
|
||||
/// </summary>
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayerEvents.UnequipAnimComplete += HandleUnequipAnimComplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes — mirrors OnEnable exactly.
|
||||
/// </summary>
|
||||
private void OnDisable()
|
||||
{
|
||||
PlayerEvents.UnequipAnimComplete -= HandleUnequipAnimComplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the inventory listeners; the bus subscription is already handled by OnDisable.
|
||||
/// </summary>
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (inventory != null)
|
||||
if (inventory == null) return;
|
||||
inventory.onSelectedSlotChanged.RemoveListener(HandleSelectionChanged);
|
||||
inventory.onSlotChanged.RemoveListener(HandleSlotChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces the swap through when the put-away animation never reports back. Runs only while a
|
||||
/// swap is actually pending, so an idle player costs nothing. The diagnostic is logged once per
|
||||
/// session rather than per swap: the cause is always a wiring or authoring gap that will repeat
|
||||
/// on every single hotbar change, and a console flooded with the same warning hides the rest.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
if (!isStowing) return;
|
||||
if (Time.time < stowDeadline) return;
|
||||
|
||||
if (!stowTimeoutReported)
|
||||
{
|
||||
inventory.onSelectedSlotChanged.RemoveListener(OnSelectionChanged);
|
||||
inventory.onSlotChanged.RemoveListener(OnSlotChanged);
|
||||
stowTimeoutReported = true;
|
||||
Debug.LogWarning("[HotbarController] The put-away animation never completed — swapping anyway " +
|
||||
"(logged once). Check that the arms rig has an ArmsAnimationEvents component and " +
|
||||
"that the Unequip clip carries an AnimUnequipComplete event. Until those clips " +
|
||||
"exist, lower Stow Timeout so the swap stays snappy.", this);
|
||||
}
|
||||
|
||||
CommitSwap();
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(int index)
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private void HandleSelectionChanged(int index) => UpdateHeldItem(true);
|
||||
|
||||
/// <summary>
|
||||
/// The held item's own stack changed (it was consumed, worn out, or refilled) — redraw only when
|
||||
/// it is the selected slot.
|
||||
/// </summary>
|
||||
private void HandleSlotChanged(int index)
|
||||
{
|
||||
UpdateHeldItem(true);
|
||||
if (index == inventory.SelectedHotbarIndex) UpdateHeldItem(true);
|
||||
}
|
||||
|
||||
private void OnSlotChanged(int index)
|
||||
/// <summary>
|
||||
/// The arms finished putting the old item away, so the swap can complete.
|
||||
/// </summary>
|
||||
private void HandleUnequipAnimComplete()
|
||||
{
|
||||
if (index == inventory.SelectedHotbarIndex)
|
||||
UpdateHeldItem(true);
|
||||
if (isStowing) CommitSwap();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Swap Sequence
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for every reason the hand might need to change. Swaps straight away when there is
|
||||
/// nothing to put away (empty hands, or the very first draw), otherwise starts the put-away
|
||||
/// animation and defers. A selection that changes again mid-stow only updates the target: the
|
||||
/// player who scrolls three slots quickly plays one put-away, not three.
|
||||
/// </summary>
|
||||
private void UpdateHeldItem(bool animate)
|
||||
{
|
||||
InventorySlot selected = inventory.GetSelectedSlot();
|
||||
ItemData newItem = selected.IsEmpty ? null : selected.ItemData;
|
||||
|
||||
if (newItem == currentHeldItem)
|
||||
if (newItem == currentHeldItem && !isStowing) return;
|
||||
|
||||
pendingItem = newItem;
|
||||
|
||||
if (isStowing) return;
|
||||
|
||||
if (!animate || currentHeldObject == null)
|
||||
{
|
||||
CommitSwap();
|
||||
return;
|
||||
|
||||
if (isSwitching)
|
||||
{
|
||||
if (currentHeldObject != null)
|
||||
{
|
||||
Destroy(currentHeldObject);
|
||||
currentHeldObject = null;
|
||||
}
|
||||
isSwitching = false;
|
||||
}
|
||||
|
||||
if (animate && currentHeldObject != null && toolHolderAnim != null)
|
||||
{
|
||||
isSwitching = true;
|
||||
toolHolderAnim.PlayUnequipAnimation(() =>
|
||||
{
|
||||
if (currentHeldObject != null)
|
||||
{
|
||||
Destroy(currentHeldObject);
|
||||
currentHeldObject = null;
|
||||
}
|
||||
currentHeldItem = newItem;
|
||||
SpawnItem(newItem);
|
||||
isSwitching = false;
|
||||
|
||||
if (currentHeldObject != null)
|
||||
toolHolderAnim.PlayEquipAnimation();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (currentHeldObject != null)
|
||||
{
|
||||
Destroy(currentHeldObject);
|
||||
currentHeldObject = null;
|
||||
}
|
||||
currentHeldItem = newItem;
|
||||
SpawnItem(newItem);
|
||||
|
||||
if (animate && toolHolderAnim != null && currentHeldObject != null)
|
||||
toolHolderAnim.PlayEquipAnimation();
|
||||
}
|
||||
isStowing = true;
|
||||
stowDeadline = Time.time + stowTimeout;
|
||||
PlayerEvents.RaiseUnequipStarted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the actual exchange. HeldItemChanged is raised *before* the new prefab is spawned so
|
||||
/// the animation binder has already loaded the item's clips by the time the draw trigger fires —
|
||||
/// otherwise the first frame of the draw would play the previous item's animation.
|
||||
/// </summary>
|
||||
private void CommitSwap()
|
||||
{
|
||||
isStowing = false;
|
||||
|
||||
if (currentHeldObject != null)
|
||||
{
|
||||
Destroy(currentHeldObject);
|
||||
currentHeldObject = null;
|
||||
}
|
||||
|
||||
currentHeldItem = pendingItem;
|
||||
PlayerEvents.RaiseHeldItemChanged(currentHeldItem);
|
||||
|
||||
SpawnItem(currentHeldItem);
|
||||
|
||||
if (currentHeldItem != null) PlayerEvents.RaiseEquipStarted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates the item's hand prefab on the rig's hand joint and links its behaviour to the
|
||||
/// player. Parenting to the joint rather than to a free-floating holder is what keeps a tool
|
||||
/// locked in the palm through every animation, with no code following the hand each frame.
|
||||
/// </summary>
|
||||
private void SpawnItem(ItemData item)
|
||||
{
|
||||
if (item == null || item.HandPrefab == null || toolHolder == null)
|
||||
if (item == null || item.HandPrefab == null) return;
|
||||
|
||||
if (handHolder == null)
|
||||
{
|
||||
Debug.LogError($"[HotbarController] No hand holder assigned — '{item.ItemName}' cannot be drawn. " +
|
||||
"Wire the arms rig's RightHand_Holder_JTN.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
currentHeldObject = Instantiate(item.HandPrefab, toolHolder);
|
||||
currentHeldObject = Instantiate(item.HandPrefab, handHolder);
|
||||
|
||||
// Link the freshly spawned held item to the player so its behaviour
|
||||
// (tool, consumable, ...) can interact — only the equipped item is alive.
|
||||
IHeldItemBehaviour behaviour = currentHeldObject.GetComponentInChildren<IHeldItemBehaviour>(true);
|
||||
if (behaviour != null)
|
||||
behaviour.Setup(heldContext, item);
|
||||
behaviour?.Setup(heldContext, item);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// The tabs of the inventory window, used to address a category by name instead of by its position
|
||||
/// in an inspector array — so code that opens the window on a given tab (a crafting station opening
|
||||
/// straight on the craft panel) can never be silently broken by a reorder.
|
||||
/// </summary>
|
||||
public enum InventoryCategory
|
||||
{
|
||||
Inventory = 0,
|
||||
Crafting = 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e1721783444e304d92ede84fdb40b7a
|
||||
@@ -16,13 +16,16 @@ namespace Ashwild.Inventory
|
||||
[SerializeField] private Ease unfillEase = Ease.InQuad;
|
||||
|
||||
private Tweener fillTween;
|
||||
private int categoryIndex;
|
||||
private InventoryCategory category;
|
||||
|
||||
public void Initialize(int index, InventoryCategoryManager manager)
|
||||
/// <summary>
|
||||
/// Binds this button to the category it selects on the strip manager.
|
||||
/// </summary>
|
||||
public void Initialize(InventoryCategory category, InventoryCategoryManager manager)
|
||||
{
|
||||
categoryIndex = index;
|
||||
this.category = category;
|
||||
fillImage.fillAmount = 0f;
|
||||
button.onClick.AddListener(() => manager.SelectCategory(categoryIndex));
|
||||
button.onClick.AddListener(() => manager.SelectCategory(this.category));
|
||||
}
|
||||
|
||||
public void Select()
|
||||
|
||||
@@ -1,41 +1,96 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns the tab strip of the inventory window: which category is selected, which panel is shown, and
|
||||
/// the button fill that goes with it. Categories are addressed by <see cref="InventoryCategory"/>
|
||||
/// rather than by array position, so code that opens the window on a given tab (a crafting station
|
||||
/// opening straight on the craft panel) stays correct if the strip is reordered.
|
||||
/// </summary>
|
||||
public class InventoryCategoryManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private InventoryCategoryButton[] categoryButtons;
|
||||
[SerializeField] private GameObject[] panels;
|
||||
[SerializeField] private int defaultCategoryIndex;
|
||||
#region Types
|
||||
|
||||
/// <summary>
|
||||
/// One tab: the category it stands for, its strip button and the panel it shows. Keeping the
|
||||
/// three together in one entry removes the parallel-array class of bug, where a button and a
|
||||
/// panel silently drift out of alignment.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
private class CategoryEntry
|
||||
{
|
||||
public InventoryCategory category;
|
||||
public InventoryCategoryButton button;
|
||||
public GameObject panel;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("Tabs")]
|
||||
[SerializeField] private CategoryEntry[] categories;
|
||||
|
||||
[Header("Default")]
|
||||
[Tooltip("The tab the window lands on when opened without an explicit category.")]
|
||||
[SerializeField] private InventoryCategory defaultCategory = InventoryCategory.Inventory;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private int currentIndex = -1;
|
||||
private bool locked;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
for (int i = 0; i < categoryButtons.Length; i++)
|
||||
{
|
||||
categoryButtons[i].Initialize(i, this);
|
||||
categoryButtons[i].SetFillImmediate(false);
|
||||
}
|
||||
#endregion
|
||||
|
||||
for (int i = 0; i < panels.Length; i++)
|
||||
panels[i].SetActive(false);
|
||||
}
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Opens the category strip on the inventory category every time — the window must always land on
|
||||
/// the inventory, never stay on craft from a previous session (and chest mode needs the grid).
|
||||
/// Wires each button to its category and parks every panel closed.
|
||||
/// </summary>
|
||||
public void Open()
|
||||
private void Awake()
|
||||
{
|
||||
SelectCategoryImmediate(defaultCategoryIndex);
|
||||
for (int i = 0; i < categories.Length; i++)
|
||||
{
|
||||
CategoryEntry entry = categories[i];
|
||||
if (entry.button != null)
|
||||
{
|
||||
entry.button.Initialize(entry.category, this);
|
||||
entry.button.SetFillImmediate(false);
|
||||
}
|
||||
|
||||
if (entry.panel != null)
|
||||
entry.panel.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Opens the strip on the default category — the window must always land on the inventory, never
|
||||
/// stay on craft from a previous session (and chest mode needs the grid).
|
||||
/// </summary>
|
||||
public void Open() => Open(defaultCategory);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the strip directly on the given category, bypassing the lock (this is the window
|
||||
/// deciding where it lands, not the player switching tabs).
|
||||
/// </summary>
|
||||
public void Open(InventoryCategory category) => SelectCategoryImmediate(IndexOf(category));
|
||||
|
||||
/// <summary>
|
||||
/// Closes every panel.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
for (int i = 0; i < panels.Length; i++)
|
||||
panels[i].SetActive(false);
|
||||
for (int i = 0; i < categories.Length; i++)
|
||||
if (categories[i].panel != null)
|
||||
categories[i].panel.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,38 +99,63 @@ namespace Ashwild.Inventory
|
||||
/// </summary>
|
||||
public void SetLocked(bool value) => locked = value;
|
||||
|
||||
public void SelectCategory(int index)
|
||||
/// <summary>
|
||||
/// Switches to a category on the player's request (a strip button), animating the fill. Ignored
|
||||
/// while locked or when the category is already shown.
|
||||
/// </summary>
|
||||
public void SelectCategory(InventoryCategory category)
|
||||
{
|
||||
if (locked) return;
|
||||
if (index == currentIndex) return;
|
||||
if (index < 0 || index >= panels.Length) return;
|
||||
|
||||
// Deselect old
|
||||
if (currentIndex >= 0 && currentIndex < categoryButtons.Length)
|
||||
int index = IndexOf(category);
|
||||
if (index < 0 || index == currentIndex) return;
|
||||
|
||||
if (currentIndex >= 0)
|
||||
{
|
||||
categoryButtons[currentIndex].Deselect();
|
||||
panels[currentIndex].SetActive(false);
|
||||
if (categories[currentIndex].button != null) categories[currentIndex].button.Deselect();
|
||||
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(false);
|
||||
}
|
||||
|
||||
// Select new
|
||||
currentIndex = index;
|
||||
categoryButtons[currentIndex].Select();
|
||||
panels[currentIndex].SetActive(true);
|
||||
if (categories[currentIndex].button != null) categories[currentIndex].button.Select();
|
||||
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Selects a tab without the fill animation, for the frame the window opens on.
|
||||
/// </summary>
|
||||
private void SelectCategoryImmediate(int index)
|
||||
{
|
||||
if (index < 0 || index >= panels.Length) return;
|
||||
if (index < 0 || index >= categories.Length) return;
|
||||
|
||||
if (currentIndex >= 0 && currentIndex < categoryButtons.Length)
|
||||
if (currentIndex >= 0)
|
||||
{
|
||||
categoryButtons[currentIndex].SetFillImmediate(false);
|
||||
panels[currentIndex].SetActive(false);
|
||||
if (categories[currentIndex].button != null) categories[currentIndex].button.SetFillImmediate(false);
|
||||
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(false);
|
||||
}
|
||||
|
||||
currentIndex = index;
|
||||
categoryButtons[currentIndex].SetFillImmediate(true);
|
||||
panels[currentIndex].SetActive(true);
|
||||
if (categories[currentIndex].button != null) categories[currentIndex].button.SetFillImmediate(true);
|
||||
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position of a category in the strip, or -1 when it was never wired — logged so a missing tab
|
||||
/// is obvious instead of silently doing nothing.
|
||||
/// </summary>
|
||||
private int IndexOf(InventoryCategory category)
|
||||
{
|
||||
for (int i = 0; i < categories.Length; i++)
|
||||
if (categories[i].category == category) return i;
|
||||
|
||||
Debug.LogError($"[InventoryCategoryManager] No tab wired for category '{category}'.", this);
|
||||
return -1;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,13 @@ namespace Ashwild.Inventory
|
||||
/// </summary>
|
||||
private Chest boundChest;
|
||||
|
||||
/// <summary>
|
||||
/// The tab the next open must land on, or null for the default. Set by whoever opens the window
|
||||
/// on a specific module (a crafting station wanting the craft tab) and consumed by Show(), so it
|
||||
/// applies to exactly one opening and never leaks into the next.
|
||||
/// </summary>
|
||||
private InventoryCategory? pendingCategory;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
@@ -175,10 +182,24 @@ namespace Ashwild.Inventory
|
||||
UIManager.Instance.OpenPanel(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the window on a specific tab — used by a crafting station, which wants the craft panel
|
||||
/// straight away rather than making the player click across. The tab is picked up in Show().
|
||||
/// </summary>
|
||||
public void OpenAtCategory(InventoryCategory category)
|
||||
{
|
||||
pendingCategory = category;
|
||||
|
||||
if (UIManager.Instance != null)
|
||||
UIManager.Instance.OpenPanel(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the inventory window. Enters chest mode when a chest was bound (right side shows the
|
||||
/// chest module, category is forced to and locked on the inventory), otherwise normal browsing
|
||||
/// (right side shows the description). Always lands on the inventory category, never craft.
|
||||
/// (right side shows the description). Lands on the tab a caller requested, or on the default
|
||||
/// (inventory) otherwise — a plain open never stays on craft from a previous session, and chest
|
||||
/// mode always wins since it needs the grid.
|
||||
/// </summary>
|
||||
public override void Show()
|
||||
{
|
||||
@@ -187,10 +208,14 @@ namespace Ashwild.Inventory
|
||||
|
||||
if (categoryManager != null)
|
||||
{
|
||||
categoryManager.Open();
|
||||
if (!chestMode && pendingCategory.HasValue) categoryManager.Open(pendingCategory.Value);
|
||||
else categoryManager.Open();
|
||||
|
||||
categoryManager.SetLocked(chestMode);
|
||||
}
|
||||
|
||||
pendingCategory = null;
|
||||
|
||||
if (chestMode)
|
||||
{
|
||||
if (descriptionModule != null) descriptionModule.SetActive(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
@@ -59,6 +60,12 @@ namespace Ashwild.Inventory
|
||||
[SerializeField] private GameObject worldPrefab;
|
||||
[SerializeField] private GameObject handPrefab;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Clips the owner's first-person arms play while holding this item. Empty = bare-hand set.")]
|
||||
[SerializeField] private PlayerAnimationSet armsAnimationSet;
|
||||
[Tooltip("Clips the third-person body plays while holding this item, as seen by other players.")]
|
||||
[SerializeField] private PlayerAnimationSet bodyAnimationSet;
|
||||
|
||||
public string ItemName => itemName;
|
||||
public Sprite Icon => icon;
|
||||
public string Description => description;
|
||||
@@ -92,5 +99,16 @@ namespace Ashwild.Inventory
|
||||
public float FuelSeconds => fuelSeconds;
|
||||
public GameObject WorldPrefab => worldPrefab;
|
||||
public GameObject HandPrefab => handPrefab;
|
||||
|
||||
/// <summary>
|
||||
/// The clip set this item imposes on one of the player's two rigs. Kept as a lookup rather than
|
||||
/// two public properties so a binder can be pointed at either rig from the inspector and stay
|
||||
/// the same component — the arms and the body run identical code on different data.
|
||||
/// Returns null when the item authors nothing, which makes the binder fall back to bare hands.
|
||||
/// </summary>
|
||||
public PlayerAnimationSet GetAnimationSet(PlayerAnimationRig rig)
|
||||
{
|
||||
return rig == PlayerAnimationRig.Arms ? armsAnimationSet : bodyAnimationSet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using FishNet.Connection;
|
||||
@@ -47,6 +48,18 @@ namespace Ashwild.Inventory
|
||||
private InventorySlot[] slots;
|
||||
private int selectedHotbarIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Scratch copy of the slots used by the capacity dry run, kept as a field so a per-swing
|
||||
/// capacity check does not allocate. Never holds meaningful state between calls.
|
||||
/// </summary>
|
||||
private SlotContent[] fitSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// One-element buffer so the single-item <see cref="CanFit"/> can reuse the multi-item dry run
|
||||
/// without allocating an array on every call.
|
||||
/// </summary>
|
||||
private readonly SlotContent[] singleFitBuffer = new SlotContent[1];
|
||||
|
||||
public int InventorySize => inventorySize;
|
||||
public int HotbarSize => hotbarSize;
|
||||
public int SelectedHotbarIndex => selectedHotbarIndex;
|
||||
@@ -156,6 +169,12 @@ namespace Ashwild.Inventory
|
||||
/// <summary>
|
||||
/// Runs on the owning client: resolves the granted item and adds it locally, restoring its
|
||||
/// remaining uses and honouring the slot the player aimed at when one was requested.
|
||||
///
|
||||
/// A grant has already left its source by the time it arrives, so anything that does not fit must
|
||||
/// not simply evaporate the way it used to. The interactions that can be refused pre-check their
|
||||
/// capacity before asking the server, which makes an overflow here a last-resort case (the
|
||||
/// inventory filled up while the request was in flight); it is put back into the world at the
|
||||
/// player's feet, announced, and logged — never destroyed.
|
||||
/// </summary>
|
||||
[TargetRpc]
|
||||
private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses, int preferredIndex, bool isTransfer)
|
||||
@@ -167,7 +186,13 @@ namespace Ashwild.Inventory
|
||||
return;
|
||||
}
|
||||
|
||||
AddItem(item, quantity, uses, preferredIndex, isTransfer);
|
||||
AddItem(item, quantity, uses, preferredIndex, isTransfer, out int leftover);
|
||||
if (leftover <= 0) return;
|
||||
|
||||
Debug.LogWarning($"[PlayerInventory] Inventory full — {leftover}x '{item.ItemName}' could not be " +
|
||||
"stored and was returned to the world.", this);
|
||||
PlayerEvents.RaiseInteractionRefused("Inventory full");
|
||||
SpawnInWorld(item, leftover, uses);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -269,7 +294,19 @@ namespace Ashwild.Inventory
|
||||
/// pulling an item a co-op partner left in a chest is a genuine first acquisition.
|
||||
/// </summary>
|
||||
public bool AddItem(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
|
||||
=> AddItem(item, quantity, uses, preferredIndex, isTransfer, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="AddItem(ItemData,int,int,int,bool)"/> but reports how many units could not be
|
||||
/// placed. Callers that received the stack from the server need that number: whatever is left over
|
||||
/// has already left its source (the harvestable is damaged, the pickup is claimed) and would simply
|
||||
/// cease to exist if it were dropped on the floor here, which is exactly how loot used to go
|
||||
/// missing without a single log line.
|
||||
/// </summary>
|
||||
public bool AddItem(ItemData item, int quantity, int uses, int preferredIndex, bool isTransfer, out int leftover)
|
||||
{
|
||||
leftover = quantity;
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
Debug.LogError("[PlayerInventory] AddItem called with no ItemData — pickup ignored.", this);
|
||||
@@ -287,7 +324,9 @@ namespace Ashwild.Inventory
|
||||
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
|
||||
if (slots[i].IsEmpty) StackInto(i, ref incoming);
|
||||
|
||||
int added = quantity - (incoming.IsEmpty ? 0 : incoming.Quantity);
|
||||
leftover = incoming.IsEmpty ? 0 : incoming.Quantity;
|
||||
|
||||
int added = quantity - leftover;
|
||||
if (added > 0)
|
||||
{
|
||||
onItemAdded?.Invoke(item, added);
|
||||
@@ -317,23 +356,43 @@ namespace Ashwild.Inventory
|
||||
{
|
||||
if (item == null) return false;
|
||||
|
||||
SlotContent incoming = SlotContent.Of(item, quantity, -1);
|
||||
singleFitBuffer[0] = SlotContent.Of(item, quantity, -1);
|
||||
return CanFitAll(singleFitBuffer);
|
||||
}
|
||||
|
||||
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
|
||||
/// <summary>
|
||||
/// Returns whether several stacks would fit *together*, without mutating anything. Asking
|
||||
/// <see cref="CanFit"/> once per item is not equivalent and quietly over-promises: with a single
|
||||
/// empty slot left, two different items each answer "yes" on their own, then the second one has
|
||||
/// nowhere to go. Harvest loot rolls several items at once, so it needs the combined answer — the
|
||||
/// dry run therefore places each stack into a running snapshot of the slots, exactly as the real
|
||||
/// add would, and fails as soon as one leftover cannot be placed.
|
||||
/// </summary>
|
||||
public bool CanFitAll(IReadOnlyList<SlotContent> incoming)
|
||||
{
|
||||
if (incoming == null || incoming.Count == 0) return true;
|
||||
|
||||
if (fitSnapshot == null || fitSnapshot.Length != inventorySize)
|
||||
fitSnapshot = new SlotContent[inventorySize];
|
||||
|
||||
for (int i = 0; i < inventorySize; i++)
|
||||
fitSnapshot[i] = ReadSlot(i);
|
||||
|
||||
for (int n = 0; n < incoming.Count; n++)
|
||||
{
|
||||
if (slots[i].IsEmpty) continue;
|
||||
SlotContent target = ReadSlot(i);
|
||||
SlotTransfer.TryStack(ref incoming, ref target);
|
||||
SlotContent pending = incoming[n];
|
||||
if (pending.IsEmpty) continue;
|
||||
|
||||
for (int i = 0; i < inventorySize && !pending.IsEmpty; i++)
|
||||
if (!fitSnapshot[i].IsEmpty) SlotTransfer.TryStack(ref pending, ref fitSnapshot[i]);
|
||||
|
||||
for (int i = 0; i < inventorySize && !pending.IsEmpty; i++)
|
||||
if (fitSnapshot[i].IsEmpty) SlotTransfer.TryStack(ref pending, ref fitSnapshot[i]);
|
||||
|
||||
if (!pending.IsEmpty) return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
|
||||
{
|
||||
if (!slots[i].IsEmpty) continue;
|
||||
SlotContent target = SlotContent.Empty;
|
||||
SlotTransfer.TryStack(ref incoming, ref target);
|
||||
}
|
||||
|
||||
return incoming.IsEmpty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RemoveItem(int index, int quantity = 1)
|
||||
@@ -408,21 +467,36 @@ namespace Ashwild.Inventory
|
||||
|
||||
ItemData item = slots[index].ItemData;
|
||||
int uses = item.HasUses ? slots[index].CurrentUses : -1;
|
||||
Transform origin = dropOrigin != null ? dropOrigin : transform;
|
||||
if (item.WorldPrefab != null && origin != null && PickableRegistry.Instance != null)
|
||||
{
|
||||
Vector3 dropPos = origin.position + origin.forward * dropForwardDistance;
|
||||
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
|
||||
if (id != 0)
|
||||
PickableRegistry.Instance.RequestDropServerRpc(id, quantity, uses, dropPos, origin.rotation);
|
||||
else
|
||||
Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this);
|
||||
}
|
||||
|
||||
SpawnInWorld(item, quantity, uses);
|
||||
|
||||
RemoveItem(index, quantity);
|
||||
PlayerEvents.RaiseItemDropped(item, quantity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the server to spawn a stack in front of the player, without touching the inventory. Shared
|
||||
/// by the deliberate drop (which removes the stack first) and by the overflow safety net (whose
|
||||
/// stack was never stored), so both go through the same authoritative drop path.
|
||||
/// </summary>
|
||||
private void SpawnInWorld(ItemData item, int quantity, int uses)
|
||||
{
|
||||
if (item == null || quantity <= 0) return;
|
||||
|
||||
Transform origin = dropOrigin != null ? dropOrigin : transform;
|
||||
if (item.WorldPrefab == null || origin == null || PickableRegistry.Instance == null) return;
|
||||
|
||||
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
|
||||
if (id == 0)
|
||||
{
|
||||
Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 dropPos = origin.position + origin.forward * dropForwardDistance;
|
||||
PickableRegistry.Instance.RequestDropServerRpc(id, quantity, uses, dropPos, origin.rotation);
|
||||
}
|
||||
|
||||
public void SelectHotbarSlot(int index)
|
||||
{
|
||||
if (index < 0 || index >= hotbarSize) return;
|
||||
|
||||
@@ -60,29 +60,43 @@ namespace Ashwild.Network
|
||||
/// <summary>
|
||||
/// Owner-side entry: tells the server the local player hit a harvestable. The server applies
|
||||
/// the (client-computed) damage, grants loot, broadcasts feedback, and handles depletion.
|
||||
///
|
||||
/// The two ways this can go wrong are reported back to the requesting player instead of being
|
||||
/// swallowed: an id the server does not track (a scene object with a missing or duplicate id) and
|
||||
/// a connection whose inventory cannot be resolved. Both used to end in a bare `return` — the
|
||||
/// player saw their hit land and gained nothing, with no trace anywhere on their machine. Losing
|
||||
/// the race against another player is the one case left unreported, since the object disappearing
|
||||
/// says it plainly enough.
|
||||
/// </summary>
|
||||
[ServerRpc(RequireOwnership = false)]
|
||||
public void RequestHitServerRpc(int id, float damage, ushort toolItemId, Vector3 hitDirection, NetworkConnection conn = null)
|
||||
public void RequestHitServerRpc(int id, float damage, ushort toolItemId, NetworkConnection conn = null)
|
||||
{
|
||||
if (IsInactive(id)) return;
|
||||
if (!TryGetObject(id, out WorldObject obj) || obj is not Harvestable harvestable) return;
|
||||
|
||||
if (!TryGetObject(id, out WorldObject obj) || obj is not Harvestable harvestable)
|
||||
{
|
||||
ReportFailure(conn, id, "no harvestable is registered with this id on the server");
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerInventory inventory = ResolveInventory(conn);
|
||||
if (inventory == null)
|
||||
{
|
||||
ReportFailure(conn, id, "the requesting player's inventory could not be resolved — the loot would be lost");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!health.TryGetValue(id, out float hp)) hp = harvestable.MaxHealth;
|
||||
hp -= damage;
|
||||
health[id] = hp;
|
||||
|
||||
// Grant the rolled loot to the hitting player.
|
||||
ItemData tool = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(toolItemId) : null;
|
||||
PlayerInventory inventory = ResolveInventory(conn);
|
||||
if (inventory != null)
|
||||
{
|
||||
foreach ((ItemData item, int quantity) in harvestable.RollDrops(tool))
|
||||
inventory.GrantItemFromServer(item, quantity);
|
||||
}
|
||||
foreach ((ItemData item, int quantity) in harvestable.RollDrops(tool))
|
||||
inventory.GrantItemFromServer(item, quantity);
|
||||
|
||||
// Feedback for everyone except the hitter (who already played it locally for responsiveness).
|
||||
int hitterClientId = conn != null ? conn.ClientId : -1;
|
||||
PlayHitObserversRpc(id, hitDirection, hitterClientId);
|
||||
PlayHitObserversRpc(id, hitterClientId);
|
||||
|
||||
if (hp <= 0f)
|
||||
{
|
||||
@@ -101,11 +115,11 @@ namespace Ashwild.Network
|
||||
/// Plays the hit feedback on every client except the one who threw the hit.
|
||||
/// </summary>
|
||||
[ObserversRpc]
|
||||
private void PlayHitObserversRpc(int id, Vector3 hitDirection, int hitterClientId)
|
||||
private void PlayHitObserversRpc(int id, int hitterClientId)
|
||||
{
|
||||
if (LocalConnection != null && LocalConnection.ClientId == hitterClientId) return;
|
||||
if (TryGetObject(id, out WorldObject obj) && obj is Harvestable harvestable)
|
||||
harvestable.PlayHitEffect(hitDirection);
|
||||
harvestable.PlayHitEffect();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using FishNet.Connection;
|
||||
using FishNet.Object;
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.Network
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-side lookup of the player systems belonging to a connection. Every server-authoritative
|
||||
/// interaction (pickup, harvest, chest, cooking station, build refund) needs the requesting player's
|
||||
/// inventory, and they all used to resolve it through <c>conn.FirstObject</c>.
|
||||
///
|
||||
/// That is unsafe: a connection owns more than one NetworkObject (the build ghost is spawned with the
|
||||
/// player as owner), FishNet stores them in an unordered <c>HashSet</c>, and it re-picks FirstObject
|
||||
/// from that set whenever the current one is despawned. FirstObject can therefore resolve to the ghost
|
||||
/// instead of the player — a null inventory, and a silently lost grant. Scanning the connection's
|
||||
/// objects for the component we actually want removes the guesswork.
|
||||
/// </summary>
|
||||
public static class NetworkPlayerLookup
|
||||
{
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Returns the inventory of the player owned by this connection, or null when the connection has
|
||||
/// no player object (disconnecting, or not spawned yet).
|
||||
/// </summary>
|
||||
public static PlayerInventory ResolveInventory(NetworkConnection conn) => Resolve<PlayerInventory>(conn);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first component of the requested type found on any NetworkObject this connection
|
||||
/// owns. Checks FirstObject before scanning, since it is the right answer in the common case.
|
||||
/// </summary>
|
||||
public static T Resolve<T>(NetworkConnection conn) where T : class
|
||||
{
|
||||
if (conn == null) return null;
|
||||
|
||||
NetworkObject first = conn.FirstObject;
|
||||
if (first != null && first.TryGetComponent(out T fromFirst)) return fromFirst;
|
||||
|
||||
foreach (NetworkObject nob in conn.Objects)
|
||||
{
|
||||
if (nob != null && nob.TryGetComponent(out T component)) return component;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dfd1e7f1d6a424b4aa0492f8c3f4c502
|
||||
@@ -61,6 +61,15 @@ namespace Ashwild.Network
|
||||
/// Asks the registry (server) to claim this pickup for the local player. Aborts while the player
|
||||
/// is building (the hammer's placement/demolition would otherwise let an interact press snatch an
|
||||
/// item mid-build), if already claimed, or if the local inventory is full.
|
||||
///
|
||||
/// A full inventory is announced rather than ignored: silently doing nothing on a press is
|
||||
/// indistinguishable from a broken pickup, and that ambiguity is what hid the real bugs. A scene
|
||||
/// pickup the registry never accepted (missing or duplicate baked id) is refused here too, with
|
||||
/// the reason spelled out — the server would drop the request anyway.
|
||||
///
|
||||
/// The grab animation fires here, once every refusal is behind us but before the server has
|
||||
/// answered: the hand must reach out on the press, not a round-trip later. A refused pickup
|
||||
/// therefore never animates, and a granted one animates immediately.
|
||||
/// </summary>
|
||||
public void Pickup()
|
||||
{
|
||||
@@ -78,10 +87,21 @@ namespace Ashwild.Network
|
||||
}
|
||||
if (PickableRegistry.Instance.IsClaimed(Id)) return;
|
||||
|
||||
if (Id >= 0 && !PickableRegistry.Instance.IsRegistered(Id))
|
||||
{
|
||||
Debug.LogError($"[Pickable] '{name}' (id {Id}) is not tracked by the registry — the server " +
|
||||
"would drop this pickup. Check the console for an id error at startup.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side pre-check so we don't claim something we can't hold.
|
||||
if (PlayerInventory.Instance != null && !PlayerInventory.Instance.CanFit(itemData, quantity))
|
||||
{
|
||||
PlayerEvents.RaiseInteractionRefused("Inventory full");
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerEvents.RaiseGrabPerformed();
|
||||
PickableRegistry.Instance.RequestPickupServerRpc(Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,19 +112,38 @@ namespace Ashwild.Network
|
||||
|
||||
/// <summary>
|
||||
/// Owner-side entry: asks the server to claim a pickup and grant its item.
|
||||
///
|
||||
/// Nothing leaves the world before the grant is known to be possible: the pickup is only marked
|
||||
/// claimed, and a drop record only removed, once the item and the requesting inventory have both
|
||||
/// been resolved. Resolving them afterwards is how a pickup could disappear while granting
|
||||
/// nothing. Anything the client could not have foreseen is reported back to it; losing the race
|
||||
/// to another player is left silent, since the object visibly vanishes.
|
||||
/// </summary>
|
||||
[ServerRpc(RequireOwnership = false)]
|
||||
public void RequestPickupServerRpc(int id, NetworkConnection conn = null)
|
||||
{
|
||||
PlayerInventory inventory = ResolveInventory(conn);
|
||||
if (inventory == null) return;
|
||||
if (inventory == null)
|
||||
{
|
||||
ReportFailure(conn, id, "the requesting player's inventory could not be resolved — the item would be lost");
|
||||
return;
|
||||
}
|
||||
|
||||
if (id >= 0)
|
||||
{
|
||||
// Scene pickup — the server reads the item from its own copy of the object.
|
||||
if (IsInactive(id)) return;
|
||||
if (!TryGetObject(id, out WorldObject obj) || obj is not Pickable pickup) return;
|
||||
if (pickup.ItemData == null) return;
|
||||
|
||||
if (!TryGetObject(id, out WorldObject obj) || obj is not Pickable pickup)
|
||||
{
|
||||
ReportFailure(conn, id, "no pickup is registered with this id on the server");
|
||||
return;
|
||||
}
|
||||
if (pickup.ItemData == null)
|
||||
{
|
||||
ReportFailure(conn, id, $"'{pickup.name}' has no ItemData assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
MarkInactive(id);
|
||||
inventory.GrantItemFromServer(pickup.ItemData, pickup.Quantity);
|
||||
@@ -133,10 +152,16 @@ namespace Ashwild.Network
|
||||
{
|
||||
// Runtime drop — the server reads the item from the synced record.
|
||||
if (!activeDrops.TryGetValue(id, out DropRecord record)) return;
|
||||
|
||||
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(record.itemId) : null;
|
||||
if (item == null)
|
||||
{
|
||||
ReportFailure(conn, id, $"item id {record.itemId} is not in the ItemDatabase — run Rebuild Item Database");
|
||||
return;
|
||||
}
|
||||
|
||||
activeDrops.Remove(id);
|
||||
if (item != null) inventory.GrantItemFromServer(item, record.quantity, record.uses);
|
||||
inventory.GrantItemFromServer(item, record.quantity, record.uses);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Ashwild.Network
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("Identity")]
|
||||
[Tooltip("Baked scene id (>= 0), assigned by Tools ▸ Ashwild ▸ Assign World Object IDs. Runtime objects get a negative id.")]
|
||||
[Tooltip("Baked scene id (>= 0), assigned by Tools ▸ Ashwild ▸ Setup World Object IDs. Runtime objects get a negative id.")]
|
||||
[SerializeField] private int id = -1;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -4,6 +4,7 @@ using FishNet.Object;
|
||||
using FishNet.Object.Synchronizing;
|
||||
using UnityEngine;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.Network
|
||||
{
|
||||
@@ -74,15 +75,44 @@ namespace Ashwild.Network
|
||||
|
||||
/// <summary>
|
||||
/// Registers a world object, hiding it immediately if it is already inactive.
|
||||
///
|
||||
/// Rejects — loudly — an object whose id is unusable, because both failure modes are otherwise
|
||||
/// invisible at runtime and produce the exact same symptom: the interaction plays its local
|
||||
/// feedback and grants nothing. A scene object left at id -1 (the id tool was never run on it, or
|
||||
/// its prefab carries a baked id) would be treated as a runtime drop; two objects sharing an id
|
||||
/// would overwrite each other here, so claiming one silently kills the other for good. The object
|
||||
/// stays unregistered, which is what makes the interaction refuse itself instead of failing later.
|
||||
/// </summary>
|
||||
public void RegisterObject(WorldObject obj)
|
||||
{
|
||||
if (obj == null) return;
|
||||
|
||||
if (obj.Id < 0)
|
||||
{
|
||||
Debug.LogError($"[{GetType().Name}] '{obj.name}' has no baked id (id = {obj.Id}) and cannot be " +
|
||||
"tracked — it will not be interactable. Run Tools ▸ Ashwild ▸ Setup World Object IDs and save the scene.", obj);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registered.TryGetValue(obj.Id, out WorldObject existing) && existing != null && existing != obj)
|
||||
{
|
||||
Debug.LogError($"[{GetType().Name}] Duplicate world object id {obj.Id}: '{obj.name}' collides with " +
|
||||
$"'{existing.name}'. Claiming one would silently disable the other. Run Tools ▸ Ashwild ▸ Setup World Object IDs.", obj);
|
||||
return;
|
||||
}
|
||||
|
||||
registered[obj.Id] = obj;
|
||||
if (inactiveIds.Contains(obj.Id))
|
||||
obj.HideAsInactive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this id is tracked by the registry on this client. Interactions check it before
|
||||
/// asking the server, so an object the registry never accepted (bad or duplicate id) refuses up
|
||||
/// front rather than playing its feedback and losing the request server-side.
|
||||
/// </summary>
|
||||
public bool IsRegistered(int id) => registered.ContainsKey(id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the object with this id has been removed from the world.
|
||||
/// </summary>
|
||||
@@ -114,12 +144,35 @@ namespace Ashwild.Network
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PlayerInventory on the player object owned by the given connection.
|
||||
/// Returns the PlayerInventory owned by the given connection. Delegates to the shared lookup,
|
||||
/// which scans the connection's objects instead of trusting FirstObject (see NetworkPlayerLookup).
|
||||
/// </summary>
|
||||
protected PlayerInventory ResolveInventory(NetworkConnection conn)
|
||||
protected PlayerInventory ResolveInventory(NetworkConnection conn) => NetworkPlayerLookup.ResolveInventory(conn);
|
||||
|
||||
/// <summary>
|
||||
/// Server-side: reports an interaction the server could not carry out for a reason the client had
|
||||
/// no way to foresee (an id it does not track, a player object it cannot resolve). It logs on the
|
||||
/// server and tells the requesting player, because the alternative — the historic behaviour — is a
|
||||
/// bare `return` that leaves the player watching a hit that gave nothing, with the only trace of it
|
||||
/// in the host's console. Normal races (someone else took it first) are not reported here: the
|
||||
/// object visibly disappears, which is explanation enough.
|
||||
/// </summary>
|
||||
protected void ReportFailure(NetworkConnection conn, int id, string reason)
|
||||
{
|
||||
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
|
||||
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
|
||||
Debug.LogWarning($"[{GetType().Name}] Interaction on id {id} refused for client " +
|
||||
$"{(conn != null ? conn.ClientId : -1)}: {reason}", this);
|
||||
TargetReportFailure(conn, id, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs on the requesting client: surfaces the refusal in the HUD and logs it locally, so the
|
||||
/// player who actually experienced it sees the diagnosis in their own console.
|
||||
/// </summary>
|
||||
[TargetRpc]
|
||||
private void TargetReportFailure(NetworkConnection conn, int id, string reason)
|
||||
{
|
||||
Debug.LogError($"[{GetType().Name}] The server refused the interaction on id {id}: {reason}", this);
|
||||
PlayerEvents.RaiseInteractionRefused("Interaction failed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -127,13 +127,24 @@ namespace Ashwild.Player
|
||||
// ============================================================
|
||||
|
||||
public static event Action<IInteractable> InteractableHoverChanged; // null = nothing hovered
|
||||
// A world interaction (pickup, harvest) could not be carried out — payload is a short, player-facing
|
||||
// reason such as "Inventory full". Raised whenever an interaction is refused, so a refusal is never
|
||||
// silent: without it a rejected harvest looks exactly like a successful one that dropped nothing.
|
||||
public static event Action<string> InteractionRefused;
|
||||
// The local player just reached out to grab something off the ground. Raised the moment the
|
||||
// gesture is committed (all checks passed, request sent) rather than when the item lands in the
|
||||
// inventory, so the grab animation plays instantly instead of after a server round-trip.
|
||||
public static event Action GrabPerformed;
|
||||
|
||||
// ============================================================
|
||||
// Tools events
|
||||
// ============================================================
|
||||
|
||||
public static event Action AttackSwung;
|
||||
public static event Action<int> AttackSwung; // the swing started — payload is the combo variant index
|
||||
public static event Action AttackImpact; // the swing's contact frame (Animation Event) — resolve the hit here
|
||||
public static event Action<Harvestable, float> HarvestableHit;
|
||||
public static event Action EquipStarted; // play the draw animation for the item now in hand
|
||||
public static event Action UnequipStarted; // play the put-away animation for the item leaving the hand
|
||||
public static event Action EquipAnimComplete;
|
||||
public static event Action UnequipAnimComplete;
|
||||
|
||||
@@ -299,8 +310,39 @@ namespace Ashwild.Player
|
||||
InteractableHoverChanged?.Invoke(target);
|
||||
}
|
||||
|
||||
public static void RaiseAttackSwung() { Log(nameof(AttackSwung)); AttackSwung?.Invoke(); }
|
||||
/// <summary>
|
||||
/// Announces that a world interaction was refused, with a short player-facing reason. The HUD turns
|
||||
/// it into a notification so the player always learns why nothing was gained.
|
||||
/// </summary>
|
||||
public static void RaiseInteractionRefused(string reason)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason)) return;
|
||||
Log(nameof(InteractionRefused));
|
||||
InteractionRefused?.Invoke(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announces a swing, carrying which combo variant to play. The variant defaults to 0 so a plain
|
||||
/// one-swing tool never has to think about combos, while a weapon can cycle its own variants.
|
||||
/// </summary>
|
||||
public static void RaiseAttackSwung(int variant = 0) { Log(nameof(AttackSwung)); AttackSwung?.Invoke(variant); }
|
||||
|
||||
/// <summary>
|
||||
/// Announces the local player's grab gesture so the arms play their pickup animation. Owner-side
|
||||
/// only, and deliberately not tied to the item actually arriving: the server still has the final
|
||||
/// say on the pickup, but the hand should reach out the instant the player presses.
|
||||
/// </summary>
|
||||
public static void RaiseGrabPerformed() { Log(nameof(GrabPerformed)); GrabPerformed?.Invoke(); }
|
||||
|
||||
/// <summary>
|
||||
/// Announces the contact frame of a swing, raised from the attack clip's Animation Event. Items
|
||||
/// resolve their hit here rather than on the input press so the damage lands when the blade does.
|
||||
/// </summary>
|
||||
public static void RaiseAttackImpact() { Log(nameof(AttackImpact)); AttackImpact?.Invoke(); }
|
||||
|
||||
public static void RaiseHarvestableHit(Harvestable h, float d) { Log(nameof(HarvestableHit)); HarvestableHit?.Invoke(h, d); }
|
||||
public static void RaiseEquipStarted() { Log(nameof(EquipStarted)); EquipStarted?.Invoke(); }
|
||||
public static void RaiseUnequipStarted() { Log(nameof(UnequipStarted)); UnequipStarted?.Invoke(); }
|
||||
public static void RaiseEquipAnimComplete() { EquipAnimComplete?.Invoke(); }
|
||||
public static void RaiseUnequipAnimComplete() { UnequipAnimComplete?.Invoke(); }
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using UnityEngine;
|
||||
using Ashwild.Harvesting;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Network;
|
||||
|
||||
namespace Ashwild.Player
|
||||
{
|
||||
@@ -44,6 +43,13 @@ namespace Ashwild.Player
|
||||
/// </summary>
|
||||
[SerializeField] private float swingCooldown = 0.5f;
|
||||
|
||||
[Header("Impact")]
|
||||
/// <summary>
|
||||
/// Delay after which a swing resolves on its own when the animation never reports its contact
|
||||
/// frame. Safety net for an item whose attack clip carries no AnimAttackImpact event.
|
||||
/// </summary>
|
||||
[SerializeField] private float impactFallbackDelay = 0.2f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
@@ -63,16 +69,27 @@ namespace Ashwild.Player
|
||||
/// </summary>
|
||||
private float nextSwingTime;
|
||||
|
||||
/// <summary>
|
||||
/// True between a swing starting and its hit being resolved.
|
||||
/// </summary>
|
||||
private bool swingPending;
|
||||
|
||||
/// <summary>
|
||||
/// Time at which a pending swing resolves itself without an impact event.
|
||||
/// </summary>
|
||||
private float impactDeadline;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the use input for as long as this item is held.
|
||||
/// Subscribes to the use input and to the animation's contact frame for as long as this item is held.
|
||||
/// </summary>
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayerEvents.AttackPressed += HandleAttackPressed;
|
||||
PlayerEvents.AttackImpact += HandleAttackImpact;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,6 +98,19 @@ namespace Ashwild.Player
|
||||
private void OnDisable()
|
||||
{
|
||||
PlayerEvents.AttackPressed -= HandleAttackPressed;
|
||||
PlayerEvents.AttackImpact -= HandleAttackImpact;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a swing whose animation never announced its contact frame, so an item with no impact
|
||||
/// event still harvests instead of swinging forever without effect. Runs only while a swing is in
|
||||
/// flight.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
if (!swingPending) return;
|
||||
if (Time.time < impactDeadline) return;
|
||||
ResolveSwing();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -101,9 +131,10 @@ namespace Ashwild.Player
|
||||
#region Event Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Swings on use input (gated by lock, setup and cooldown) and harvests the
|
||||
/// aimed target. A depleted but non-destroyed tool (a worn-out repairable axe) is
|
||||
/// unusable: the swing is blocked until it is repaired.
|
||||
/// Starts a swing on use input (gated by lock, setup and cooldown). The hit is deliberately not
|
||||
/// resolved here: it waits for the animation's contact frame, so the tree only loses a chunk once
|
||||
/// the blade has actually reached it. A depleted but non-destroyed tool (a worn-out repairable
|
||||
/// axe) is unusable: the swing is blocked until it is repaired.
|
||||
/// </summary>
|
||||
private void HandleAttackPressed()
|
||||
{
|
||||
@@ -113,8 +144,33 @@ namespace Ashwild.Player
|
||||
if (Time.time < nextSwingTime) return;
|
||||
|
||||
nextSwingTime = Time.time + swingCooldown;
|
||||
swingPending = true;
|
||||
impactDeadline = Time.time + impactFallbackDelay;
|
||||
|
||||
PlayerEvents.RaiseAttackSwung();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The swing animation reached its contact frame. Ignored when no swing of ours is in flight, so
|
||||
/// an impact event belonging to a previous item cannot make a freshly drawn tool hit for free.
|
||||
/// </summary>
|
||||
private void HandleAttackImpact()
|
||||
{
|
||||
if (!swingPending) return;
|
||||
ResolveSwing();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Swing Resolution
|
||||
|
||||
/// <summary>
|
||||
/// Closes an in-flight swing and applies its hit. Clearing the pending flag first makes the swing
|
||||
/// resolve exactly once whether the impact event or the fallback timer got here first.
|
||||
/// </summary>
|
||||
private void ResolveSwing()
|
||||
{
|
||||
swingPending = false;
|
||||
TryHarvest();
|
||||
}
|
||||
|
||||
@@ -124,47 +180,31 @@ namespace Ashwild.Player
|
||||
|
||||
/// <summary>
|
||||
/// Raycasts forward and applies a hit to the harvestable found, or a blocked
|
||||
/// "clunk" when this tool deals no damage to that target type.
|
||||
/// "clunk" when this tool deals no damage to that target type. The hit itself goes through
|
||||
/// Harvestable.ApplyHit (the shared path with bare-hand gathering); the tool is only worn when
|
||||
/// that hit actually connects, never on a swing into the air or a blocked clunk. Triggers are
|
||||
/// included so walkable harvestables (e.g. bushes with a trigger collider) can still be hit.
|
||||
/// </summary>
|
||||
private void TryHarvest()
|
||||
{
|
||||
if (!Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out RaycastHit hit, harvestRange, harvestMask))
|
||||
if (!Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out RaycastHit hit,
|
||||
harvestRange, harvestMask, QueryTriggerInteraction.Collide))
|
||||
return;
|
||||
|
||||
Harvestable harvestable = hit.collider.GetComponentInParent<Harvestable>();
|
||||
if (harvestable == null) return;
|
||||
|
||||
Vector3 hitDirection = raycastOrigin.forward;
|
||||
float damage = CalculateDamage(harvestable.HarvestType);
|
||||
|
||||
// No proper tool match: the hit is blocked — no resources, no damage,
|
||||
// just a small "clunk" so it reads as "you need a tool for this".
|
||||
if (damage <= 0f)
|
||||
{
|
||||
harvestable.PlayBlockedFeedback(hitDirection);
|
||||
harvestable.PlayBlockedFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (HarvestableRegistry.Instance == null)
|
||||
{
|
||||
Debug.LogError("[ToolBehaviour] No HarvestableRegistry in the scene — cannot harvest.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already depleted (e.g. someone else just felled it): ignore.
|
||||
if (HarvestableRegistry.Instance.IsInactive(harvestable.Id)) return;
|
||||
|
||||
// Play the hit feedback locally for instant response; the server replays it to others.
|
||||
harvestable.PlayHitEffect(hitDirection);
|
||||
|
||||
// Server applies the damage authoritatively, grants loot, and handles depletion/respawn.
|
||||
ushort toolId = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
|
||||
HarvestableRegistry.Instance.RequestHitServerRpc(harvestable.Id, damage, toolId, hitDirection);
|
||||
|
||||
PlayerEvents.RaiseHarvestableHit(harvestable, damage);
|
||||
|
||||
// Wear the tool only on a real connecting hit, never on a swing into the air or a blocked clunk.
|
||||
if (PlayerInventory.Instance != null)
|
||||
if (harvestable.ApplyHit(item, damage) && PlayerInventory.Instance != null)
|
||||
PlayerInventory.Instance.ConsumeSelectedToolUse(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,13 +102,7 @@ namespace Ashwild.Player
|
||||
return;
|
||||
}
|
||||
|
||||
IInteractable raw = null;
|
||||
if (raycastOrigin != null
|
||||
&& Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out RaycastHit hit,
|
||||
interactRange, interactMask, QueryTriggerInteraction.Collide))
|
||||
{
|
||||
hit.collider.TryGetComponent(out raw);
|
||||
}
|
||||
IInteractable raw = RaycastInteractable(out _);
|
||||
|
||||
// Restart the stability timer whenever the seen target changes.
|
||||
if (!ReferenceEquals(raw, candidateHover))
|
||||
@@ -152,14 +146,40 @@ namespace Ashwild.Player
|
||||
/// </summary>
|
||||
private void OnInteractPressed()
|
||||
{
|
||||
if (PlayerEvents.InputLocked || raycastOrigin == null) return;
|
||||
if (PlayerEvents.InputLocked) return;
|
||||
|
||||
if (Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out RaycastHit hit,
|
||||
interactRange, interactMask, QueryTriggerInteraction.Collide)
|
||||
&& hit.collider.TryGetComponent(out IInteractable interactable))
|
||||
IInteractable interactable = RaycastInteractable(out _);
|
||||
if (interactable != null) interactable.Interact();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interactable Detection
|
||||
|
||||
/// <summary>
|
||||
/// One raycast forward for an interaction target. Returns the first IInteractable under the aim
|
||||
/// ray that actually offers an interaction — a component whose InteractionPrompt is null/empty is
|
||||
/// treated as inert and skipped, so IInteractables that opt out (e.g. a Harvestable with no
|
||||
/// bare-hand gathering) never register as a hover or a valid interact. Triggers are included so
|
||||
/// walkable interactables (bushes with a trigger collider) still respond.
|
||||
///
|
||||
/// The lookup walks up from the collider (as the tool swing does) rather than reading the collider
|
||||
/// alone: an object whose colliders sit on child meshes is perfectly valid, and matching only the
|
||||
/// exact hit object made such an object silently unusable — no prompt, no interaction, no error.
|
||||
/// </summary>
|
||||
private IInteractable RaycastInteractable(out RaycastHit hit)
|
||||
{
|
||||
hit = default;
|
||||
if (raycastOrigin == null) return null;
|
||||
|
||||
if (Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out hit,
|
||||
interactRange, interactMask, QueryTriggerInteraction.Collide))
|
||||
{
|
||||
interactable.Interact();
|
||||
IInteractable interactable = hit.collider.GetComponentInParent<IInteractable>();
|
||||
if (interactable != null && !string.IsNullOrEmpty(interactable.InteractionPrompt))
|
||||
return interactable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -175,9 +195,7 @@ namespace Ashwild.Player
|
||||
{
|
||||
Transform o = raycastOrigin != null ? raycastOrigin : transform;
|
||||
|
||||
bool hitInteractable = Physics.Raycast(o.position, o.forward, out RaycastHit hit,
|
||||
interactRange, interactMask, QueryTriggerInteraction.Collide)
|
||||
&& hit.collider.TryGetComponent(out IInteractable _);
|
||||
bool hitInteractable = RaycastInteractable(out RaycastHit hit) != null;
|
||||
|
||||
float length = hitInteractable ? hit.distance : interactRange;
|
||||
Vector3 endPoint = o.position + o.forward * length;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6d6c4a9e9b879644a606de6561f30ac
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,254 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.Player
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class PlayerToolHolder : MonoBehaviour
|
||||
{
|
||||
// ============================================================
|
||||
// Tunables
|
||||
// ============================================================
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private Transform toolHolder;
|
||||
|
||||
[Header("Weapon bob")]
|
||||
[SerializeField] private bool enableWeaponBob = true;
|
||||
[SerializeField] private float bobFrequency = 8f;
|
||||
[SerializeField] private float bobAmplitudeY = 0.04f;
|
||||
[SerializeField] private float bobAmplitudeX = 0.03f;
|
||||
[SerializeField] private float bobSmooth = 8f;
|
||||
[SerializeField] private float sprintBobMultiplier = 1.4f;
|
||||
[SerializeField] private float crouchBobMultiplier = 0.5f;
|
||||
|
||||
[Header("Weapon sway")]
|
||||
[SerializeField] private bool enableWeaponSway = true;
|
||||
[SerializeField] private float swayAmount = 0.02f;
|
||||
[SerializeField] private float swaySmooth = 6f;
|
||||
[SerializeField] private float swayMaxAmount = 0.06f;
|
||||
|
||||
[Header("Idle tilt")]
|
||||
[SerializeField] private bool enableIdleTilt = true;
|
||||
[SerializeField] private float idleTiltFrequency = 0.4f;
|
||||
[SerializeField] private float idleTiltAmplitudeZ = 1.5f;
|
||||
[SerializeField] private float idleTiltAmplitudeX = 0.8f;
|
||||
[SerializeField] private float idleTiltSmooth = 4f;
|
||||
|
||||
[Header("Equip / Unequip")]
|
||||
[SerializeField] private float equipDropOffset = -0.5f;
|
||||
[SerializeField] private float equipTiltAngle = -15f;
|
||||
[SerializeField] private float equipDuration = 0.25f;
|
||||
[SerializeField] private float unequipDuration = 0.12f;
|
||||
|
||||
// ============================================================
|
||||
// Wiring
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// Runtime
|
||||
// ============================================================
|
||||
|
||||
private Vector2 lookInput;
|
||||
private float currentSpeed;
|
||||
private bool isSprinting;
|
||||
private bool isCrouching;
|
||||
private bool isGrounded = true;
|
||||
|
||||
private Vector3 toolHolderBasePos;
|
||||
private Quaternion toolHolderBaseRot;
|
||||
private Vector3 currentSway;
|
||||
private float bobTimer;
|
||||
private float idleTimer;
|
||||
private float currentBobIntensity;
|
||||
|
||||
private float equipAnimProgress = 1f;
|
||||
private float equipAnimTimer;
|
||||
private bool isUnequipping;
|
||||
private float unequipAnimProgress = 1f;
|
||||
private float unequipAnimTimer;
|
||||
private Action onUnequipComplete;
|
||||
|
||||
// ============================================================
|
||||
// Public API (consumed by HotbarController)
|
||||
// ============================================================
|
||||
|
||||
public void PlayEquipAnimation()
|
||||
{
|
||||
equipAnimProgress = 0f;
|
||||
equipAnimTimer = 0f;
|
||||
}
|
||||
|
||||
public void PlayUnequipAnimation(Action onComplete)
|
||||
{
|
||||
isUnequipping = true;
|
||||
unequipAnimProgress = 0f;
|
||||
unequipAnimTimer = 0f;
|
||||
onUnequipComplete = onComplete;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lifecycle
|
||||
// ============================================================
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (toolHolder != null)
|
||||
{
|
||||
toolHolderBasePos = toolHolder.localPosition;
|
||||
toolHolderBaseRot = toolHolder.localRotation;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayerEvents.LookInput += OnLookInput;
|
||||
PlayerEvents.VelocityChanged += OnVelocityChanged;
|
||||
PlayerEvents.SprintChanged += OnSprintChanged;
|
||||
PlayerEvents.CrouchChanged += OnCrouchChanged;
|
||||
PlayerEvents.GroundedChanged += OnGroundedChanged;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
PlayerEvents.LookInput -= OnLookInput;
|
||||
PlayerEvents.VelocityChanged -= OnVelocityChanged;
|
||||
PlayerEvents.SprintChanged -= OnSprintChanged;
|
||||
PlayerEvents.CrouchChanged -= OnCrouchChanged;
|
||||
PlayerEvents.GroundedChanged -= OnGroundedChanged;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event handlers
|
||||
// ============================================================
|
||||
|
||||
private void OnLookInput(Vector2 v) => lookInput = v;
|
||||
private void OnVelocityChanged(Vector3 v) => currentSpeed = v.magnitude;
|
||||
private void OnSprintChanged(bool b) => isSprinting = b;
|
||||
private void OnCrouchChanged(bool b) => isCrouching = b;
|
||||
private void OnGroundedChanged(bool grounded, float _) => isGrounded = grounded;
|
||||
|
||||
// ============================================================
|
||||
// Main loop
|
||||
// ============================================================
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (toolHolder == null) return;
|
||||
|
||||
bool dead = PlayerEvents.IsDead;
|
||||
if (dead)
|
||||
{
|
||||
currentBobIntensity = 0f;
|
||||
currentSway = Vector3.zero;
|
||||
toolHolder.localPosition = Vector3.Lerp(toolHolder.localPosition, toolHolderBasePos, Time.deltaTime * bobSmooth);
|
||||
toolHolder.localRotation = Quaternion.Slerp(toolHolder.localRotation, toolHolderBaseRot, Time.deltaTime * idleTiltSmooth);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateBobTimers();
|
||||
UpdateSway();
|
||||
UpdateEquipAnimations();
|
||||
ApplyToolHolderPose();
|
||||
}
|
||||
|
||||
private void UpdateBobTimers()
|
||||
{
|
||||
float dt = Time.deltaTime;
|
||||
float target = (isGrounded && currentSpeed > 0.1f) ? 1f : 0f;
|
||||
currentBobIntensity = Mathf.Lerp(currentBobIntensity, target, dt * 4f);
|
||||
|
||||
if (isGrounded && currentSpeed > 0.1f)
|
||||
bobTimer += dt * bobFrequency * (currentSpeed / 5f);
|
||||
|
||||
if (currentBobIntensity < 0.05f) idleTimer += dt;
|
||||
else idleTimer = 0f;
|
||||
}
|
||||
|
||||
private void UpdateSway()
|
||||
{
|
||||
if (enableWeaponSway)
|
||||
{
|
||||
Vector3 target = new Vector3(-lookInput.x * swayAmount, -lookInput.y * swayAmount, 0f);
|
||||
target.x = Mathf.Clamp(target.x, -swayMaxAmount, swayMaxAmount);
|
||||
target.y = Mathf.Clamp(target.y, -swayMaxAmount, swayMaxAmount);
|
||||
currentSway = Vector3.Lerp(currentSway, target, Time.deltaTime * swaySmooth);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentSway = Vector3.Lerp(currentSway, Vector3.zero, Time.deltaTime * swaySmooth);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEquipAnimations()
|
||||
{
|
||||
if (isUnequipping)
|
||||
{
|
||||
unequipAnimTimer += Time.deltaTime;
|
||||
float t = Mathf.Clamp01(unequipAnimTimer / unequipDuration);
|
||||
unequipAnimProgress = t * t;
|
||||
if (t >= 1f)
|
||||
{
|
||||
isUnequipping = false;
|
||||
PlayerEvents.RaiseUnequipAnimComplete();
|
||||
onUnequipComplete?.Invoke();
|
||||
onUnequipComplete = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (equipAnimTimer < equipDuration)
|
||||
{
|
||||
equipAnimTimer += Time.deltaTime;
|
||||
float t = Mathf.Clamp01(equipAnimTimer / equipDuration);
|
||||
const float c1 = 1.70158f;
|
||||
const float c3 = c1 + 1f;
|
||||
equipAnimProgress = 1f + c3 * Mathf.Pow(t - 1f, 3f) + c1 * Mathf.Pow(t - 1f, 2f);
|
||||
if (t >= 1f) PlayerEvents.RaiseEquipAnimComplete();
|
||||
}
|
||||
else
|
||||
{
|
||||
equipAnimProgress = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyToolHolderPose()
|
||||
{
|
||||
Vector3 targetPos = toolHolderBasePos;
|
||||
float mult = isCrouching ? crouchBobMultiplier : (isSprinting ? sprintBobMultiplier : 1f);
|
||||
|
||||
if (enableWeaponBob)
|
||||
{
|
||||
targetPos.y += Mathf.Sin(bobTimer) * bobAmplitudeY * mult * currentBobIntensity;
|
||||
targetPos.x += Mathf.Cos(bobTimer * 0.5f) * bobAmplitudeX * mult * currentBobIntensity;
|
||||
}
|
||||
targetPos += currentSway;
|
||||
|
||||
bool isAnimating = isUnequipping || equipAnimProgress < 1f;
|
||||
float animFactor = isUnequipping ? unequipAnimProgress : (1f - equipAnimProgress);
|
||||
|
||||
if (isAnimating)
|
||||
{
|
||||
targetPos.y += animFactor * equipDropOffset;
|
||||
toolHolder.localPosition = targetPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
toolHolder.localPosition = Vector3.Lerp(toolHolder.localPosition, targetPos, Time.deltaTime * bobSmooth);
|
||||
}
|
||||
|
||||
if (enableIdleTilt || isAnimating)
|
||||
{
|
||||
float idleWeight = 1f - currentBobIntensity;
|
||||
float phase = idleTimer * idleTiltFrequency * Mathf.PI * 2f;
|
||||
float tiltZ = Mathf.Sin(phase) * idleTiltAmplitudeZ * idleWeight;
|
||||
float tiltX = Mathf.Cos(phase * 0.6f) * idleTiltAmplitudeX * idleWeight;
|
||||
if (isAnimating) tiltZ += animFactor * equipTiltAngle;
|
||||
|
||||
Quaternion targetTilt = toolHolderBaseRot * Quaternion.Euler(tiltX, 0f, tiltZ);
|
||||
if (isAnimating) toolHolder.localRotation = targetTilt;
|
||||
else toolHolder.localRotation = Quaternion.Slerp(toolHolder.localRotation, targetTilt, Time.deltaTime * idleTiltSmooth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8c9d0e1f20314255567890123456abc
|
||||
@@ -5,6 +5,8 @@ using FishNet.Object.Synchronizing;
|
||||
using UnityEngine;
|
||||
using Ashwild.Interaction;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Network;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.Storage
|
||||
{
|
||||
@@ -263,7 +265,8 @@ namespace Ashwild.Storage
|
||||
if (!TryGetSlot(index, out ItemData item, out int quantity, out _)) return;
|
||||
if (!inv.CanFit(item, quantity))
|
||||
{
|
||||
Debug.LogWarning($"[Chest] Inventaire plein — impossible de retirer '{item.ItemName}'.", this);
|
||||
Debug.LogWarning($"[Chest] Inventory full — cannot withdraw '{item.ItemName}'.", this);
|
||||
PlayerEvents.RaiseInteractionRefused("Inventory full");
|
||||
return;
|
||||
}
|
||||
QuickWithdrawServerRpc(index);
|
||||
@@ -464,13 +467,10 @@ namespace Ashwild.Storage
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PlayerInventory on the player object owned by the given connection.
|
||||
/// Returns the PlayerInventory owned by the given connection. Delegates to the shared lookup,
|
||||
/// which scans the connection's objects instead of trusting FirstObject (see NetworkPlayerLookup).
|
||||
/// </summary>
|
||||
private PlayerInventory ResolveInventory(NetworkConnection conn)
|
||||
{
|
||||
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
|
||||
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
|
||||
}
|
||||
private PlayerInventory ResolveInventory(NetworkConnection conn) => NetworkPlayerLookup.ResolveInventory(conn);
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Ashwild.UI
|
||||
private float hideTimer;
|
||||
private bool isHiding;
|
||||
private bool isDiscovery;
|
||||
private string message;
|
||||
|
||||
private Tweener fadeTween;
|
||||
private Tweener slideTween;
|
||||
@@ -34,6 +35,10 @@ namespace Ashwild.UI
|
||||
// A discovery notification (icon + name + "NEW" badge) never merges with a gain/loss one.
|
||||
public bool IsDiscovery => isDiscovery;
|
||||
|
||||
// The refusal text this notification shows, or null when it is not a message — used to collapse
|
||||
// repeats of the same reason onto the line already on screen.
|
||||
public string Message => message;
|
||||
|
||||
public void Initialize(ItemData item, int signedQuantity, Color numberColor, float displayDuration, float slideInDuration, Ease slideInEase)
|
||||
{
|
||||
itemData = item;
|
||||
@@ -81,6 +86,52 @@ namespace Ashwild.UI
|
||||
fadeTween = canvasGroup.DOFade(1f, slideInDuration).SetEase(Ease.OutQuad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a plain message notification: a reason (e.g. "Inventory full") with no icon and no
|
||||
/// number, tinted by the caller. It carries no item, so it never merges with a gain/loss — a
|
||||
/// refusal is a one-off statement, not a running total.
|
||||
/// </summary>
|
||||
public void InitializeMessage(string text, Color messageColor, float displayDuration, float slideInDuration)
|
||||
{
|
||||
itemData = null;
|
||||
isDiscovery = true;
|
||||
message = text;
|
||||
hideTimer = displayDuration;
|
||||
isHiding = false;
|
||||
|
||||
if (iconImage != null)
|
||||
iconImage.gameObject.SetActive(false);
|
||||
|
||||
if (labelText != null)
|
||||
{
|
||||
labelText.text = text;
|
||||
labelText.color = messageColor;
|
||||
}
|
||||
|
||||
if (numberText != null)
|
||||
numberText.text = string.Empty;
|
||||
|
||||
canvasGroup.alpha = 0f;
|
||||
fadeTween = canvasGroup.DOFade(1f, slideInDuration).SetEase(Ease.OutQuad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the display timer of a message already on screen, so repeating the same refused
|
||||
/// action keeps one line alive instead of queueing a duplicate behind it.
|
||||
/// </summary>
|
||||
public void RefreshMessage(float displayDuration)
|
||||
{
|
||||
hideTimer = displayDuration;
|
||||
|
||||
if (isHiding)
|
||||
{
|
||||
isHiding = false;
|
||||
fadeTween?.Kill();
|
||||
slideTween?.Kill();
|
||||
canvasGroup.alpha = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
public void AddQuantity(int signedAmount, float displayDuration)
|
||||
{
|
||||
totalQuantity += signedAmount;
|
||||
|
||||
@@ -59,6 +59,7 @@ namespace Ashwild.UI
|
||||
private class PendingNotification
|
||||
{
|
||||
public bool isDiscovery;
|
||||
public bool isMessage;
|
||||
public ItemData item;
|
||||
public int signedQuantity;
|
||||
public Sprite icon;
|
||||
@@ -71,6 +72,7 @@ namespace Ashwild.UI
|
||||
PlayerEvents.ItemDropped += OnItemDropped;
|
||||
PlayerEvents.ItemConsumed += OnItemConsumed;
|
||||
PlayerEvents.RecipeDiscovered += OnRecipeDiscovered;
|
||||
PlayerEvents.InteractionRefused += OnInteractionRefused;
|
||||
PlayerEvents.FoodPlacedToCook += OnItemSpent;
|
||||
PlayerEvents.FuelAdded += OnItemSpent;
|
||||
}
|
||||
@@ -81,6 +83,7 @@ namespace Ashwild.UI
|
||||
PlayerEvents.ItemDropped -= OnItemDropped;
|
||||
PlayerEvents.ItemConsumed -= OnItemConsumed;
|
||||
PlayerEvents.RecipeDiscovered -= OnRecipeDiscovered;
|
||||
PlayerEvents.InteractionRefused -= OnInteractionRefused;
|
||||
PlayerEvents.FoodPlacedToCook -= OnItemSpent;
|
||||
PlayerEvents.FuelAdded -= OnItemSpent;
|
||||
}
|
||||
@@ -98,6 +101,7 @@ namespace Ashwild.UI
|
||||
private void OnItemDropped(ItemData item, int quantity) => Push(item, -quantity);
|
||||
private void OnItemConsumed(ItemData item) => Push(item, -1);
|
||||
private void OnRecipeDiscovered(Sprite icon, string recipeName) => PushDiscovery(icon, recipeName);
|
||||
private void OnInteractionRefused(string reason) => PushMessage(reason);
|
||||
|
||||
// Food placed on a cooking station or fuel fed to it both leave the inventory by one.
|
||||
private void OnItemSpent(ItemData item) => Push(item, -1);
|
||||
@@ -146,6 +150,33 @@ namespace Ashwild.UI
|
||||
Enqueue(new PendingNotification { isDiscovery = true, icon = icon, label = recipeName });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a plain message telling the player why an interaction gave them nothing ("Inventory
|
||||
/// full"). Collapses onto an identical message that is still on screen — spamming the interact key
|
||||
/// against a full inventory must refresh one line, not stack ten of them — and is tinted with the
|
||||
/// loss colour, since it always reports something the player did not get.
|
||||
/// </summary>
|
||||
private void PushMessage(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
|
||||
activeNotifications.RemoveAll(n => n == null);
|
||||
|
||||
for (int i = 0; i < activeNotifications.Count; i++)
|
||||
{
|
||||
if (activeNotifications[i] != null && activeNotifications[i].Message == message)
|
||||
{
|
||||
activeNotifications[i].RefreshMessage(displayDuration);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PendingNotification pending in pendingQueue)
|
||||
if (pending.isMessage && pending.label == message) return;
|
||||
|
||||
Enqueue(new PendingNotification { isMessage = true, label = message });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a pending notification to the queue, dropping the oldest waiting one when the queue is
|
||||
/// full so the most recent notifications are never lost.
|
||||
@@ -189,7 +220,9 @@ namespace Ashwild.UI
|
||||
RectTransform rt = notification.RectTransform;
|
||||
rt.anchoredPosition = new Vector2(0f, targetY - 30f);
|
||||
|
||||
if (pending.isDiscovery)
|
||||
if (pending.isMessage)
|
||||
notification.InitializeMessage(pending.label, lossColor, displayDuration, slideInDuration);
|
||||
else if (pending.isDiscovery)
|
||||
notification.InitializeDiscovery(pending.icon, pending.label, discoveryLabel, discoveryColor, displayDuration, slideInDuration);
|
||||
else
|
||||
notification.Initialize(pending.item, pending.signedQuantity, pending.signedQuantity > 0 ? gainColor : lossColor, displayDuration, slideInDuration, slideInEase);
|
||||
|
||||
Reference in New Issue
Block a user