How we did the tutorial system in Academia: School Simulator, Part 3: Putting them all together

In part 2, I discussed how we made the tutorial actions that will be available on the editor. Now we’re on the last part (I promise). I’m going to discuss how we put them all together from the SSM framework discussed in part 1 and the actions in part 2.

Editor

Let me show you the editor of a single tutorial topic:

The editor data is represented as a single Scriptable Object that contains the list of tutorial steps/states (will explain the data later). Each tutorial step has a list of SSM actions that it will execute when the step is activated. In the editor, the list on the sidebar at the left are the list of steps. When a step is selected, an inspector on the right side is shown showing its details. Each step has a transitionSignal which the tutorial flow will listen to to advance to the next step. Each step also has a list of SsmActions to execute. We can add/delete/edit actions or rearrange them.

Actions Browser

An actions browser window will be displayed when the button Add SsmAction… is clicked. We can then pick the action that we want to be added:

Note that the items that can be selected here are already the SsmAction classes that we already created. We are using reflection to make this possible. We already have an editor framework for ease of making these editors. If you follow this blog, you’ll notice that our editors have the same look. That’s because we’re just reusing the same code. I haven’t written about these yet but that’s for another day.

Back in the inspector of the main window, you’ll also notice that there are some parameters rendered for some actions.

Our editor framework does this using reflection. SetCameraFocusSsmAction has these in its code:

[Group("Game.Camera")]
public class SetCameraFocusSsmAction : SsmAction {
    public NamedVector3 position { get; set; }
    public NamedFloat orthoSize { get; set; }

    public override void OnEnter() {
        GameSignals.SET_CAMERA_FOCUS.Dispatch(new SetCameraFocus(
            this.position.Value, new Maybe<float>(this.orthoSize.Value)));
    }
}

Our framework recognizes the properties position and orthoSize and generates the appropriate UI fields for them. The types NamedVector3 and NamedFloat are part of this framework.

Editor Data

The Scriptable Object that the editor is editing is nothing more than a list of tutorial state/step data. Here’s how the state data looks:

[Serializable]
public class TutorialStateData : IDataPoolItem, IDuplicable<TutorialStateData>, IEquatable<TutorialStateData> {
    [SerializeField]
    private string id;
    
    [SerializeField]
    private int intId;

    [SerializeField]
    private string transitionSignal;

    [SerializeField]
    private List<ClassData> actions = new List<ClassData>();

    public string Id {
        get {
            return this.id;
        }
        set {
            this.id = value;
        }
    }

    [Hidden]
    public int IntId {
        get {
            return this.intId;
        }
        set {
            this.intId = value;
        }
    }

    public string TransitionSignal {
        get {
            return this.transitionSignal;
        }
        set {
            this.transitionSignal = value;
        }
    }

    public List<ClassData> Actions {
        get {
            return this.actions;
        }
    }

    ... IEquatable methods omitted
}

The fields id and intId are needed for IDataPoolItem which is used in our framework to easily conjure up an editor for such item. The field transitionSignal is the signal that will be used by the tutorial flow system to move to the next tutorial step. For example, on a tutorial step where player needs to click to Todo button, the transitionSignal would be “OpenTodoPanel” so that the tutorial would move to the next step when this signal is dispatched. To put it in another way, we know that the Todo button was clicked by the player because the “OpenTodoPanel” was dispatched.

The meat of this data is the list of actions. See here that it’s a list of ClassData, not SsmAction. ClassData is part of our framework that can represent any type. It holds data for easier instantiation to an actual instance of the class during runtime using reflection. The data needed for this are just the following:

[Serializable]
public class ClassData {
    [SerializeField]
    private string className;

    [SerializeField]
    private NamedValueLibrary variables;

    ...
}

The field className is the full class name. NamedValueLibrary will hold the values of its Named* properties. (I should write about this framework in another post. It’s beyond the scope of this post for now.) What happens on instantiation is that an instance would be instantiated and the Named* properties of that instances would be populated by the values found in the NamedValueLibrary. The instantiation of a ClassData looks like this:

