How we did the tutorial system in Academia: School Simulator, Part 2: Actions

In part 1 of this post, I discussed about the underlying framework used for our tutorial system for Academia. I’ll continue it here on how we made the tutorial actions.

Locking or unlocking an interaction UI

In Unity, there’s a base class for all components that makes a UI interactable. It’s called Selectable. The components Button and Toggle are child classes of it. To lock a Selectable, we simple set the property Selectable.interactable to false. Set it back to true to unlock it.

To make a system that locks or unlocks these UI items at a whim, all we have to do is manage Selectable instances. We have one script that acts as the manager of Selectable components. We then use signals (see here and here) to register selectable items. We also use the same mechanism to later on lock or unlock them during the tutorial mode.

Here are the signals that we used for this system:

public static readonly TypedSignal<RegisterLockItem> REGISTER_LOCK_ITEM = new TypedSignal<RegisterLockItem>();
public static readonly Signal LOCK_ALL_ITEMS = new Signal("LockAllItems");
public static readonly Signal UNLOCK_ALL_ITEMS = new Signal("UnlockAllItems");
public static readonly TypedSignal<UiLockingParameter> LOCK_UI_ITEM = new TypedSignal<UiLockingParameter>();
public static readonly TypedSignal<UiLockingParameter> UNLOCK_UI_ITEM = new TypedSignal<UiLockingParameter>();

Here are the parameters of the TypedSignals:

public readonly struct RegisterLockItem {
    public readonly Selectable selectable;
    public readonly Option<string> id;

    public RegisterLockItem(Selectable selectable, string id) {
        this.selectable = selectable;
        this.id = string.IsNullOrEmpty(id) ? Option<string>.NONE : Option<string>.Some(id);
    }
}

public readonly struct UiLockingParameter {
    public readonly string id;

    public UiLockingParameter(string id) {
        this.id = id;
    }
}

We use Option for the ID when registering selectables because it is optional. Not all selectables will be referenced during the tutorial. We lock/unlock only a few of them so we don’t actually need all registered selectables to have an ID. We register selectables without an ID because we just want them to be locked when we dispatch the signal LOCK_ALL_ITEMS or gets unlocked again on dispatch of UNLOCK_ALL_ITEMS.

Here’s the script that manages the selectables that can be locked/unlocked:

public class UiLockSystem : SignalHandlerAcademiaComponent {
    // Container of all selectables that have ID or not
    private readonly SimpleList<Selectable> selectables = new SimpleList<Selectable>(50);
    
    // Selectables with ID. Not all will have IDs.
    private readonly Dictionary<string, Selectable> selectableMap = new Dictionary<string, Selectable>(50);
    
    protected override void Awake() {
        base.Awake();
        
        AddSignalListener(GameSignals.LOCK_ALL_ITEMS, LockAll);
        AddSignalListener(GameSignals.UNLOCK_ALL_ITEMS, UnlockAll);
        
        GameSignals.REGISTER_LOCK_ITEM.AddListener(RegisterItem);
        GameSignals.LOCK_UI_ITEM.AddListener(LockUiItem);
        GameSignals.UNLOCK_UI_ITEM.AddListener(UnlockUiItem);
    }

    protected override void OnDestroy() {
        base.OnDestroy();
        
        GameSignals.REGISTER_LOCK_ITEM.RemoveListener(RegisterItem);
        GameSignals.LOCK_UI_ITEM.RemoveListener(LockUiItem);
        GameSignals.UNLOCK_UI_ITEM.RemoveListener(UnlockUiItem);
    }

    private void RegisterItem(RegisterLockItem parameter) {
        // Add to list
        this.selectables.Add(parameter.selectable);
        
        // Add to map if id exists
        parameter.id.Match(new AddToMap(this, parameter.selectable));
    }

    private readonly struct AddToMap : IOptionMatcher<string> {
        private readonly UiLockSystem system;
        private readonly Selectable selectable;

        public AddToMap(UiLockSystem system, Selectable selectable) {
            this.system = system;
            this.selectable = selectable;
        }

        public void OnSome(string id) {
            Assertion.Assert(!this.system.selectableMap.ContainsKey(id), id);
            this.system.selectableMap[id] = this.selectable;
        }

        public void OnNone() {
            // Does nothing when no ID is specified
        }
    }

