Yes, I admit at first it looks a little confusing (it is due to SOs an the whole “template vs runtime data” concept I added). I think I should have explained it instead of just dumping the code on you. You do buy the flexibility with more setup work (i.e. setting up the components before combining them). But the more components you have the less setup work you need to do and the fun part (composition) will take over.
Most of the “Data” scripts are there to marry default Unity SOs with Interfaces and separate your template data from your object data. If you define and load your data in code (from a json maybe) then you could reduce it a lot.
So, rather late, here is some documentation on the example (plus an updated version as .zip).
Let’s get a sneak peek at the result of all of this (the API you would end up with by using this system):
Main:
public class Main : MonoBehaviour
{
/// <summary>
/// Reference to ScriptableObject with all item templates.
/// </summary>
public ItemTemplateList ItemTemplates;
public void Start()
{
var itemFactory = new ItemFactory(ItemTemplates);
// index 0 is the cheese
// You can add your own means of selection instead but I wanted to keep it simple.
var cheese = itemFactory.CreateItem(0);
var bananas = itemFactory.CreateItem(1);
// is the cheese consumeable?
if (cheese.HasTrait<IConsumeable>())
{
var cheeseConsumeable = cheese.GetTrait<IConsumeable>();
Debug.Log("Cheese amount: " + cheeseConsumeable.AmountLeft());
Debug.Log("Ate " + cheeseConsumeable.Consume(1) + " cheese.");
Debug.Log("Cheese left: " + cheeseConsumeable.AmountLeft());
}
if (bananas.HasTrait<IConsumeable>())
{
var bananasConsumeable = bananas.GetTrait<IConsumeable>();
Debug.Log("Bananas amount: " + bananasConsumeable.AmountLeft());
Debug.Log("Ate " + bananasConsumeable.Consume(2) + " bananas.");
Debug.Log("Bananas left: " + bananasConsumeable.AmountLeft());
}
}
}
Main uses an ItemTemplatesList (a list of ScriptableObjects) and an ItemFactory (instantiates items by handing them a list of trait data).
Before we get into the meat of the code here is an overview of how the logic and the data is structured:

NOTICE: TraitTemplates can be reused in many items. That’s the composition we are after.