public static T Instantiate<T>(ClassData data, NamedValueLibrary parentVariables) {
    Type type = TypeIdentifier.GetType(data.ClassName);
    ConstructorInfo constructor = ResolveEmptyConstructor(type);
    T instance = (T) constructor.Invoke(EMPTY_PARAMETERS);
    
    NamedValueUtils.InjectNamedProperties(parentVariables, data.Variables, type, instance);

    return instance;
}

The method NamedValueUtils.InjectNamedProperties() will perform the injecting of property values of the instance.

In summary, TutorialStateData holds the transitionSignal and the list of ClassData of supertype SsmAction. We shall see in the next section how this data would be parsed to prepare the whole SSM for a single tutorial topic. The ScriptableObject then just looks like this:

[CreateAssetMenu(menuName = "Game/Tutorial/TutorialSequenceData")]
public class TutorialSequenceData : DataPool<TutorialStateData> {
}

DataPool<T> is part of the editor framework which really just maintains the item T in a list and dictionary. It also handles the generation of integer IDs. DataPool<T> is a subclass of ScriptableObject. Using this base class allows us to use the editor framework to create the editor window faster and easier.

Tutorial Sequence Flow

We made a MonoBehaviour class that requires a reference to a TutorialSequenceData and turns it into an SSM. It’s aptly called TutorialSequenceFlow:

public class TutorialSequenceFlow : SignalHandlerAcademiaComponent {
    [SerializeField]
    private TutorialSequenceData sequenceData;

    private Ssm ssm;
    
    // This is a mapping of what signal the current SsmState listens to move to the next state
    private readonly Dictionary<SsmState, Signal> signalMap = new Dictionary<SsmState, Signal>();
    
    private Signal.SignalListener doTransition;

    protected override void Awake() {
        base.Awake();
        
        PrepareSsm();
        
        this.doTransition = DoTransition;
        
        AddSignalListener(GameSignals.START_TUTORIAL, StartTutorial);
        AddSignalListener(GameSignals.OPEN_TITLE_SCREEN, StopTutorial);
        AddSignalListener(GameSignals.LOAD_PROCESS_STARTED, StopTutorial);
    }

    protected override void OnDestroy() {
        // We remove the transition listener here so that it will not remain on the next 
        // tutorial
        RemoveTransitionListenerFromCurrentState();
    }

    private void PrepareSsm() {
        this.ssm = new Ssm($"{this.gameObject.name}.Ssm");
        
        // Parse each sequence data
        foreach (TutorialStateData stateData in this.sequenceData.GetAll()) {
            PrepareState(stateData);
        }
    }

    private void PrepareState(TutorialStateData stateData) {
        SsmState state = this.ssm.AddState(stateData.Id);
        if (!string.IsNullOrEmpty(stateData.TransitionSignal)) {
            // There's a transition signal specified. Let's add to signalMap.
            this.signalMap[state] = GameSignals.GetSignal(stateData.TransitionSignal);
        }
        
        // Parse actions
        IReadOnlyList<ClassData> actionsData = stateData.Actions;
        for (int i = 0; i < actionsData.Count; ++i) {
            SsmAction action = TypeUtils.Instantiate<SsmAction>(actionsData[i], null);
            state.AddAction(action);
        }
    }
    
    private void StartTutorial(ISignalParameters parameters) {
        this.ssm.Start();
        
        // We automatically do transition here as starting the SSM will activate Begin state
        // We need to be on the second state
        DoTransition();
    }

    private void DoTransition(ISignalParameters parameters) {
        DoTransition();
    }

    private void DoTransition() {
        RemoveTransitionListenerFromCurrentState();
        
        if (!this.ssm.HasMore) {
            // No more states to transition to
            return;
        }
            
        this.ssm.Next();
            
        // Add the transition listener to the current state if it was specified
        if (this.signalMap.TryGetValue(this.ssm.Current, out Signal currentStateSignal)) {
            currentStateSignal.AddListener(this.doTransition);    
        }
    }

    private void RemoveTransitionListenerFromCurrentState() {
        if (this.signalMap.TryGetValue(this.ssm.Current, out Signal stateSignal)) {
            stateSignal.RemoveListener(this.doTransition);
        }
    }
    