    private void LockUiItem(UiLockingParameter parameter) {
        // Should exist
        Selectable selectable = this.selectableMap[parameter.id];
        selectable.interactable = false;
    }

    private void UnlockUiItem(UiLockingParameter parameter) {
        // Should exist
        if (this.selectableMap.TryGetValue(parameter.id, out Selectable selectable)) {
            selectable.interactable = true;
        } else {
            Debug.LogError($"Selectable not found: {parameter.id}");
        }
    }

    private void LockAll(ISignalParameters parameters) {
        for (int i = 0; i < this.selectables.Count; ++i) {
            this.selectables[i].interactable = false;
        }
    }

    private void UnlockAll(ISignalParameters parameters) {
        for (int i = 0; i < this.selectables.Count; ++i) {
            this.selectables[i].interactable = true;
        }
    }
}

This manager uses two types of containers, a list and a dictionary. The list is used to contain all registered Selectables whether they have an ID or not. The list is used for cases when we want to lock or unlock all registered selectables. We usually lock all selectables at the start of the tutorial and only unlock the one that the player needs to interact. We unlock all selectables when the tutorial is done. The dictionary is used for those that have an ID. It’s used for cases when we want to lock or unlock only a specific selectable.

We use signals to interact with this manager because we employ multiple scenes for our UI (we use this pattern a lot actually, even for non UI cases).

To register a Selectable, we used a simple custom script that we can just add to the button or toggle object that needs to be registered. I specifically made this script so I don’t have to touch a lot of code if I want to register a selectable. I just add this script to the GameObject that has the Selectable component in the editor.

public class UiLockItem : MonoBehaviour {
    [SerializeField]
    private Selectable selectable;

    // May or may not be specified
    [SerializeField]
    private string id;

    [SerializeField]
    private bool autoRegister = true;

    private void Awake() {
        Assertion.AssertNotNull(this.selectable);
    }

    private void Start() {
        if (this.autoRegister) {
            Register();
        }
    }

    public void Register() {
        GameSignals.REGISTER_LOCK_ITEM.Dispatch(new RegisterLockItem(this.selectable, this.id));
    }

    public Selectable Selectable {
        get {
            return this.selectable;
        }
    }

    public string Id {
        get {
            return this.id;
        }

        // We provided a setter because some UI elements might be instantiated at runtime
        set {
            Assertion.Assert(string.IsNullOrEmpty(this.id)); // ID should only be set once
            this.id = value;
        }
    }
}

Notice here that we just dispatch a register signal for the Register() method. We added the option to auto register or not because some selectables are found in prefabs that are instantiated at runtime. For such cases, we set the autoRegister to false then set the ID (we only know the unique ID upon instantiation) when instantiated then manually call Register().

So we have manager that manages the selectables and the registration of selectables. All that’s left now are the SSM actions implementation that will be used by the editor. Here’s how they are implemented:

[Group("Game.Tutorial")]
public class LockUiItem : SsmAction {
    public NamedString id { get; set; }

    public override void OnEnter() {
        GameSignals.LOCK_UI_ITEM.Dispatch(new UiLockingParameter(this.id.Value));
    }
}

[Group("Game.Tutorial")]
public class UnlockUiItem : SsmAction {
    public NamedString id { get; set; }

    public override void OnEnter() {
        GameSignals.UNLOCK_UI_ITEM.Dispatch(new UiLockingParameter(this.id.Value));
    }
}

[Group("Game.Tutorial")]
public class UnlockAllItems : SsmAction {
    public override void OnEnter() {
        GameSignals.UNLOCK_ALL_ITEMS.Dispatch();
    }
}

[Group("Game.Tutorial")]
public class DisableEverything : SsmAction {
    public override void OnEnter() {
        GameSignals.LOCK_ALL_ITEMS.Dispatch();
        ... // Other stuff that are disabled
    }
}

The SSM actions are just wrappers to dispatching signals. The [Group] attribute will be used by the editor to group actions. Types like NamedString is a custom implementation such that it will be picked up by our editor system to automatically render a text field when such action is added (I haven’t written about this yet). It will look like this in the editor:

