├── Assets └── _Project │ └── Scripts │ ├── Utilities │ ├── Timer.cs.meta │ └── Timer.cs │ ├── Entity.cs.meta │ ├── Pickup.cs.meta │ ├── Stats │ ├── BaseStats.cs.meta │ ├── Stats.cs.meta │ ├── StatModifier.cs.meta │ ├── StatsMediator.cs.meta │ ├── Visitor.cs.meta │ ├── Visitor.cs │ ├── BaseStats.cs │ ├── OperationStrategy.cs │ ├── StatModifierApplicationOrder.cs │ ├── Stats.cs │ ├── StatModifier.cs │ └── StatsMediator.cs │ ├── StatModifierPickup.cs.meta │ ├── Stats.meta │ ├── Bootstrapper.cs │ ├── Entity.cs │ ├── Pickup.cs │ ├── StatModifierFactory.cs │ └── StatModifierPickup.cs ├── README.md └── .gitignore /Assets/_Project/Scripts/Utilities/Timer.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: eb0fd4179938deb4ab441901fa6b58d4 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Entity.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 5722982cd9eb4b65b314c3398651b99a 3 | timeCreated: 1712965803 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Pickup.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 6a8ea8ae74cf4b8fb166078b1da2be27 3 | timeCreated: 1712969192 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/BaseStats.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 63523fdf38c44ae2ae3083f907494464 3 | timeCreated: 1712981979 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/Stats.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 4fc25d4adb8c4b3fa7352f26b84acfcb 3 | timeCreated: 1712965425 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/StatModifierPickup.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: a6a3fbd120524d478b965d3f6633af93 3 | timeCreated: 1713043413 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/StatModifier.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 422f36639f8740c287028d0186bbd42f 3 | timeCreated: 1712983926 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/StatsMediator.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: f77d523787b34ae9afeab0b77a17c0fb 3 | timeCreated: 1712984831 -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: fe4ad5731b14da5449c162f8cdd14526 3 | folderAsset: yes 4 | DefaultImporter: 5 | externalObjects: {} 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/Visitor.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | <<<<<<< HEAD 3 | guid: 75d6c6b7fa044f82bec34b5b74a9e7f9 4 | timeCreated: 1711719836 5 | ======= 6 | guid: 27c816b063270af4ea4a558e97d17d23 7 | >>>>>>> origin/master 8 | -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/Visitor.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public interface IVisitor { 4 | void Visit(T visitable) where T : Component, IVisitable; 5 | } 6 | 7 | public interface IVisitable { 8 | void Accept(IVisitor visitor); 9 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/BaseStats.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | [CreateAssetMenu(fileName = "BaseStats", menuName = "Stats/BaseStats")] 4 | public class BaseStats : ScriptableObject { 5 | public int attack = 10; 6 | public int defense = 20; 7 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Bootstrapper.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using UnityServiceLocator; 3 | 4 | public class Bootstrapper : MonoBehaviour { 5 | void Awake() { 6 | ServiceLocator.Global.Register(new StatModifierFactory()); 7 | } 8 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Entity.cs: -------------------------------------------------------------------------------- 1 | using Sirenix.OdinInspector; 2 | using UnityEngine; 3 | 4 | public abstract class Entity : MonoBehaviour, IVisitable { 5 | [SerializeField, InlineEditor, Required] BaseStats baseStats; 6 | public Stats Stats { get; private set; } 7 | 8 | void Awake() { 9 | Stats = new Stats(new StatsMediator(), baseStats); 10 | } 11 | 12 | public void Update() { 13 | Stats.Mediator.Update(Time.deltaTime); 14 | } 15 | 16 | public void Accept(IVisitor visitor) => visitor.Visit(this); 17 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Pickup.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public abstract class Pickup : MonoBehaviour, IVisitor { 4 | protected abstract void ApplyPickupEffect(Entity entity); 5 | 6 | public void Visit(T visitable) where T : Component, IVisitable { 7 | if (visitable is Entity entity) { 8 | ApplyPickupEffect(entity); 9 | } 10 | } 11 | 12 | public void OnTriggerEnter(Collider other) { 13 | other.GetComponent()?.Accept(this); 14 | Debug.Log("Picked up " + name); 15 | Destroy(gameObject); 16 | } 17 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/OperationStrategy.cs: -------------------------------------------------------------------------------- 1 | public interface IOperationStrategy { 2 | int Calculate(int value); 3 | } 4 | 5 | public class AddOperation : IOperationStrategy { 6 | readonly int value; 7 | 8 | public AddOperation(int value) { 9 | this.value = value; 10 | } 11 | 12 | public int Calculate(int value) => value + this.value; 13 | } 14 | 15 | public class MultiplyOperation : IOperationStrategy { 16 | readonly int value; 17 | 18 | public MultiplyOperation(int value) { 19 | this.value = value; 20 | } 21 | 22 | public int Calculate(int value) => value * this.value; 23 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stats and Modifiers System for Unity 2 | ![BrokerChain3](https://github.com/adammyhre/Unity-Stats-and-Modifiers/assets/38876398/4fc492cb-bdde-402b-9c1c-28db14c35a28) 3 | 4 | Easy to use Stats and Modifiers implemented using the Broker Chain programming pattern. 5 | 6 | ## YouTube 7 | Watch the implementation and explanation on YouTube in 2 parts: 8 | 9 | - [Part 1: EASY Stats and Modifiers (Broker Chain Pattern)](https://youtu.be/gYYfrtq6MrA) 10 | - [Part 2: Refactoring to Add Features and Optimizations](https://youtu.be/ZJI6USyLtLo) 11 | 12 | Join us on the **git-amend** [YouTube channel](https://www.youtube.com/@git-amend?sub_confirmation=1) for weekly videos and other cool stuff. 13 | -------------------------------------------------------------------------------- /Assets/_Project/Scripts/StatModifierFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | public interface IStatModifierFactory { 4 | StatModifier Create(OperatorType operatorType, StatType statType, int value, float duration); 5 | } 6 | 7 | public class StatModifierFactory : IStatModifierFactory { 8 | public StatModifier Create(OperatorType operatorType, StatType statType, int value, float duration) { 9 | IOperationStrategy strategy = operatorType switch { 10 | OperatorType.Add => new AddOperation(value), 11 | OperatorType.Multiply => new MultiplyOperation(value), 12 | _ => throw new ArgumentOutOfRangeException() 13 | }; 14 | 15 | return new StatModifier(statType, strategy, duration); 16 | } 17 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/StatModifierPickup.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using UnityServiceLocator; 3 | 4 | public enum OperatorType { Add, Multiply } 5 | 6 | [RequireComponent(typeof(AudioSource))] 7 | public class StatModifierPickup : Pickup { 8 | 9 | // TODO Move configuration to ScriptableObject 10 | [SerializeField] StatType type = StatType.Attack; 11 | [SerializeField] OperatorType operatorType = OperatorType.Add; 12 | [SerializeField] int value = 10; 13 | [SerializeField] float duration = 5f; 14 | 15 | protected override void ApplyPickupEffect(Entity entity) { 16 | StatModifier modifier = ServiceLocator.For(this).Get().Create(operatorType, type, value, duration); 17 | entity.Stats.Mediator.AddModifier(modifier); 18 | } 19 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/StatModifierApplicationOrder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | public interface IStatModifierApplicationOrder { 5 | int Apply(IEnumerable statModifiers, int baseValue); 6 | } 7 | 8 | public class NormalStatModifierOrder : IStatModifierApplicationOrder { 9 | public int Apply(IEnumerable statModifiers, int baseValue) { 10 | var allModifiers = statModifiers.ToList(); 11 | 12 | foreach (var modifier in allModifiers.Where(modifier => modifier.Strategy is AddOperation)) { 13 | baseValue = modifier.Strategy.Calculate(baseValue); 14 | } 15 | 16 | foreach (var modifier in allModifiers.Where(modifier => modifier.Strategy is MultiplyOperation)) { 17 | baseValue = modifier.Strategy.Calculate(baseValue); 18 | } 19 | 20 | return baseValue; 21 | } 22 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/Stats.cs: -------------------------------------------------------------------------------- 1 | public enum StatType { Attack, Defense } 2 | 3 | public class Stats { 4 | readonly StatsMediator mediator; 5 | readonly BaseStats baseStats; 6 | 7 | public StatsMediator Mediator => mediator; 8 | 9 | public int Attack { 10 | get { 11 | var q = new Query(StatType.Attack, baseStats.attack); 12 | mediator.PerformQuery(this, q); 13 | return q.Value; 14 | } 15 | } 16 | 17 | public int Defense { 18 | get { 19 | var q = new Query(StatType.Defense, baseStats.defense); 20 | mediator.PerformQuery(this, q); 21 | return q.Value; 22 | } 23 | } 24 | 25 | public Stats(StatsMediator mediator, BaseStats baseStats) { 26 | this.mediator = mediator; 27 | this.baseStats = baseStats; 28 | } 29 | 30 | public override string ToString() => $"Attack: {Attack}, Defense: {Defense}"; 31 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/StatModifier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | 4 | public class StatModifier : IDisposable { 5 | public StatType Type { get; } 6 | public IOperationStrategy Strategy { get; } 7 | 8 | public readonly Sprite icon; 9 | public bool MarkedForRemoval { get; set; } // TODO: Make private and add a public method to set it 10 | 11 | public event Action OnDispose = delegate { }; 12 | 13 | readonly CountdownTimer timer; 14 | 15 | public StatModifier(StatType type, IOperationStrategy strategy, float duration) { 16 | Type = type; 17 | Strategy = strategy; 18 | if (duration <= 0) return; 19 | 20 | timer = new CountdownTimer(duration); 21 | timer.OnTimerStop += () => MarkedForRemoval = true; 22 | timer.Start(); 23 | } 24 | 25 | public void Update(float deltaTime) => timer?.Tick(deltaTime); 26 | 27 | public void Handle(object sender, Query query) { 28 | if (query.StatType == Type) { 29 | query.Value = Strategy.Calculate(query.Value); 30 | } 31 | } 32 | 33 | public void Dispose() { 34 | OnDispose.Invoke(this); 35 | } 36 | } -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Stats/StatsMediator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | public class StatsMediator { 5 | readonly List listModifiers = new(); 6 | readonly Dictionary> modifiersCache = new(); 7 | readonly IStatModifierApplicationOrder order = new NormalStatModifierOrder(); // OR INJECT 8 | 9 | public void PerformQuery(object sender, Query query) { 10 | if (!modifiersCache.ContainsKey(query.StatType)) { 11 | modifiersCache[query.StatType] = listModifiers.Where(modifier => modifier.Type == query.StatType).ToList(); 12 | } 13 | query.Value = order.Apply(modifiersCache[query.StatType], query.Value); 14 | } 15 | 16 | void InvalidateCache(StatType statType) { 17 | modifiersCache.Remove(statType); 18 | } 19 | 20 | public void AddModifier(StatModifier modifier) { 21 | listModifiers.Add(modifier); 22 | InvalidateCache(modifier.Type); 23 | modifier.MarkedForRemoval = false; 24 | 25 | modifier.OnDispose += _ => InvalidateCache(modifier.Type); 26 | modifier.OnDispose += _ => listModifiers.Remove(modifier); 27 | } 28 | 29 | public void Update(float deltaTime) { 30 | foreach (var modifier in listModifiers) { 31 | modifier.Update(deltaTime); 32 | } 33 | 34 | foreach (var modifier in listModifiers.Where(modifier => modifier.MarkedForRemoval).ToList()) { 35 | modifier.Dispose(); 36 | } 37 | } 38 | } 39 | 40 | public class Query { 41 | public readonly StatType StatType; 42 | public int Value; 43 | 44 | public Query(StatType statType, int value) { 45 | StatType = statType; 46 | Value = value; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This .gitignore file should be placed at the root of your Unity project directory 2 | # 3 | # Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore 4 | # 5 | /[Ll]ibrary/ 6 | /[Tt]emp/ 7 | /[Oo]bj/ 8 | /[Bb]uild/ 9 | /[Bb]uildFullScreen/ 10 | /[Bb]uilds/ 11 | /[Ll]ogs/ 12 | /[Uu]ser[Ss]ettings/ 13 | /CCDBuildData/ 14 | 15 | # Ignore everything under Assets except the _Project folder 16 | Assets/* 17 | !Assets/_Project/ 18 | 19 | # MemoryCaptures can get excessive in size. 20 | # They also could contain extremely sensitive data 21 | /[Mm]emoryCaptures/ 22 | 23 | # Recordings can get excessive in size 24 | /[Rr]ecordings/ 25 | 26 | # Uncomment this line if you wish to ignore the asset store tools plugin 27 | # /[Aa]ssets/AssetStoreTools* 28 | 29 | # Autogenerated Jetbrains Rider plugin 30 | /[Aa]ssets/Plugins/Editor/JetBrains* 31 | 32 | # Visual Studio cache directory 33 | .vs/ 34 | 35 | # Gradle cache directory 36 | .gradle/ 37 | 38 | # Autogenerated VS/MD/Consulo solution and project files 39 | ExportedObj/ 40 | .consulo/ 41 | *.csproj 42 | *.unityproj 43 | *.sln 44 | *.suo 45 | *.tmp 46 | *.user 47 | *.userprefs 48 | *.pidb 49 | *.booproj 50 | *.svd 51 | *.pdb 52 | *.mdb 53 | *.opendb 54 | *.VC.db 55 | 56 | # Unity3D generated meta files 57 | *.pidb.meta 58 | *.pdb.meta 59 | *.mdb.meta 60 | 61 | # Unity3D generated file on crash reports 62 | sysinfo.txt 63 | 64 | # Builds 65 | *.apk 66 | *.aab 67 | *.unitypackage 68 | *.app 69 | 70 | # Crashlytics generated file 71 | crashlytics-build.properties 72 | 73 | # Packed Addressables 74 | /[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin* 75 | 76 | # Temporary auto-generated Android Assets 77 | /[Aa]ssets/[Ss]treamingAssets/aa.meta 78 | /[Aa]ssets/[Ss]treamingAssets/aa/* 79 | 80 | # Custom 81 | Assets/SceneDependencyCache* 82 | Assets/NetCodeGenerated* 83 | .idea/ 84 | .DS_Store 85 | RiderScriptEditorPersistedState.asset 86 | Packages/packages-lock.json 87 | -------------------------------------------------------------------------------- /Assets/_Project/Scripts/Utilities/Timer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | public abstract class Timer { 4 | protected float initialTime; 5 | public float Time { get; set; } 6 | public bool IsRunning { get; protected set; } 7 | 8 | public float Progress => Time / initialTime; 9 | 10 | public Action OnTimerStart = delegate { }; 11 | public Action OnTimerStop = delegate { }; 12 | 13 | protected Timer(float value) { 14 | initialTime = value; 15 | IsRunning = false; 16 | } 17 | 18 | public void Start() { 19 | Time = initialTime; 20 | if (!IsRunning) { 21 | IsRunning = true; 22 | OnTimerStart.Invoke(); 23 | } 24 | } 25 | 26 | public void Stop() { 27 | if (IsRunning) { 28 | IsRunning = false; 29 | OnTimerStop.Invoke(); 30 | } 31 | } 32 | 33 | public void Resume() => IsRunning = true; 34 | public void Pause() => IsRunning = false; 35 | 36 | public abstract void Tick(float deltaTime); 37 | } 38 | 39 | public class CountdownTimer : Timer { 40 | public CountdownTimer(float value) : base(value) { } 41 | 42 | public override void Tick(float deltaTime) { 43 | if (IsRunning && Time > 0) { 44 | Time -= deltaTime; 45 | } 46 | 47 | if (IsRunning && Time <= 0) { 48 | Stop(); 49 | } 50 | } 51 | 52 | public bool IsFinished => Time <= 0; 53 | 54 | public void Reset() => Time = initialTime; 55 | 56 | public void Reset(float newTime) { 57 | initialTime = newTime; 58 | Reset(); 59 | } 60 | } 61 | 62 | public class StopwatchTimer : Timer { 63 | public StopwatchTimer() : base(0) { } 64 | 65 | public override void Tick(float deltaTime) { 66 | if (IsRunning) { 67 | Time += deltaTime; 68 | } 69 | } 70 | 71 | public void Reset() => Time = 0; 72 | 73 | public float GetTime() => Time; 74 | } --------------------------------------------------------------------------------