Signals

Signals are a core concept in the Blackboard framework, enabling dynamic data flow and interaction within your application. They are used to represent data that can change over time and trigger updates or actions when these changes occur. This document provides an overview of how signals work within the Blackboard framework, particularly focusing on computed signals as illustrated in the ComputedBlackboard class.

What is a Signal?

A signal is an encapsulation of a value that can change over time. In the Blackboard framework, signals are used to represent data points, such as health levels or status indicators, that other parts of the application need to monitor or react to. Signals can be simple, holding a single value, or computed, deriving their value from other signals.

Simple Signals

Simple signals hold a direct value, like an integer or a boolean. They are straightforward and notify listeners when their value changes. An example of a simple signal could be a health signal representing a character's health points in a game.

A field must adhere to the following conditions to be generated as a signal:

  1. It must be private

  2. The name must be written as camelCase or _camelCase

Computed Signals

Computed signals derive their value from one or more other signals. They automatically update when any of the dependent signals change, ensuring that the computed value is always up to date. Computed signals are powerful tools for creating reactive systems where changes propagate through the application as data changes.

For a computed signal to be generated a method must adhere to the following rules:

  1. It must be private

  2. It must be static

  3. The name must be written as camelCase or _camelCase

Example: Health Status Signals

Consider a scenario where we have two signals: Health and LowHealth. The Health signal represents the current health points of a character, while LowHealth is a threshold value below which the character is considered to be in low health.

The ComputedBlackboard class demonstrates how computed signals can be implemented:

_isAlive: This computed signal returns true if the Health value is greater than 0, indicating the character is alive. _isLowHealth: This computed signal returns true if the Health value is less than or equal to the LowHealth value, indicating the character is in low health.

private static bool _isAlive(Signals.Health health) => health.Value > 0;
private static bool _isLowHealth(Signals.Health health, Signals.LowHealth lowHealth) => health.Value <= lowHealth.Value;

These computed signals enable the application to react dynamically to changes in the character's health, updating game logic, UI, or triggering events based on the character's health status.

List Signals

A list signal is defined similarly to simple signals but is specifically designed to hold a list of items. It can be used to represent any collection of data that needs to be monitored for additions, removals, or changes.

Creating a List Signal

To create a list signal, you define a private field in your blackboard that holds a collection, and the Blackboard system's source generators automatically generate the necessary code to treat it as a signal. This auto-generated code includes methods for adding, removing, and iterating over items in the list, as well as notifying listeners of changes.

Example:

[Serializable]
public partial class InventoryBlackboard : BlackboardBehaviour
{
    private List<Item> _items = new List<Item>();
}

In this example, _items is of type List<>, indicating that it should be treated as a list signal. The Blackboard system will generate code that allows other parts of the game to interact with the _items list in a reactive manner.

Interacting with List Signals

Components can interact with list signals by subscribing to change notifications or by using the generated methods to modify the list. This allows for a decoupled architecture where components can react to changes in the list without directly managing the list itself.

Example:

public class InventoryManager : MonoBehaviour
{
    public InventoryBlackboard Blackboard { get; set; }

    public void AddItem(Item item)
    {
        this.Blackboard.Items.Add(item);
    }

    public void RemoveItem(Item item)
    {
        this.Blackboard.Items.Remove(item);
    }
}

In this example, InventoryManager interacts with the Items list signal on the InventoryBlackboard to add and remove items.

Dictionary Signals

Dictionary signals are similar to list signals but are designed to hold key-value pairs. They are useful for representing data that requires a mapping between keys and values, such as a collection of settings or lookup tables.

Creating a Dictionary Signal

To create a dictionary signal, you define a private field in your blackboard that holds a dictionary, and the Blackboard system's source generators automatically generate the necessary code to treat it as a signal. All the standard dictionary operations, such as adding, removing, and updating key-value pairs, are supported through the generated code.

Example:

[Serializable]
public partial class ExampleBlackboard : BlackboardBehaviour
{
    private Dictionary<string, int> _playerScores_ = new Dictionary<string, int>();
}

Usage in behaviours

Signals are typically used within components that need to react to data changes. The ComputedHealthBehaviour class demonstrates how a component can interact with signals, adjusting the character's health and responding to changes:

DoDamage: Decreases the health signal by 10. Heal: Increases the health signal by 10. These methods modify the underlying signals, which in turn can trigger updates in computed signals or other parts of the application that are listening for changes.

// Simplified example
public class ComputedHealthBehaviour : MonoBehaviour
{
    public ComputedBlackboard Blackboard { get; set; }

    public void DoDamage()
    {
        this.Blackboard.Health -= 10;
    }
    
    public void Heal()
    {
        this.Blackboard.Health += 10;
    }
}

Signal Events