Highlighting an interaction UI

Another common action during a tutorial mode is highlighting a UI element that needs to be interacted. We do this simply by blinking the background color or sprite of such UI element.

Here’s our Blinker script:

public class Blinker : AcademiaComponent {
    [SerializeField]
    private Image image;

    [SerializeField]
    private Color normalColor = ColorUtils.WHITE;

    [SerializeField]
    private Color highlightColor = ColorUtils.WHITE;

    [SerializeField]
    private bool useHighlightSprite;

    [SerializeField]
    private Sprite highlightSprite;

    [Tooltip("Enable if blinking is not triggered via code.")]
    [SerializeField]
    private bool startInNewGame;

    private Sprite defaultSprite;

    private Fsm fsm;

    // Starting State
    private const string START_BLINKING = "StartBlinking";

    // Events
    private const string FINISHED = "Finished";
    private const string WAIT = "Wait";
    private const string DONE_BLINKING = "DoneBlinking";

    private static readonly FloatGameVariable BLINK_SPEED = new FloatGameVariable("BlinkSpeed");

    protected virtual void Awake() {
        PrepareFsm();

        if (this.useHighlightSprite) {
            this.defaultSprite = this.image.sprite;
        }
        
        GameSignals.NEW_GAME_LOADED.AddListener(NewGameLoaded);
    }

    private void OnDestroy() {
        GameSignals.NEW_GAME_LOADED.RemoveListener(NewGameLoaded);
    }

    private void NewGameLoaded(ISignalParameters parameters) {
        if (this.startInNewGame) {
            this.fsm.Start(START_BLINKING);
        }
    }

    // Cached states
    private FsmState blinkState;
    private FsmState waitState;

    private void PrepareFsm() {
        this.fsm = new Fsm("BlinkerFsm.fsm");

        // States
        this.blinkState = this.fsm.AddState(START_BLINKING);
        this.waitState = this.fsm.AddState("WaitState");
        FsmState doneState = this.fsm.AddState("DoneState");

        // Actions
        {
            TimedWaitAction timedWaitAction = new TimedWaitAction(this.waitState, TimeReferenceManager.UI, FINISHED);
            timedWaitAction.Init(BLINK_SPEED.Value);

            this.waitState.AddAction(timedWaitAction);
        }

        {
            this.blinkState.AddAction(new FsmDelegateAction(this.blinkState, delegate {
                ToggleHighlight();
                this.blinkState.SendEvent(WAIT);
            }));
        }

        {
            // To make sure that the object will end on its normal color
            doneState.AddAction(new FsmDelegateAction(doneState, delegate {
                ShowAsNormal();
            }));
        }

        // Transitions
        this.blinkState.AddTransition(WAIT, this.waitState);
        this.blinkState.AddTransition(DONE_BLINKING, doneState);

        this.waitState.AddTransition(FINISHED, this.blinkState);
        this.waitState.AddTransition(DONE_BLINKING, doneState);
    }

    protected virtual void ShowAsNormal() {
        Assertion.AssertNotNull(this.image);
        
        if (this.useHighlightSprite) {
            this.image.sprite = this.defaultSprite;
        } else {
            this.image.color = this.normalColor;
        }
    }

    protected virtual void ToggleHighlight() {
        Assertion.AssertNotNull(this.image);
        
        if (this.useHighlightSprite) {
            this.image.sprite = this.image.sprite != this.highlightSprite ? this.highlightSprite : this.defaultSprite;
        } else {
            // Using image color
            this.image.color = this.image.color == this.normalColor ? this.highlightColor : this.normalColor;
        }
    }

    private void Update() {
        this.fsm.Update();
    }

    public void SetDefaultSprite(Sprite newSprite) {
        if (this.image != null) {
            this.image.sprite = newSprite;
        }

        this.defaultSprite = newSprite;
    }

    public void StartBlinking() {
        this.fsm.Start(START_BLINKING);
    }

    public void StopBlinking() {
        FsmState currentState = this.fsm.GetCurrentState();
        if (currentState == this.blinkState || currentState == this.waitState) {
            this.fsm.SendEvent(DONE_BLINKING);
        }

        this.fsm.Stop();
    }
}

