Blackboard

Overview

The Blackboard system is a flexible and powerful framework designed for Unity, enabling developers to create and manage game data dynamically. It leverages the concept of a "blackboard" as a central data repository where different components of a game can read and write data, facilitating communication and data sharing among disparate systems without direct references.

Key Concepts

BlackboardBehaviour

BlackboardBehaviour is the base class for any blackboard. It allows the creation of custom blackboards by extending this class. Custom blackboards can contain any number of data fields, known as signals, which are automatically managed by the system.

Signals

Signals are the primary means of communication within the blackboard system. They represent data points on the blackboard, such as health, ammo, or player state. Signals are dynamically generated and managed, allowing for a flexible and scalable approach to data management.

Debugging

The DebugBlackboard attribute can be applied to a BlackboardBehaviour class to enable viewing the generated source for the blackboard in the Unity inspector. This is particularly useful for debugging and understanding the auto-generated aspects of your blackboard.

Creating a Simple Blackboard

Below is an example of how to create a simple blackboard with a single signal representing health.

using CrashKonijn.Blackboard.Contracts;

namespace CrashKonijn.Blackboard.Blackboards.Examples
{
    public partial class BasicBlackboard : BlackboardBehaviour
    {
        private int health = 100;
    }
}

A source generator will generate the following code:

The source generators are automatically run by the Unity compiler!

public partial class ExampleBlackboard : IExampleBlackboard {
    // Prive serialized reference to the signal
    [SerializeField]
    private Signals.Health healthSignal;
    // Wrapper around the signal value that allows you to acces the health value as blackboard.Health;
    public int Health { get => this.healthSignal.Value; set { this.healthSignal.Value = value; } }
    // The reference to the signal
    public Signals.IHealth HealthSignal => this.healthSignal;
    
    public static class Signals {
        // The generated signal
        [Serializable]
        public class Health : FieldSignal<int>, IHealth {
            public Health(int value) : base(value, "Health", "healthSignal") { }
        }
        // An interface matching the signal
        public interface IHealth : IFieldSignal<int> { }
    }
}

// An interface mathing the entire blackboard is also generated!
public interface IExampleBlackboard
{
    public int Health { get; }
    public BasicBlackboard.Signals.IHealth HealthSignal { get; }
}

Interacting with the Blackboard

Components can interact with the blackboard by referencing the BlackboardBehaviour and using the generated signals. Here's an example of a component that modifies the health signal on a blackboard:

public class EventsHealthBehaviour : MonoBehaviour
{
    public EventsBlackboard Blackboard { get; set; }

    public void OnEnable()
    {
        this.Blackboard.HealthSignal.OnValueChanged.AddListener(this.OnHealthChanged);
    }
    
    public void OnDisable()
    {
        this.Blackboard.HealthSignal.OnValueChanged.RemoveListener(this.OnHealthChanged);
    }

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

    public void OnHealthChanged(int health)
    {
        Debug.Log($"Health changed: {health}");
    }
}

Viewing Generated Code

The source generators automatically run by Unity's compilation process add signals and other necessary code to the blackboard. To view the generated code:

  • In Rider: Ctrl + click on the classname.

  • In Visual Studio: Place your cursor on the classname and press F12. The declarations window should show, and you can select the generated partial. The file is named <FileName>_<lite|pro>_generated.cs.

  • Other editors: Unity doesn't support any other editor officially and as such are incapable of viewing the generated code added by source generators. By adding the [DebugBlackboard] attribute to your blackboard the inspector will show two buttons which will allow you to Debug.Log the generated code or to copy it.

IAllAreSignals and ISomeAreSignals Interfaces

In the Blackboard framework, signals are a fundamental concept used to represent data that can change over time, triggering updates or actions when these changes occur. The IAllAreSignals and ISomeAreSignals interfaces play a crucial role in defining how signals are treated within classes that represent data in the Blackboard.

Using these interfaces allows you to add signals to nested classes in a blackboard, or to add signals to any other class!

IAllAreSignals Interface

The IAllAreSignals interface is used to mark a class as a container where all valid fields are considered signals. This means that every field in a class implementing this interface is automatically treated as a signal without the need for explicit marking. This interface is particularly useful when you want to ensure that all properties of an object are reactive and can trigger updates or actions upon changes.

Example Usage

[Serializable]
public partial class Gun : IAllAreSignals
{
    private GunType _type;
}

In the example above, the Gun class is marked with the IAllAreSignals interface, indicating that all of its fields, such as _type, are treated as signals within the Blackboard system.

ISomeAreSignals Interface

Contrary to IAllAreSignals, the ISomeAreSignals interface is used when only some fields within a class should be treated as signals. This interface requires explicit marking of fields that should be considered signals, offering more granular control over which properties are reactive.

Example Usage

[Serializable]
public partial class Bullets : ISomeAreSignals
{
    // This won't be generated as a signal
    // The serializefield makes sure it's saved in the editor
    [SerializeField]
    private int clipSize = 5;
    
    // You manually have to mark the field as a signal
    [Signal]
    [Blackboard.Contracts.Min(0)]
    private int inGun = 5;
    
    // This marks it as a computed signal
    [Signal]
    private static bool _isEmpty(Signals.InGun inGun) => inGun.Value <= 0;

    // Methods won't collide with generated code
    public void Shoot()
    {
        // Make sure you use the property instead of the field
        // Updating the field won't do anything
        this.InGun--;
    }

    public void Reload()
    {
        this.InGun = this.clipSize;
    }
}

In the Bullets class example, the ISomeAreSignals interface is implemented, and only the inGun field is explicitly marked as a signal using the [Signal] attribute. This allows for specific fields to be reactive, while others, like clipSize, remain as regular fields.

Last updated