From 99ff2b64706a9ff03993d9793557bb89bb43840c Mon Sep 17 00:00:00 2001 From: Joanna May Date: Sun, 18 Dec 2022 16:11:11 -0600 Subject: [PATCH] feat: allow announce to be called on machine and notifier --- GoDotNet.csproj | 2 +- README.md | 87 +++++---------------------------- src/state/Machine.cs | 9 +++- src/state/Notifier.cs | 58 ++++++++++++++++------ test/test/state/NotifierTest.cs | 18 +++++-- 5 files changed, 79 insertions(+), 95 deletions(-) diff --git a/GoDotNet.csproj b/GoDotNet.csproj index 5d14f0b..ddcdfbf 100644 --- a/GoDotNet.csproj +++ b/GoDotNet.csproj @@ -14,7 +14,7 @@ Chickensoft Chickensoft.GoDotNet - 1.2.0-beta8 + 1.3.0-beta8 GoDotNet release. Godot;State Machine;Deterministic;Finite;FSM;Extensions;Notifier;Listener;Observable;Chickensoft;Gamedev;Utility;Utilities diff --git a/README.md b/README.md index 996b271..89e3b76 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ this.Autoload().NextFrame( GoDotNet provides a simple state machine implementation that emits a C# event when the state changes (since [Godot signals are more fragile](#signals-and-events)). If you try to update the machine to a state that isn't a valid transition from the current state, it throws an exception. The machine requires an initial state to avoid nullability issues during construction. -State machines are not extensible — instead, GoDotNet almost always prefers the pattern of [composition over inheritance][composition-inheritance]. The state machine relies on state equality to determine if the state has changed to avoid issuing unnecessary events. Using `record` types for the state allows this to happen automatically. +State machines are not extensible — instead, GoDotNet almost always prefers the pattern of [composition over inheritance][composition-inheritance]. The state machine relies on state equality to determine if the state has changed to avoid issuing unnecessary events. Using `record` or other value types for the state makes equality checking work automatically for free. -States used with a state machine must implement `IMachineState`, where T is just the type of the machine state. Your machine states can optionally implement `CanTransitionTo(IMachineState state)`, which should return true if the given "next state" is a valid transition. Otherwise, the default implementation returns `true` to allow transitions to any state. +States used with a state machine must implement `IMachineState`, where T is just the type of the machine state. Your machine states can optionally implement the method `CanTransitionTo(IMachineState state)`, which should return true if the given "next state" is a valid transition. Otherwise, the default implementation returns `true` to allow transitions to any state. To create states for use with a machine, create an interface which implements `IMachineState`. Then, create record types for each state which implement your interface, optionally overriding `CanTransitionTo` for any states which only allow transitions to specific states. @@ -108,14 +108,8 @@ Machines are fairly simple to use: create one with an initial state (and optiona public class GameManager : Node { private readonly Machine _machine; - // Expose the machine's event. - public event Machine.Changed OnChanged { - add => _machine.OnChanged += value; - remove => _machine.OnChanged -= value; - } - public override void _Ready() { - _machine = new Machine(new GameMainMenuState(), onChanged); + _machine = new Machine(new GameMainMenuState(), OnChanged); } /// Starts the game. @@ -157,81 +151,22 @@ public class AnObjectThatOnlyListensToAMachine { ## Notifiers -A notifier is an object which emits a signal when its value changes. Notifiers are similar to state machines, but they don't care about transitions. Any update that changes the value (determined by comparing the new value with the previous value using `Object.Equals`) will emit a signal. It's often convenient to use record types as the value of a Notifier. Like state machines, the value of a notifier can never be `null` — make sure you initialize with a valid value! - -Because notifiers check equality to determine changes, they are convenient to use with "value" types (like primitives, records, and structs). Notifiers, like state machines, also emit a signal to announce their value as soon as they are constructed. - -```csharp -private var _notifier - = new Notifier("Player", OnPlayerNameChanged); - -private void OnPlayerNameChanged(string name) { - _log.Print($"Player name changed to $name"); -} -``` +A notifier is an object which emits a signal when its value changes. Notifiers are similar to state machines, but they don't care about transitions. Any update that changes the value (determined by comparing the new value with the previous value using `Object.Equals`) will emit a signal. Like state machines, the value of a notifier can never be `null` — make sure you initialize with a valid value! -Like state machines, notifiers should typically be kept private. Instead of letting consumers modify the value directly, you can create game logic classes which provide the appropriate methods to mutate the notifier. Game logic classes can provide an event which redirects to the notifier event, or they can emit their own events when certain pieces of the notifier value changes. +Using "value" types (primitive types, records, and structs) with a notifier is a natural fit since notifiers check equality to determine if the value has changed. Like state machines, notifiers also invoke an event to announce their value as soon as they are constructed. ```csharp -public record EnemyData(string Name, int Health); - -public class EnemyManager { - private readonly Notifier _notifier; - - public event Notifier.Changed OnChanged { - add => _notifier.OnChanged += value; - remove => _notifier.OnChanged -= value; - } +var notifier = new Notifier("Player", OnPlayerNameChanged); +notifier.Update("Godot"); - public EnemyData Value => _notifier.Value; +// Elsewhere... - public EnemyManager(string name, int health) => _notifier = new( - new EnemyData(name, health) - ); - - public void UpdateName(string name) => - _notifier.Update(_notifier.Value with { Name = name }); - - public void UpdateHealth(int health) => - _notifier.Update(_notifier.Value with { Health = health }); -} -``` - -The class above shows an enemy state class that emits an `OnChanged` event whenever any part of the enemy's state changes. You can easily modify it to emit more specific events when certain pieces of the enemy state changes. - -```csharp -public class EnemyManager { - private readonly Notifier _notifier; - - public EnemyData Value => _notifier.Value; - - public event Action? OnNameChanged; - public event Action? OnHealthChanged; - - public EnemyManager(string name, int health) => _notifier = new( - new EnemyData(name, health), - OnChanged - ); - - public void UpdateName(string name) => - _notifier.Update(_notifier.Value with { Name = name }); - - public void UpdateHealth(int health) => - _notifier.Update(_notifier.Value with { Health = health }); - - private void OnChanged(EnemyData enemy, EnemyData? previous) { - // Emit different events depending on what changed. - if (!System.Object.Equals(enemy.Name, previous?.Name)) { - OnNameChanged?.Invoke(enemy.Name); - } - else if (!System.Object.Equals(enemy.Health, previous?.Health)) { - OnHealthChanged?.Invoke(enemy.Health); - } - } +private void OnPlayerNameChanged(string name, string previous) { + _log.Print($"Player name changed from ${previous} to ${name}"); } ``` -By providing classes which wrap state machines or notifiers to dependent nodes, you can create nodes which easily respond to changes in the values provided by distant ancestor nodes. To easily provide dependencies to distant child nodes, check out [go_dot_dep]. +> To easily inject dependencies to descendent nodes, check out [go_dot_dep]. ### Read-only Notifiers diff --git a/src/state/Machine.cs b/src/state/Machine.cs index 902002f..7a66d8d 100644 --- a/src/state/Machine.cs +++ b/src/state/Machine.cs @@ -147,5 +147,12 @@ public void Update(TState value) { IsBusy = false; } - private void Announce() => OnChanged?.Invoke(State); + /// + /// Announces the current state to any listeners. + /// calls this automatically if the new state is + /// different from the previous state. + ///
+ /// Call this whenever you want to force a re-announcement. + ///
+ public void Announce() => OnChanged?.Invoke(State); } diff --git a/src/state/Notifier.cs b/src/state/Notifier.cs index a93708b..6038e2a 100644 --- a/src/state/Notifier.cs +++ b/src/state/Notifier.cs @@ -7,19 +7,32 @@ namespace GoDotNet; /// public interface IReadOnlyNotifier { /// - /// Signature of the event handler for when the value changes. The notifier - /// and the previous event are passed in as arguments to make comparing - /// state changes simpler. + /// Signature of the event handler for when the value changes. The current + /// and previous values are provided to allow listeners to react to changes. /// - /// The new value. - /// The previous value. + /// Current value of the notifier. + /// Previous value of the notifier. delegate void Changed(TData current, TData? previous); /// - /// Event emitted when the current notifier value has changed. + /// Signature of the event handler for when the value changes. + /// + /// Current value of the notifier. + delegate void Updated(TData value); + + /// + /// Event emitted when the current value of the notifier has changed. + /// Event listeners will receive both the current and previous values. /// event Changed? OnChanged; + /// + /// Event emitted when the current value of the notifier has changed. + /// Event listeners will receive only the current value. Subscribe to + /// if you need both the current and previous values. + /// + event Updated? OnUpdated; + /// Current notifier value. TData Value { get; } } @@ -42,15 +55,25 @@ public Notifier( ) { if (onChanged != null) { OnChanged += onChanged; } Value = initialValue; - Announce(default); + Previous = default; + Announce(); } + /// + /// Previous value of the notifier, if any. This is `default` when the + /// notifier has just been created. + /// + public TData? Previous { get; private set; } + /// public TData Value { get; private set; } /// public event IReadOnlyNotifier.Changed? OnChanged; + /// + public event IReadOnlyNotifier.Updated? OnUpdated; + /// /// Updates the notifier value. Any listeners will be called with the /// new and previous values if the new value is not equal to the old value @@ -58,14 +81,21 @@ public Notifier( /// /// New value of the notifier. public void Update(TData value) { - var hasChanged = !Object.Equals(Value, value); - var previous = Value; + if (Object.Equals(Value, value)) { return; } + Previous = Value; Value = value; - if (hasChanged) { - Announce(previous); - } + Announce(); } - private void Announce(TData? previous) - => OnChanged?.Invoke(Value, previous); + /// + /// Announces the current and previous values to any listeners. + /// calls this automatically if the new value is + /// different from the previous value. + ///
+ /// Call this whenever you want to force a re-announcement. + ///
+ public void Announce() { + OnChanged?.Invoke(Value, Previous); + OnUpdated?.Invoke(Value); + } } diff --git a/test/test/state/NotifierTest.cs b/test/test/state/NotifierTest.cs index ae07515..377e9f1 100644 --- a/test/test/state/NotifierTest.cs +++ b/test/test/state/NotifierTest.cs @@ -11,6 +11,7 @@ public NotifierTest(Node testScene) : base(testScene) { } public void Instantiates() { var notifier = new Notifier("a"); notifier.Value.ShouldBe("a"); + notifier.Previous.ShouldBeNull(); } [Test] @@ -24,6 +25,7 @@ void OnChanged(string value, string? previous) { var notifier = new Notifier("a", OnChanged); called.ShouldBeTrue(); notifier.Value.ShouldBe("a"); + notifier.Previous.ShouldBeNull(); } [Test] @@ -34,20 +36,30 @@ public void DoesNothingOnSameValue() { notifier.OnChanged += OnChanged; notifier.Update("a"); called.ShouldBeFalse(); + notifier.Previous.ShouldBeNull(); } [Test] public void UpdatesValue() { var notifier = new Notifier("a"); - var called = false; + var calledOnChanged = false; + var calledOnUpdated = false; void OnChanged(string value, string? previous) { - called = true; + calledOnChanged = true; value.ShouldBe("b"); previous.ShouldBe("a"); + notifier.Previous.ShouldBe("a"); + } + void OnUpdated(string value) { + calledOnUpdated = true; + value.ShouldBe("b"); + notifier.Previous.ShouldBe("a"); } notifier.OnChanged += OnChanged; + notifier.OnUpdated += OnUpdated; notifier.Update("b"); - called.ShouldBeTrue(); + calledOnChanged.ShouldBeTrue(); + calledOnUpdated.ShouldBeTrue(); } }