What it does is it that it changes the color of the specified Image reference to the highlightColor then back again to normalColor after some time interval stored in BLINK_SPEED. A highlight sprite could also be used instead of a color. This script makes use of our FSM framework.

Like locking/unlocking UI, we use signals to control when to “blink” a UI item. Here are the responsible signals and parameter:

public static readonly TypedSignal<UiHighlightParameter> TURN_ON_UI_HIGHLIGHT = new TypedSignal<UiHighlightParameter>();
public static readonly TypedSignal<UiHighlightParameter> TURN_OFF_UI_HIGHLIGHT = new TypedSignal<UiHighlightParameter>();
public static readonly Signal TURN_OFF_ALL_HIGHLIGHT_ITEMS = new Signal("TurnOffHighlightItems");

// It just wraps an ID
public readonly struct UiHighlightParameter {
    public readonly string id;

    public UiHighlightParameter(string id) {
        this.id = id;
    }
}

Also like the lock/unlock system, there’s a manager that handles the Blinker instances.

public class UiHighlightSystem : SignalHandlerAcademiaComponent {
    public static readonly TypedSignal<RegisterUiHighlight> REGISTER_UI_HIGHLIGHT = new TypedSignal<RegisterUiHighlight>();
    public static readonly TypedSignal<UnregisterUiHighlight> UNREGISTER_UI_HIGHLIGHT = new TypedSignal<UnregisterUiHighlight>();
    
    private readonly Dictionary<string, Blinker> map = new Dictionary<string, Blinker>(10);

    protected override void Awake() {
        base.Awake();
        
        AddSignalListener(GameSignals.TURN_OFF_ALL_HIGHLIGHT_ITEMS, TurnOffAllItems);
        
        GameSignals.TURN_ON_UI_HIGHLIGHT.AddListener(TurnOn);
        GameSignals.TURN_OFF_UI_HIGHLIGHT.AddListener(TurnOff);
        
        REGISTER_UI_HIGHLIGHT.AddListener(Register);
        UNREGISTER_UI_HIGHLIGHT.AddListener(Unregister);
    }

    protected override void OnDestroy() {
        base.OnDestroy();
        
        GameSignals.TURN_ON_UI_HIGHLIGHT.RemoveListener(TurnOn);
        GameSignals.TURN_OFF_UI_HIGHLIGHT.RemoveListener(TurnOff);
        
        REGISTER_UI_HIGHLIGHT.RemoveListener(Register);
        UNREGISTER_UI_HIGHLIGHT.RemoveListener(Unregister);
    }

    private void Register(RegisterUiHighlight parameter) {
        // Assert only if replacement is not allowed
        if (!parameter.allowReplace) {
            Assertion.Assert(!this.map.ContainsKey(parameter.id), parameter.id, parameter.blinker.gameObject);
        }

        this.map[parameter.id] = parameter.blinker;
    }

    private void Unregister(UnregisterUiHighlight parameter) {
        this.map.Remove(parameter.id);
    }

    private void TurnOn(UiHighlightParameter parameter) {
        if (this.map.TryGetValue(parameter.id, out Blinker blinker)) {
            blinker.StartBlinking();
        }
    }

    private void TurnOff(UiHighlightParameter parameter) {
        if (this.map.TryGetValue(parameter.id, out Blinker blinker)) {
            blinker.StopBlinking();
        }
    }

    private void TurnOffAllItems(ISignalParameters parameters) {
        foreach (KeyValuePair<string, Blinker> entry in this.map) {
            entry.Value.StopBlinking();
        }
    }
}

Basically, it maintains Blinker instances in a Dictionary using their ID as the key. It handles the signals for turning on/off a UI item and the one that turns off all items. This manager is also responsible for registering Blinker instances using the signals REGISTER_UI_HIGHLIGHT and UNREGISTER_UI_HIGHLIGHT.

To register a certain Blinker, we use a companion script that does the registering. What we do then is add this component to an object with Blinker in the editor if we want it to be controlled by the tutorial actions.