NOTICE: TraitConsumeableData exists on both sides, the ScriptableObject templates and (as a copy) in runtime Items.
Item Factory:
It needs item template data to work (surprise). Luckily our Main object is nice enough to hand it over. You can use dependency injection etc. for this but let’s keep it simple here.
public class ItemFactory
{
/// <summary>
/// Reference to SO item template data.
/// </summary>
protected ItemTemplateList itemTemplates;
public ItemFactory(ItemTemplateList itemTemplates)
{
this.itemTemplates = itemTemplates;
}
/// <summary>
/// Creates an item by taking the ItemTemplate at the given index from itemTemplates list.
/// </summary>
/// <param name="index"></param>
/// <returns></returns>
public ITraitList CreateItem(int index)
{
return new Item(itemTemplates.Templates[index]);
}
}
Item:
An item is nothing more but a list of runtime objects (list of ITrait). Each Trait is LOGIC plus DATA and both only exist at runtime (the DATA is copied from the ScriptableObject template by TraitListTemplate.InstantiateTraits()).
public class Item : ITraitList
{
public List<ITrait> Traits;
public Item(TraitListTemplate traitListTemplate)
{
Traits = TraitListTemplate.InstantiateTraits(traitListTemplate);
}
/// <summary>
/// Does the list of traits contain this trait?
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool HasTrait<T>() where T : class
{
return GetTrait<T>() != null;
}
/// <summary>
/// Get the trait.
/// Returns NULL if not found.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns>The trait or NULL if not found.</returns>
public T GetTrait<T>() where T : class
{
foreach (var trait in Traits)
{
if (trait is T)
{
return trait as T;
}
}
return null;
}
}
Let’s look at a concrete Trait implementation.
IConsumeable:
Here is our interface for the consumeable Trait (the item can be consumed one or more times):
using UnityEngine;
public interface IConsumeable
{
/// <summary>
/// Consumes it. May only consume N parts of many.
/// Returns how much has been consumed.
/// </summary>
/// <param name="amount">How much to consume.</param>
/// <returns>Returns how much has been consumed. Can return 0.</returns>
int Consume(int amount);
/// <summary>
/// Returns how often this can be consumed.
/// </summary>
/// <returns></returns>
int AmountLeft();
}
And here is the consumeable implementation. It is split into three parts. One data and one logic object for runtime use and one ScriptableObject as data template. This is the tedious setup part.
TraitConsumeableData (notice this is not a ScriptableObject but it is serializable):
[System.Serializable]
public class TraitConsumeableData : ITraitData
{
public int Amount = 10;
public int ReductionPerCosumption = 1;
public TraitConsumeableData(int amount, int reductionPerCosumption)
{
Amount = amount;
ReductionPerCosumption = reductionPerCosumption;
}
public T GetCopy<T>() where T : class, ITraitData
{
return new TraitConsumeableData(Amount, ReductionPerCosumption) as T;
}
}
ITraitData is just there to have a common root and enforce a Copy method on all TraitDataTemplates. We don’t want to accidentally modify our template data in our running game (important in the editor). Therefore we use this to make a copy and work with that.
public interface ITraitData
{
T GetCopy<T>() where T : class, ITraitData;
}
Consumeable (Logic):
This whole Consumeable is just one of potentially many Traits to implement. This is the fun part where we implement our logic.
using UnityEngine;
public class Consumeable : ITrait, IConsumeable
{
public TraitConsumeableData Data;
public Consumeable(TraitConsumeableData data)
{
this.Data = data;
}
/// <summary>
/// <inheritdoc/>
/// </summary>
public int Consume(int amount)
{
int leftBefore = Data.Amount;
Data.Amount = Mathf.Max(0, Data.Amount - amount);
return leftBefore - Data.Amount;
}
/// <summary>
/// <inheritdoc/>
/// </summary>
public int AmountLeft()
{
return Data.Amount;
}
public ITraitData GetData()
{
return Data;
}
}
ITrait is again just a common root with one method for all traits. The method exists to get the data which can then be copied into the actual item.
public interface ITrait
{
ITraitData GetData();
}
We already know IConsumeable from above.
TraitConsumeableTemplate:
This is a ScriptableObject which wraps some data. We use it to store a TraitConsumeableData as template for our runtime Traits.
[CreateAssetMenu(fileName = "Trait Consumeable", menuName = "ScriptableObjects/ItemTraits/Consumeable", order = 2)]
public class TraitConsumeableTemplate : TraitTemplate
{
[SerializeField]
protected TraitConsumeableData data;
public override ITraitData GetData()
{
return data;
}
}
TraitListTemplate:
Another noteworthy class is TraitListTemplate (also a ScriptableObject).
The SOs part is just a list but the interesting thing are the utility methods. This is where we match the SOs with runtime types (data and logic).
At some point we have to associate which runtime class matches the data. This is done “manually” in combineLogicWithData() which I consider very ugly as you would have to update this with every new Type of Trait you add. But it is simple enough to work in this example.
[CreateAssetMenu(fileName = "ItemTraitList", menuName = "ScriptableObjects/Item", order = 1)]
public class TraitListTemplate : ScriptableObject
{
public List<TraitTemplate> Traits;
/// <summary>
/// Instantiates the logic for reach trait template. Each instance gets a separate copy of the tempate data.
/// </summary>
/// <param name="traitListTemplate"></param>
/// <returns></returns>
public static List<ITrait> InstantiateTraits(TraitListTemplate traitListTemplate)
{
var traits = new List<ITrait>();
foreach (var traitTemplate in traitListTemplate.Traits)
{
traits.Add(combineLogicWithData(traitTemplate));
}
return traits;
}
/// <summary>
/// Matches the logic to the template data and returns an instance.
/// </summary>
/// <param name="traitTemplate"></param>
/// <returns></returns>
protected static ITrait combineLogicWithData(ITrait traitTemplate)
{
// Now this is ugly but we only do it at instantiation
var templateData = traitTemplate.GetData();
// Consumeable
if (templateData is TraitConsumeableData)
{
return new Consumeable(templateData.GetCopy<TraitConsumeableData>());
}
// .. add for every new trait
return null;
}
}
I hope this gives more insight into how the code was intended to work. I also renamed the Trait SOs as the names in the first zipped example are definitely confusing.
7520951–927929–Assets.zip (21.9 KB)