So here is our crafting system in action (mind the bad colour, to reduce size this is a highly compressed gif):
How we accomplish this is that we have ScriptableObject’s for both item’s and recipes:
InventoryItem:
using UnityEngine;
using System.Collections.Generic;
using com.spacepuppy;
using com.spacepuppy.Scenario;
using com.spacepuppy.Utils;
using com.mansion.Entities.Inventory.UI;
namespace com.mansion.Entities.Inventory
{
[CreateAssetMenu(fileName = "InventoryItem", menuName = "Inventory/InventoryItem")]
public class InventoryItem : ScriptableObject, INameable, IObservableTrigger
{
#region Fields
[SerializeField]
private InventoryUsage _usage;
[SerializeField]
private Sprite _icon;
[SerializeField]
private InventoryItemViewUI _menuVisual;
[SerializeField]
private string _title;
[SerializeField]
private string _description;
[SerializeField]
[Tooltip("Items that are unique can only be added to the InventoryPouch once. Re-adding it doesn't do anything.")]
private bool _unique = true;
[SerializeField]
private bool _consumeOnUse = true;
[SerializeField]
private Trigger _onUsed;
#endregion
#region CONSTRUCTOR
public InventoryItem()
{
_nameCache = new NameCache.UnityObjectNameCache(this);
}
#endregion
#region Properties
public virtual InventoryUsage Usage
{
get { return _usage; }
set { _usage = value; }
}
public Sprite Icon
{
get { return _icon; }
set { _icon = value; }
}
public virtual InventoryItemViewUI MenuVisual
{
get { return _menuVisual; }
set { _menuVisual = value; }
}
public string Title
{
get { return _title; }
set { _title = value; }
}
public string Description
{
get { return _description; }
set { _description = value; }
}
public bool Unique
{
get { return _unique; }
set { _unique = value; }
}
public bool ConsumeOnUse
{
get { return _consumeOnUse; }
set { _consumeOnUse = value; }
}
public Trigger OnUsed
{
get { return _onUsed; }
}
#endregion
#region Methods
public void Use()
{
_onUsed.ActivateTrigger(this, null);
}
public virtual void OnItemAddedToInventory(InventoryPouch pouch)
{
//do nothing
}
public virtual void OnItemRemovedFromInventory(InventoryPouch pouch)
{
//do nothing
}
public virtual bool PlayerHasInInventory(PlayerEntity player, bool isEquipped)
{
if (isEquipped) return false;
var inventory = player.GetComponent<InventoryPouch>();
if (inventory == null) return false;
return inventory.Items.Contains(this);
}
#endregion
#region INameable Interface
private NameCache.UnityObjectNameCache _nameCache;
public new string name
{
get { return _nameCache.Name; }
set { _nameCache.Name = value; }
}
string INameable.Name
{
get { return _nameCache.Name; }
set { _nameCache.Name = value; }
}
public bool CompareName(string nm)
{
return _nameCache.CompareName(nm);
}
void INameable.SetDirty()
{
_nameCache.SetDirty();
}
#endregion
#region IObservableTrigger Interface
Trigger[] IObservableTrigger.GetTriggers()
{
return new Trigger[] { _onUsed };
}
#endregion
}
}
ItemRecipe:
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
using com.spacepuppy;
using com.spacepuppy.Scenario;
using com.spacepuppy.Utils;
namespace com.mansion.Entities.Inventory
{
[CreateAssetMenu(fileName = "ItemRecipe", menuName = "Inventory/ItemRecipe")]
public class ItemRecipe : ScriptableObject, INameable
{
#region Fields
[SerializeField]
private InventoryItem _item;
[SerializeField]
[ReorderableArray()]
[DisableOnPlay()]
private InventoryItem[] _recipe;
[SerializeField]
private bool _consumeItems = true;
[SerializeField]
private Trigger _onCreated;
#endregion
#region Properties
public InventoryItem Item
{
get { return _item; }
set
{
_item = value;
}
}
public InventoryItem[] Recipe
{
get { return _recipe; }
set { _recipe = value; }
}
public bool ConsumeItems
{
get { return _consumeItems; }
set { _consumeItems = value; }
}
public int RecipeItemCount
{
get { return _recipe != null ? _recipe.Length : 0; }
}
public Trigger OnCreated
{
get { return _onCreated; }
}
#endregion
#region Methods
/// <summary>
/// Tests if the recipe uses the item at all.
/// </summary>
/// <param name="item"></param>
/// <returns></returns>
public bool UsesItem(InventoryItem item)
{
if (_recipe == null || _recipe.Length == 0) return false;
return System.Array.IndexOf(_recipe, item) >= 0;
}
/// <summary>
/// Tests if this combination works for this recipe.
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public bool Matches(InventoryItem a, InventoryItem b)
{
if (_recipe == null || _recipe.Length != 2) return false;
if (_recipe[0] == a)
return _recipe[1] == b;
else if (_recipe[1] == a)
return _recipe[0] == b;
else
return false;
}
/// <summary>
/// Tests if this combination works for this recipe.
/// </summary>
/// <param name="arr"></param>
/// <returns></returns>
public bool Matches(params InventoryItem[] arr)
{
if (_recipe == null) return arr == null;
if (arr == null) return _recipe == null;
if (_recipe.Length != arr.Length) return false;
return !_recipe.Except(arr).Any();
}
public bool Matches(ICollection<InventoryItem> coll)
{
if (_recipe == null) return coll == null;
if (coll == null) return _recipe == null;
if (_recipe.Length != coll.Count) return false;
return !_recipe.Except(coll).Any();
}
public bool PartialMatch(params InventoryItem[] arr)
{
if (_recipe == null) return arr == null;
if (arr == null) return _recipe == null;
return !arr.Except(_recipe).Any();
}
public bool PartialMatch(ICollection<InventoryItem> coll)
{
if (_recipe == null) return coll == null;
if (coll == null) return _recipe == null;
return !coll.Except(_recipe).Any();
}
#endregion
#region INameable Interface
private NameCache.UnityObjectNameCache _nameCache;
public new string name
{
get { return _nameCache.Name; }
set { _nameCache.Name = value; }
}
string INameable.Name
{
get { return _nameCache.Name; }
set { _nameCache.Name = value; }
}
public bool CompareName(string nm)
{
return _nameCache.CompareName(nm);
}
void INameable.SetDirty()
{
_nameCache.SetDirty();
}
#endregion
}
}
(you may notice more than 1 version of Matches… this is because early versions of our crafting system only allowed for 2 items to combine… we later added the ability to do multi-item combination like this fishing rod)
As for the general logic for combining here is the inventory screens update cycle. Mind you that because it supports multiple interactions (using, combining, equipping, etc), there is more going on here than JUST combining.
private void Update()
{
//boiler plate
_display.Spindle.DefaultUpdate_AnimateSpindleElements(true);
if (!_display.IsShowing || _routine != null) return;
var input = Services.Get<IInputManager>().GetDevice<MansionInputDevice>(Game.MAIN_INPUT);
if (input == null || !_display.IsShowing) return;
if (input.GetExitMenu(true, true))
{
_display.Hide();
return;
}
if (_display.Spindle.DefaultUpdate_ProcessSpindleInputRotation(input)) this.UpdateDisplayText();
//process for this state
switch(_state)
{
case State.Standard:
{
if (input.GetButtonState(MansionInputs.Action) == ButtonState.Down)
{
var item = _display.Spindle.GetCurrentlyFocusedInventoryView();
switch (item != null && item.Item != null ? item.Item.Usage : InventoryUsage.Default)
{
case InventoryUsage.Default:
//do nothing
break;
case InventoryUsage.Equippable:
if (item.Item is WeaponInventoryItem)
{
//we close on select
if (_display.CurrentlyEquippedWeaponIndex == _display.Spindle.FocusedIndex ||
_display.ChangeEquippedWeapon(_display.Spindle.FocusedIndex))
this.Invoke(() => _display.Hide(), 0.1f, SPTime.Real);
}
break;
case InventoryUsage.Usable:
{
_routine = this.StartRadicalCoroutine(this.DoConsumeItem(item));
}
break;
case InventoryUsage.Combinable:
{
_state = State.WaitingOnCombination;
_waitingOnCombinationItems.Add(item.Item);
item.SetHighlighted(true, cakeslice.OutlineEffect.OutlinePreset.B);
for(int i = 0; i < _display.Items.Count; i++)
{
if (_display.Items[i] != item)
_display.Items[i].SetHighlighted(false);
}
}
break;
}
}
}
break;
case State.WaitingOnCombination:
{
if (input.GetButtonState(MansionInputs.Action) == ButtonState.Down)
{
var item = _display.Spindle.GetCurrentlyFocusedInventoryView();
if(item != null && item.Item != null)
{
if (Game.EpisodeSettings.RecipeSet == null)
{
_state = State.Standard;
_display.SetHighlights();
_waitingOnCombinationItems.Clear();
_onCombinationFail.ActivateTrigger(this, null);
}
else
{
item.SetHighlighted(true, cakeslice.OutlineEffect.OutlinePreset.B);
_waitingOnCombinationItems.Add(item.Item);
int foundMatch = 0; //0 = no, 1 = partial, 2 = yes
var recipes = Game.EpisodeSettings.RecipeSet;
foreach (var recipe in recipes.GetAllAssets<ItemRecipe>())
{
if (recipe.Matches(_waitingOnCombinationItems))
{
_routine = this.StartRadicalCoroutine(this.DoCombineItem(recipe));
foundMatch = 2;
break;
}
else if(recipe.PartialMatch(_waitingOnCombinationItems))
{
foundMatch = 1;
}
}
if (foundMatch == 0)
{
_state = State.Standard;
_display.SetHighlights();
_waitingOnCombinationItems.Clear();
_onCombinationFail.ActivateTrigger(this, null);
}
}
}
}
}
break;
}
}
private System.Collections.IEnumerator DoCombineItem(ItemRecipe recipe)
{
if (_display.ConfirmPrompt == null) goto Confirmed;
var result = _display.ConfirmPrompt.Show();
yield return result;
yield return null; //wait one extra frame for effect
if (result != null && result.SelectedConfirm)
goto Confirmed;
else
{
_onCombinationFail.ActivateTrigger(this, null);
goto Cleanup;
}
Confirmed:
if(recipe.ConsumeItems)
{
foreach (var item in _waitingOnCombinationItems)
{
_display.InventoryPouch.Items.Remove(item);
}
}
_waitingOnCombinationItems.Clear();
if (recipe.Item != null) _display.InventoryPouch.Items.Add(recipe.Item);
recipe.OnCreated.ActivateTrigger(recipe, null);
_onCombinationSuccess.ActivateTrigger(this, null);
yield return WaitForDuration.Seconds(0.1f, SPTime.Real);
//_display.Hide();
if(_display.IsShowing)
{
_display.SyncItemCollections();
int newIndex = _display.GetItemIndex(recipe.Item);
if (newIndex >= 0) _display.Spindle.SetFocusedIndex(newIndex, true);
_display.UpdateCurrentlyEquippedHighlight(); //in-case what was combined was an euipped item
this.UpdateDisplayText();
}
Cleanup:
_state = State.Standard;
if (_display.IsShowing) _display.SetHighlights();
_waitingOnCombinationItems.Clear();
_routine = null;
}
Basically I have a state enum that tracks what state the inventory screen is in.
When someone selects the first item I determine what sort of item it is… if it’s a combinable item I go into a ‘WaitingOnCombination’ state. Then as more items are selected I test if any of the recipes on hand match the combination I currently have via the ‘matches’ method on the recipes. I keep doing this until they either cancel, or a recipe matches. At which point I remove the old items and add the new one.
…
Now this may not be exactly what you need… it is designed specifically for our game which has only very basic crafting. We don’t have things like using y items of x, or minecraft style crafting table/pattern stuff.
It’s a really simple “these items combine to make that item” system. Since our game is more a survival horror meets old SCUMM/Adventure games (like secret of monkey island).