public class UiHighlightItem : AcademiaComponent {
    [SerializeField]
    private string id;

    [SerializeField]
    private Blinker blinker;

    [SerializeField]
    private bool autoRegister = true;

    private void Start() {
        // We don't always register at Start because some items might have their IDs set procedurally
        if (this.autoRegister) {
            Register();
        }
    }

    public void Register() {
        Assertion.AssertNotEmpty(this.id);
        Assertion.AssertNotNull(this.blinker);
        UiHighlightSystem.REGISTER_UI_HIGHLIGHT.Dispatch(new RegisterUiHighlight(this.id, this.blinker));
    }

    public void Unregister() {
        Assertion.AssertNotEmpty(this.id);
        UiHighlightSystem.UNREGISTER_UI_HIGHLIGHT.Dispatch(new UnregisterUiHighlight(this.id));
    }

    public string Id {
        get {
            return this.id;
        }
        
        set {
            // Shouldn't be able to replace an existing ID
            Assertion.Assert(string.IsNullOrEmpty(this.id), this.id);
            this.id = value;
        }
    }
}

Finally, we make the SsmAction that will turn on/off the blinking:

[Group("Game.Tutorial")]
public class TurnOnUiHighlight : SsmAction {
    public NamedString id { get; set; }

    public override void OnEnter() {
        GameSignals.TURN_ON_UI_HIGHLIGHT.Dispatch(new UiHighlightParameter(this.id.Value));
    }

    public override void OnExit() {
        // Automatically turn off on exit
        GameSignals.TURN_OFF_UI_HIGHLIGHT.Dispatch(new UiHighlightParameter(this.id.Value));
    }
}

As you can see, it just uses the signals the turn on/off the blinking. It automatically turns off the blinking on exit of the current state. It looks like this on our editor:

Show/hide tutorial messages

Tutorial messages are these floating green panels that contains the instruction or description of a certain tutorial step. Hiding or showing these messages are also controlled by the tutorial system.

We’re using multiple scenes in our game. Managing these message panels in a single scene for each topic comes naturally.

A scene where all tutorial messages are placed.

What you see here are all tutorial messages for each step in a single tutorial topic. We already positioned each panel to where they would appear. One main reason we did it this way is that the messages may have different UI anchors depending on the position of UI element that they are describing. Another reason is… we’re lazy. 🙂 It’s just easier this way. Remember that you can load multiple scenes in Unity. What we do is we also load the scene of the UI item being described so we can position the corresponding message panel correctly.

To hide/show a tutorial message, we simple deactivate/activate the root object of the message. The implementation of how we manage this is mostly the same with the previous sections. Here are the signals and parameter:

public static readonly TypedSignal<RegisterUiVisibility> REGISTER_UI_VISIBILITY = new TypedSignal<RegisterUiVisibility>();
public static readonly TypedSignal<RegisterUiVisibility> REMOVE_UI_VISIBILITY = new TypedSignal<RegisterUiVisibility>();
public static readonly TypedSignal<SetUiVisibility> SET_UI_VISIBILITY = new TypedSignal<SetUiVisibility>();
public static readonly Signal HIDE_ALL_VISIBILITY_ITEMS = new Signal("HideAllVisibilityItems");

public readonly struct RegisterUiVisibility {
    public readonly string id;
    public readonly GameObject item; // The root object of the message panel
    
    public RegisterUiVisibility(string id, GameObject item) {
        this.id = id;
        this.item = item;
    } 
}

Here’s what the manager looks like:

public class UiVisibilitySystem : SignalHandlerAcademiaComponent {
    private readonly Dictionary<string, GameObject> map = new Dictionary<string, GameObject>();

    protected override void Awake() {
        base.Awake();
        
        AddSignalListener(GameSignals.HIDE_ALL_VISIBILITY_ITEMS, HideAll);
        
        GameSignals.REGISTER_UI_VISIBILITY.AddListener(Register);
        GameSignals.SET_UI_VISIBILITY.AddListener(SetVisibility);
        GameSignals.REMOVE_UI_VISIBILITY.AddListener(Remove);
    }

