(Feat) add crafting discovery + notification + inventory tool tips
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using FishNet.Object;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.Crafting
|
||||
{
|
||||
/// <summary>
|
||||
/// The local player's craft-discovery knowledge: the set of items it has ever acquired.
|
||||
/// A recipe is only revealed (and only craftable) once its ingredients have been discovered,
|
||||
/// so the player uncovers crafts by gathering resources rather than seeing the whole catalog
|
||||
/// at once. Discovery is triggered the first time an item enters the inventory — pickups,
|
||||
/// harvest drops AND craft results all count, which lets crafting chains reveal step by step.
|
||||
///
|
||||
/// This is owner-only local state: each player keeps its own knowledge, and it is never
|
||||
/// replicated over the wire (no one else needs to read it). It mirrors PlayerInventory's
|
||||
/// singleton pattern purely for reliable owner gating — set the Instance for the owning
|
||||
/// client in OnStartNetwork, and gate the component off on remotes via ownerOnlyBehaviours.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class CraftDiscovery : NetworkBehaviour
|
||||
{
|
||||
#region Singleton
|
||||
|
||||
/// <summary>
|
||||
/// The local player's discovery knowledge (set only on the owning client).
|
||||
/// </summary>
|
||||
public static CraftDiscovery Instance { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private readonly HashSet<ItemData> discovered = new HashSet<ItemData>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Network Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Registers the singleton on the owning client only (before LocalPlayerSpawned fires).
|
||||
/// </summary>
|
||||
public override void OnStartNetwork()
|
||||
{
|
||||
base.OnStartNetwork();
|
||||
if (base.Owner.IsLocalClient) Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the singleton when this player leaves the network.
|
||||
/// </summary>
|
||||
public override void OnStopNetwork()
|
||||
{
|
||||
base.OnStopNetwork();
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Starts tracking every item the local player acquires.
|
||||
/// </summary>
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayerEvents.ItemAdded += HandleItemAdded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops tracking — mirrors OnEnable exactly.
|
||||
/// </summary>
|
||||
private void OnDisable()
|
||||
{
|
||||
PlayerEvents.ItemAdded -= HandleItemAdded;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Marks an acquired item as discovered. Guarded to the owning instance so a remote puppet
|
||||
/// that briefly subscribed before being gated off can never drive discovery on the wrong copy.
|
||||
/// </summary>
|
||||
private void HandleItemAdded(ItemData item, int quantity)
|
||||
{
|
||||
if (Instance != this) return;
|
||||
Discover(item);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Whether the local player has ever acquired this item (and therefore knows it).
|
||||
/// </summary>
|
||||
public bool IsDiscovered(ItemData item) => item != null && discovered.Contains(item);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Adds an item to the discovered set the first time it is seen and announces it on the bus
|
||||
/// so the crafting view can reveal any recipe the item unlocks.
|
||||
/// </summary>
|
||||
private void Discover(ItemData item)
|
||||
{
|
||||
if (item == null) return;
|
||||
if (discovered.Add(item))
|
||||
PlayerEvents.RaiseItemDiscovered(item);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fabb912832c44b945a26c8ef03db222a
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using Ashwild.Inventory;
|
||||
@@ -31,6 +32,7 @@ namespace Ashwild.Crafting
|
||||
|
||||
private PlayerInventory inventory;
|
||||
private bool bound;
|
||||
private readonly HashSet<CraftingRecipe> unlockedRecipes = new HashSet<CraftingRecipe>();
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -43,19 +45,25 @@ namespace Ashwild.Crafting
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public bool CanCraft(CraftingRecipe recipe, int count = 1)
|
||||
{
|
||||
return PlayerInventory.Instance != null && recipe.CanCraft(PlayerInventory.Instance, count);
|
||||
return PlayerInventory.Instance != null
|
||||
&& IsFullyDiscovered(recipe)
|
||||
&& recipe.CanCraft(PlayerInventory.Instance, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the ingredients and produces the result if the local player can afford it.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool TryCraft(CraftingRecipe recipe, int count = 1)
|
||||
{
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
if (inv == null || !recipe.CanCraft(inv, count))
|
||||
if (inv == null || !IsFullyDiscovered(recipe) || !recipe.CanCraft(inv, count))
|
||||
return false;
|
||||
|
||||
// Consume ingredients
|
||||
@@ -82,6 +90,7 @@ namespace Ashwild.Crafting
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayerEvents.LocalPlayerSpawned += HandleLocalPlayerSpawned;
|
||||
PlayerEvents.ItemDiscovered += HandleItemDiscovered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,6 +99,7 @@ namespace Ashwild.Crafting
|
||||
private void OnDisable()
|
||||
{
|
||||
PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned;
|
||||
PlayerEvents.ItemDiscovered -= HandleItemDiscovered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -98,7 +108,7 @@ namespace Ashwild.Crafting
|
||||
private void Start()
|
||||
{
|
||||
if (craftingUI != null)
|
||||
craftingUI.Build(recipes, CanCraft, CountItem, TryCraft);
|
||||
craftingUI.Build(recipes, CanCraft, CountItem, TryCraft, IsDiscovered, IsRecipeVisible);
|
||||
else
|
||||
Debug.LogError("[CraftingManager] No CraftingUI assigned — the crafting panel will not populate.", this);
|
||||
|
||||
@@ -134,6 +144,19 @@ namespace Ashwild.Crafting
|
||||
craftingUI.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reacts to a newly discovered item: announces any recipe it just unlocked (for the "NEW"
|
||||
/// craft notification), then refreshes the view so those recipes appear and their once-mystery
|
||||
/// ingredients are shown.
|
||||
/// </summary>
|
||||
private void HandleItemDiscovered(ItemData item)
|
||||
{
|
||||
NotifyNewlyUnlockedRecipes();
|
||||
|
||||
if (craftingUI != null)
|
||||
craftingUI.Refresh();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Helpers
|
||||
@@ -150,10 +173,41 @@ namespace Ashwild.Crafting
|
||||
|
||||
inventory.onSlotChanged.AddListener(HandleInventoryChanged);
|
||||
|
||||
SeedUnlockedRecipes();
|
||||
|
||||
if (craftingUI != null)
|
||||
craftingUI.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the recipes already revealed at bind time (e.g. from restored discoveries) so they
|
||||
/// are treated as known and never fire a spurious "NEW" notification. With a fresh player this
|
||||
/// is empty since discovery starts at zero.
|
||||
/// </summary>
|
||||
private void SeedUnlockedRecipes()
|
||||
{
|
||||
for (int i = 0; i < recipes.Length; i++)
|
||||
if (IsRecipeVisible(recipes[i]))
|
||||
unlockedRecipes.Add(recipes[i]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises a "NEW" notification for every recipe that has just become visible (its first
|
||||
/// discovered ingredient), each announced once. Called after an item is discovered.
|
||||
/// </summary>
|
||||
private void NotifyNewlyUnlockedRecipes()
|
||||
{
|
||||
for (int i = 0; i < recipes.Length; i++)
|
||||
{
|
||||
CraftingRecipe recipe = recipes[i];
|
||||
if (unlockedRecipes.Contains(recipe)) continue;
|
||||
if (!IsRecipeVisible(recipe)) continue;
|
||||
|
||||
unlockedRecipes.Add(recipe);
|
||||
PlayerEvents.RaiseRecipeDiscovered(recipe.Icon, recipe.RecipeName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many of an item the local player currently holds (0 when offline).
|
||||
/// </summary>
|
||||
@@ -162,6 +216,37 @@ namespace Ashwild.Crafting
|
||||
return PlayerInventory.Instance != null ? PlayerInventory.Instance.CountItem(item) : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the local player has discovered the item (false when the discovery tracker isn't ready).
|
||||
/// </summary>
|
||||
private bool IsDiscovered(ItemData item)
|
||||
{
|
||||
return CraftDiscovery.Instance != null && CraftDiscovery.Instance.IsDiscovered(item);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private bool IsRecipeVisible(CraftingRecipe recipe)
|
||||
{
|
||||
CraftingIngredient[] ingredients = recipe.Ingredients;
|
||||
for (int i = 0; i < ingredients.Length; i++)
|
||||
if (IsDiscovered(ingredients[i].item)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether every ingredient of the recipe has been discovered — the gate for actually crafting it.
|
||||
/// </summary>
|
||||
private bool IsFullyDiscovered(CraftingRecipe recipe)
|
||||
{
|
||||
CraftingIngredient[] ingredients = recipe.Ingredients;
|
||||
for (int i = 0; i < ingredients.Length; i++)
|
||||
if (!IsDiscovered(ingredients[i].item)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ namespace Ashwild.Crafting
|
||||
private Func<CraftingRecipe, int, bool> canCraftQuery;
|
||||
private Func<ItemData, int> countItemQuery;
|
||||
private Func<CraftingRecipe, int, bool> craftRequest;
|
||||
private Func<ItemData, bool> isDiscoveredQuery;
|
||||
private Func<CraftingRecipe, bool> isRecipeVisibleQuery;
|
||||
private readonly List<RecipeButtonUI> recipeButtons = new List<RecipeButtonUI>();
|
||||
private readonly List<GameObject> ingredientInstances = new List<GameObject>();
|
||||
private RecipeButtonUI selectedButton;
|
||||
@@ -55,12 +57,15 @@ namespace Ashwild.Crafting
|
||||
/// <summary>
|
||||
/// Builds the recipe buttons (once) and wires the manager-owned queries/action. Safe to
|
||||
/// call again later to re-point the delegates; it then simply refreshes the display.
|
||||
/// The discovery queries decide which recipes are shown and which ingredients are still a mystery.
|
||||
/// </summary>
|
||||
public void Build(CraftingRecipe[] recipes, Func<CraftingRecipe, int, bool> canCraft, Func<ItemData, int> countItem, Func<CraftingRecipe, int, bool> craft)
|
||||
public void Build(CraftingRecipe[] recipes, Func<CraftingRecipe, int, bool> canCraft, Func<ItemData, int> countItem, Func<CraftingRecipe, int, bool> craft, Func<ItemData, bool> isDiscovered, Func<CraftingRecipe, bool> isRecipeVisible)
|
||||
{
|
||||
canCraftQuery = canCraft;
|
||||
countItemQuery = countItem;
|
||||
craftRequest = craft;
|
||||
isDiscoveredQuery = isDiscovered;
|
||||
isRecipeVisibleQuery = isRecipeVisible;
|
||||
|
||||
if (!built)
|
||||
{
|
||||
@@ -81,8 +86,9 @@ namespace Ashwild.Crafting
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes craftability for every recipe button and refreshes the open detail.
|
||||
/// Called by the manager whenever the local player's inventory changes.
|
||||
/// Recomputes each recipe button's visibility (revealed once an ingredient is discovered) and
|
||||
/// its craftability, then refreshes the open detail. Called by the manager whenever the local
|
||||
/// player's inventory changes or a new item is discovered.
|
||||
/// </summary>
|
||||
public void Refresh()
|
||||
{
|
||||
@@ -91,6 +97,9 @@ namespace Ashwild.Crafting
|
||||
for (int i = 0; i < recipeButtons.Count; i++)
|
||||
{
|
||||
RecipeButtonUI btn = recipeButtons[i];
|
||||
bool visible = isRecipeVisibleQuery == null || isRecipeVisibleQuery(btn.Recipe);
|
||||
btn.gameObject.SetActive(visible);
|
||||
|
||||
bool canCraft = canCraftQuery != null && canCraftQuery(btn.Recipe, btn.CraftCount);
|
||||
btn.UpdateCraftability(canCraft);
|
||||
}
|
||||
@@ -193,12 +202,21 @@ namespace Ashwild.Crafting
|
||||
GameObject slotGO = Instantiate(ingredientSlotPrefab, ingredientContainer);
|
||||
IngredientSlotUI slotUI = slotGO.GetComponent<IngredientSlotUI>();
|
||||
|
||||
int required = ingredients[i].quantity * count;
|
||||
int playerHas = countItemQuery != null ? countItemQuery(ingredients[i].item) : 0;
|
||||
slotUI.Setup(ingredients[i].item, required, playerHas);
|
||||
|
||||
if (playerHas < required)
|
||||
bool discovered = isDiscoveredQuery == null || isDiscoveredQuery(ingredients[i].item);
|
||||
if (!discovered)
|
||||
{
|
||||
slotUI.SetupMystery();
|
||||
canCraft = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
int required = ingredients[i].quantity * count;
|
||||
int playerHas = countItemQuery != null ? countItemQuery(ingredients[i].item) : 0;
|
||||
slotUI.Setup(ingredients[i].item, required, playerHas);
|
||||
|
||||
if (playerHas < required)
|
||||
canCraft = false;
|
||||
}
|
||||
|
||||
ingredientInstances.Add(slotGO);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,45 @@ using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.Crafting
|
||||
{
|
||||
/// <summary>
|
||||
/// A single ingredient cell in the crafting detail panel. Renders either a known ingredient
|
||||
/// (icon + required quantity, tinted by whether the player has enough) or a mystery placeholder
|
||||
/// ("?") for an ingredient the player has not discovered yet.
|
||||
/// </summary>
|
||||
public class IngredientSlotUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Image iconImage;
|
||||
[SerializeField] private TextMeshProUGUI quantityText;
|
||||
|
||||
[Header("Mystery (undiscovered ingredient)")]
|
||||
[Tooltip("Icon shown for an ingredient the player hasn't discovered yet (a question mark).")]
|
||||
[SerializeField] private Sprite mysteryIcon;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color enoughColor = Color.white;
|
||||
[SerializeField] private Color missingColor = new Color(1f, 0.3f, 0.3f, 1f);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a known ingredient: its icon and required amount, dimmed/red when the player lacks it.
|
||||
/// </summary>
|
||||
public void Setup(ItemData item, int required, int playerHas)
|
||||
{
|
||||
quantityText.gameObject.SetActive(true);
|
||||
iconImage.sprite = item.Icon;
|
||||
quantityText.text = "x" + required;
|
||||
quantityText.color = playerHas >= required ? enoughColor : missingColor;
|
||||
iconImage.color = playerHas >= required ? Color.white : new Color(1f, 1f, 1f, 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an undiscovered ingredient as a mystery: the "?" icon with no quantity text, so the
|
||||
/// player knows a craft exists but must still find what it needs and how much.
|
||||
/// </summary>
|
||||
public void SetupMystery()
|
||||
{
|
||||
iconImage.sprite = mysteryIcon;
|
||||
iconImage.color = new Color(1f, 1f, 1f, 0.5f);
|
||||
quantityText.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user