    private void StopTutorial(ISignalParameters parameters) {
        RemoveTransitionListenerFromCurrentState();
        
        GameSignals.UNLOCK_ALL_ITEMS.Dispatch();
        GameSignals.SET_ALLOW_CAMERA_PAN_AND_ZOOM.Dispatch(true);
        GameSignals.SET_CAMERA_WASD_ENABLED.Dispatch(true);
        GameSignals.UNLOCK_TIME_CONTROLS.Dispatch();
    }

    private void Update() {
        // We call update here because some actions have Update() like blinking the zone link
        // color
        this.ssm.Update();
    }
}

In Awake(), you’ll see the call to PrepareSsm(). It’s an easy method to follow. It just instantiates the SSM then prepares each state which is represented by TutorialStateData stored in the specified TutorialSequenceData scriptable object. The method PrepareState() is kind of converting TutorialStateData into an SsmState.

Note that TypeUtils.Instantiate<T>() is used here which I showed in the previous section. What it does is it instantiates ClassData into an actual SsmAction instance then add it as an action to the SsmState. This is usually how we manage different unique classes in editor then use them at runtime. We’ve used this pattern a lot in Academia.

When the player loads a tutorial, the signal GameSignals.START_TUTORIAL gets dispatched which TutorialSequenceFlow listens to and executes the method StartTutorial(). What it simply does is start the SSM and do a transition. The first state usually has the actions that disables all UI or turns off all blinkers. Kind of a reset. The real flow really starts on the second step.

The method DoTransition() does some magic. In Awake(), we cache a delegate Signal.SignalListener doTransition which is just the method DoTransition(ISignalParameters). We did it this way to avoid garbage. Remember how every tutorial state has a transitionSignal? That data is used here.

Take a look at the method DoTransition(). First, RemoveTransitionListenerFromCurrentState() is called to remove the doTransition listener from the transitionSignal of the current tutorial step. We need to remove it so that DoTransition() will not be invoked when the same signal would be dispatched. This would be a bug if not removed as it would look like the tutorial is moving on the next step when it’s not really time to move.

Ssm.Next() is then invoked to move to the next tutorial step if there are still more steps. The method then adds the doTransition listener to the transitionSignal of the new current step. In other words, we’re kind of moving the listening for transition to the current step. When that signal is dispatched, DoTransition() would be invoked again which moves to the next step and listens to the transitionSignal of that next step.

StopTutorial() is just a signal listener to some signals where it needs to stop like say, going back to the title screen. Ssm.Update() is invoked on Update() of the MonoBehaviour because some actions do have frame by frame routines on them. And that’s about it for TutorialSequenceFlow which handles any tutorial sequence data being fed to it.

Load/Unload a tutorial topic

We employ a multi-scene development to our project. We always add new features in their separate scene and our tutorial system is no different. We maintain one scene for each tutorial topic. Each tutorial scene is not really complicated. They just contain the GameObject that has the TutorialSequenceFlow script and a UI Canvas full of tutorial messages.

Here’s a single tutorial scene

Some tutorials may need some customized help. We also add those to the tutorial scene. An example here is the script BasicsTutorialHandler. What it mostly does is it dispatches signals that the tutorial needs that may not have been implemented in the normal game. There are times where we add additional signals just to make them work with the tutorial system.

By using scenes, we can just simply load one on a tutorial mode and unload it when it’s no longer needed. The good thing about unloading a scene is all GameObjects associated with it are automatically removed. You don’t have to manage these GameObjects yourself.

The tutorial menu

The implementation of our tutorial menu is another matter altogether. Needless to say that I also made an editor for it but I need not go in depth. The gist is that each tutorial topic has data on what save file to load (this is prepared by our designer) and the name of the tutorial scene to load. While the save file is being loaded, the tutorial scene is also loaded additively using SceneManager.LoadScene(). When the loading is done, the signal GameSignals.START_TUTORIAL is dispatched which is being listened to by TutorialSequenceFlow (which was now loaded). This will in turn start the tutorial SSM sequence.

And that’s how we did our tutorial system. The end.

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

One thought on “How we did the tutorial system in Academia: School Simulator, Part 3: Putting them all together

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