    protected override void OnDestroy() {
        base.OnDestroy();
        
        GameSignals.REGISTER_UI_VISIBILITY.RemoveListener(Register);
        GameSignals.SET_UI_VISIBILITY.RemoveListener(SetVisibility);
        GameSignals.REMOVE_UI_VISIBILITY.RemoveListener(Remove);
    }

    private void Register(RegisterUiVisibility parameter) {
        // Add only if it's not found yet
        bool containsKey = this.map.ContainsKey(parameter.id);
        Assertion.Assert(!containsKey);
        if (!containsKey) {
            this.map[parameter.id] = parameter.item;
        }
    }
    
    private void Remove(RegisterUiVisibility parameter) {
        this.map.Remove(parameter.id);
    }

    private void SetVisibility(SetUiVisibility parameter) {
        Assertion.Assert(this.map.TryGetValue(parameter.id, out GameObject go), parameter.id);
        if (go != null) {
            go.SetActive(parameter.visible);
        }
    }

    private void HideAll(ISignalParameters parameters) {
        foreach (KeyValuePair<string, GameObject> entry in this.map) {
            entry.Value.SetActive(false);
        }
    }
}

Then here’s the component that we add to a message panel that will register such UI item to the UiVisibilitySystem:

public class UiVisibilityItem : AcademiaComponent {
    [SerializeField]
    private string id;

    [SerializeField]
    private GameObject targetRootObject;

    [SerializeField]
    private bool hiddenByDefault = true; // Default to hidden

    private void Awake() {
        Assertion.AssertNotEmpty(this.id);
        Assertion.AssertNotNull(this.targetRootObject);
        GameSignals.REGISTER_UI_VISIBILITY.Dispatch(new RegisterUiVisibility(this.id, this.targetRootObject));

        if (this.hiddenByDefault) {
            this.targetRootObject.SetActive(false);
        }
    }

    private void OnDestroy() {
        GameSignals.REMOVE_UI_VISIBILITY.Dispatch(new RegisterUiVisibility(this.id, this.targetRootObject));
    }
}

Finally, here’s the SsmAction that will appear in the editor:

[Group("Game.Tutorial")]
public class ShowUi : SsmAction {
    public NamedString id { get; set; }

    public override void OnEnter() {
        GameSignals.SET_UI_VISIBILITY.Dispatch(new SetUiVisibility(this.id.Value, true));
    }

    public override void OnExit() {
        // Auto hide on exit
        GameSignals.SET_UI_VISIBILITY.Dispatch(new SetUiVisibility(this.id.Value, false));
    }
}

Like the other actions, it merely dispatch the signals to execute the showing and hiding of the UI item. A tutorial message is automatically hidden when its tutorial step is done (OnExit()). Here’s what it looks like in our editor:

Other actions

We can implement any actions that we want by just subclassing from SsmAction class. One common action is dispatching any game signal. There are cases during the tutorial that we want something to happen. To make that something to happen, we just simply dispatch a signal. Here’s how it looks:

// We added the term "SsmAction" on the class name because DispatchSignal class
// name already exists in another system
[Group("Game.Common")]
public class DispatchSignalSsmAction : SsmAction {
    public NamedString signalName { get; set; }

    private Signal signal;

    public override void OnEnter() {
        this.signal ??= GameSignals.GetSignal(this.signalName.Value);
        this.signal.Dispatch();
    }
}

There’s also a case where we want something to happen when a tutorial step is done. This can be implemented by dispatching a signal on exit:

[Group("Game.Common")]
public class DispatchSignalOnExit : SsmAction {
    public NamedString signalName { get; set; }

    private Signal signal;

    public override void OnExit() {
        this.signal ??= GameSignals.GetSignal(this.signalName.Value);
        this.signal.Dispatch();
    }
}

Other actions are very specific for a certain tutorial topic. I’ll just show you the names of the actions:

All the actions in our tutorial system

See you on part 3

I planned this series of articles to only have two parts but this one has become longer than expected. On part 3, I’ll discuss how we did the tutorial flow script, how we load a tutorial topic, and how we did the editor. That’s all for now.

If you like my posts, subscribe to my mailing list!

2 thoughts on “How we did the tutorial system in Academia: School Simulator, Part 2: Actions

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s