Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery

# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
This commit is contained in:
2026-07-25 19:42:26 +02:00
954 changed files with 999772 additions and 26193 deletions
+116 -15
View File
@@ -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