Signals in the CrashKonijn Blackboard system are designed to notify interested parties when the value of a signal changes. Each signal has two primary events associated with it, designed to cater to different needs for observing changes.

OnChanged Event

The OnChanged event is a basic notification that the signal's value has changed. It does not provide the new value of the signal. This event is useful when you only need to know that a change occurred but do not need to know the specifics of the change.

Example usage:

signal.OnChanged += () => Debug.Log("Signal value changed.");

OnValueChanged Event

The OnValueChanged event is more detailed, providing the new value of the signal as a parameter to the event handlers. This is useful when the new value is needed to perform further operations or calculations.

Example usage:

signal.OnValueChanged += (newValue) => Debug.Log($"New signal value: {newValue}");

Determining Value Changes

The system determines that a signal's value has changed through the HasChanged method. This method compares the current value of the signal with the new value being set. If the method determines that the value has indeed changed (i.e., the new value is different from the current value), it triggers the change events (OnChanged and OnValueChanged).

Example implementation:

public T Value {
    get => this.value;
    set { this.setValue(value) };
}

protected void SetValue(T value)
{
    if (this.HasChanged(value))
    {
        this.value = value;
        this.Changed(value);
    }
}

Limitations with Nested Types

It's important to note that changes to nested types within a signal's value do not automatically trigger change events. The HasChanged method typically compares the top-level object references or primitive values. If a nested property or field within an object changes, but the top-level reference remains the same, the system does not recognize this as a change.

For example, if a signal holds an object of a class Person with a property Name, changing the Name property of the existing object does not trigger the signal's change events. To ensure changes are detected, a new instance of the object with the updated values must be set on the signal, or custom logic must be implemented to manually trigger the change events when nested properties are modified.

This behavior ensures performance optimization by avoiding deep checks on complex objects but requires careful consideration when working with nested types to ensure changes are properly detected and communicated.

Signal interfaces

In the Blackboard system, each signal class can have a corresponding interface generated automatically. This feature is particularly useful in the Pro version, where it enables a more flexible and type-safe way of interacting with signals. The generated interface allows for easy mocking in tests and adherence to the SOLID principles, especially the Dependency Inversion Principle.

How It Works

When a new signal class is defined, the source generator checks if the class is part of the Pro version. If so, it generates a matching interface for that signal. This interface is named after the signal class but prefixed with an I. For example, if the signal class is named Health, the generated interface will be named IHealth.

Code Example

Consider the following signal class definition:

[Serializable]
public class Health : FieldSignal<int>
{
    public Health(int value) : base(value, "Health", "healthSignal") { }
}

For the above Health signal, the source generator will produce an interface like this:

public interface IHealth : IFieldSignal<int> { }

Using the Generated Interface

Once the interface is generated, it can be used to interact with the signal in a type-safe manner. Here's how you can use the IHealth interface:

// Assuming ExampleBlackboard is a class that contains the Health signal
var blackboard = this.GetComponent<ExampleBlackboard>();

// Accessing the Health value directly
blackboard.Health = 100;
Debug.Log(blackboard.Health);

// Using the generated interface to interact with the Health signal
IHealth healthSignal = blackboard.HealthSignal;
healthSignal.Value = 100;
Debug.Log(healthSignal.Value);

Advantages of Using Interfaces for Signals

Type Safety: By defining an interface for signals, such as IHealth, you ensure that any method accepting this interface as a parameter can only be passed objects that adhere to the specified contract. This reduces runtime errors and improves code reliability.

  1. Decoupling: Interfaces allow for a decoupling between the signal's implementation and its usage. You can change the underlying implementation of the Health signal without affecting the methods that use it, as long as the interface remains consistent.

  2. Testability: With interfaces, it becomes easier to mock signals in unit tests. You can create mock objects that implement IHealth to test how your methods react to different signal states without needing to instantiate the actual signal classes.

Example Usage

Consider a method within your Unity project that modifies the health of a character based on certain game events:

public void ApplyDamage(IHealth healthSignal, int damageAmount)
{
    healthSignal.Value -= damageAmount;
    Debug.Log($"New Health: {healthSignal.Value}");
}

This method accepts an IHealth interface as a parameter, making it agnostic to the specific implementation of the health signal. You can pass any object that implements IHealth, allowing for a flexible and modular approach to signal management.

To use this method with a signal from your blackboard, you would retrieve the signal reference and pass it as follows:

var blackboard = this.GetComponent<ExampleBlackboard>();
var healthSignal = blackboard.HealthSignal;

ApplyDamage(healthSignal, 20);

In this scenario, ApplyDamage can interact with the health signal through the IHealth interface, adjusting its value and logging the new health. This approach leverages the benefits of interfaces for signals, promoting a clean, maintainable, and testable codebase.

Last updated