Skip to content
This repository has been archived by the owner on Aug 26, 2023. It is now read-only.

Commit

Permalink
Merge pull request #7 from chickensoft-games/feat/announce
Browse files Browse the repository at this point in the history
feat: allow announce to be called on machine and notifier
  • Loading branch information
jolexxa authored Dec 18, 2022
2 parents ebf87dd + 99ff2b6 commit 4f765d0
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 95 deletions.
2 changes: 1 addition & 1 deletion GoDotNet.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<Company>Chickensoft</Company>

<PackageId>Chickensoft.GoDotNet</PackageId>
<PackageVersion>1.2.0-beta8</PackageVersion>
<PackageVersion>1.3.0-beta8</PackageVersion>
<PackageReleaseNotes>GoDotNet release.</PackageReleaseNotes>
<PackageIcon></PackageIcon>
<PackageTags>Godot;State Machine;Deterministic;Finite;FSM;Extensions;Notifier;Listener;Observable;Chickensoft;Gamedev;Utility;Utilities</PackageTags>
Expand Down
87 changes: 11 additions & 76 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ this.Autoload<Scheduler>().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<T>`, 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<T>`, 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<IYourInterface>`. Then, create record types for each state which implement your interface, optionally overriding `CanTransitionTo` for any states which only allow transitions to specific states.

Expand Down Expand Up @@ -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<IGameState> _machine;

// Expose the machine's event.
public event Machine<IGameState>.Changed OnChanged {
add => _machine.OnChanged += value;
remove => _machine.OnChanged -= value;
}

public override void _Ready() {
_machine = new Machine<IGameState>(new GameMainMenuState(), onChanged);
_machine = new Machine<IGameState>(new GameMainMenuState(), OnChanged);
}

/// <summary>Starts the game.</summary>
Expand Down Expand Up @@ -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<string>("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<EnemyData> _notifier;

public event Notifier<EnemyData>.Changed OnChanged {
add => _notifier.OnChanged += value;
remove => _notifier.OnChanged -= value;
}
var notifier = new Notifier<string>("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<EnemyData> _notifier;

public EnemyData Value => _notifier.Value;

public event Action<string>? OnNameChanged;
public event Action<int>? 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

Expand Down
9 changes: 8 additions & 1 deletion src/state/Machine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,12 @@ public void Update(TState value) {
IsBusy = false;
}

private void Announce() => OnChanged?.Invoke(State);
/// <summary>
/// Announces the current state to any listeners.
/// <see cref="Update"/> calls this automatically if the new state is
/// different from the previous state.
/// <br />
/// Call this whenever you want to force a re-announcement.
/// </summary>
public void Announce() => OnChanged?.Invoke(State);
}
58 changes: 44 additions & 14 deletions src/state/Notifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,32 @@ namespace GoDotNet;
/// </summary>
public interface IReadOnlyNotifier<TData> {
/// <summary>
/// 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.
/// </summary>
/// <param name="current">The new value.</param>
/// <param name="previous">The previous value.</param>
/// <param name="current">Current value of the notifier.</param>
/// <param name="previous">Previous value of the notifier.</param>
delegate void Changed(TData current, TData? previous);

/// <summary>
/// Event emitted when the current notifier value has changed.
/// Signature of the event handler for when the value changes.
/// </summary>
/// <param name="value">Current value of the notifier.</param>
delegate void Updated(TData value);

/// <summary>
/// Event emitted when the current value of the notifier has changed.
/// Event listeners will receive both the current and previous values.
/// </summary>
event Changed? OnChanged;

/// <summary>
/// Event emitted when the current value of the notifier has changed.
/// Event listeners will receive only the current value. Subscribe to
/// <see cref="OnChanged"/> if you need both the current and previous values.
/// </summary>
event Updated? OnUpdated;

/// <summary>Current notifier value.</summary>
TData Value { get; }
}
Expand All @@ -42,30 +55,47 @@ public Notifier(
) {
if (onChanged != null) { OnChanged += onChanged; }
Value = initialValue;
Announce(default);
Previous = default;
Announce();
}

/// <summary>
/// Previous value of the notifier, if any. This is `default` when the
/// notifier has just been created.
/// </summary>
public TData? Previous { get; private set; }

/// <inheritdoc/>
public TData Value { get; private set; }

/// <inheritdoc/>
public event IReadOnlyNotifier<TData>.Changed? OnChanged;

/// <inheritdoc/>
public event IReadOnlyNotifier<TData>.Updated? OnUpdated;

/// <summary>
/// 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
/// (as determined by Object.Equals).
/// </summary>
/// <param name="value">New value of the notifier.</param>
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);
/// <summary>
/// Announces the current and previous values to any listeners.
/// <see cref="Update"/> calls this automatically if the new value is
/// different from the previous value.
/// <br />
/// Call this whenever you want to force a re-announcement.
/// </summary>
public void Announce() {
OnChanged?.Invoke(Value, Previous);
OnUpdated?.Invoke(Value);
}
}
18 changes: 15 additions & 3 deletions test/test/state/NotifierTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public NotifierTest(Node testScene) : base(testScene) { }
public void Instantiates() {
var notifier = new Notifier<string>("a");
notifier.Value.ShouldBe("a");
notifier.Previous.ShouldBeNull();
}

[Test]
Expand All @@ -24,6 +25,7 @@ void OnChanged(string value, string? previous) {
var notifier = new Notifier<string>("a", OnChanged);
called.ShouldBeTrue();
notifier.Value.ShouldBe("a");
notifier.Previous.ShouldBeNull();
}

[Test]
Expand All @@ -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<string>("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();
}

}

0 comments on commit 4f765d0

Please sign in to comment.