Source file unity-csharp/patterns/game-flow-tutorial/GameFlow.cs from the GDnD code examples. Download raw file
// GDnD wiki example
// Demonstrates: a tutorial that hands over to the core loop without a scene load or a menu.
// The game-flow machine has one Playing state; inside it, a nested machine runs tutorial
// sub-states. Each step unlocks one thing in Enter and ends when the player has DONE the action
// (moved, jumped, hit something), not on a timer or a "Next" button. The last step simply
// starts the core loop, so the camera, music, input map and player keep running.
// Related pages: [[state-pattern]], [[player-guidance]], [[flow]], [[unity-gamemanager-pattern]]
// Unity 6 (6000.x). Reuses IState and StateMachine from patterns/PlayerStateController.cs and
// PlayerEvents from patterns/feedback-layer/PlayerEvents.cs.
//
// Setup: add to a GameFlow object in the gameplay scene. Fill Steps in the Inspector (prompt,
// goal, count, and an On Enter event that unlocks the ability or opens the next area). Wire
// Show Prompt / Hide Prompt to your HUD and Start Core Loop to the enemy spawner, music layer
// or timer. Tick Skip Tutorial for returning players.
using System;
using GDnD.Patterns.Feedback;
using GDnD.Patterns.State;
using UnityEngine;
using UnityEngine.Events;
namespace GDnD.Patterns.GameFlow
{
public enum TutorialGoal { Move, Jump, Hit }
[Serializable]
public class TutorialStepData
{
public string prompt = "Press Space to jump";
public TutorialGoal goal = TutorialGoal.Jump;
[Min(1)] public int count = 1; // e.g. "hit the dummy 3 times"
public UnityEvent onEnter; // unlock the ability, open a gate, spawn a dummy
}
public class GameFlow : MonoBehaviour
{
[SerializeField] private PlayerEvents playerEvents;
[SerializeField] private Transform player;
[SerializeField] private TutorialStepData[] steps;
[SerializeField] private bool skipTutorial;
[SerializeField] private float moveGoalDistance = 3f;
[Header("Presentation hooks")]
public UnityEvent<string> showPrompt;
public UnityEvent hidePrompt;
public UnityEvent startCoreLoop;
public StateMachine Flow { get; } = new StateMachine(); // Playing, Paused, GameOver...
public TutorialStepData[] Steps => steps;
public bool SkipTutorial => skipTutorial;
// Progress counters, reset by each step's Enter.
public int Jumps { get; private set; }
public int Hits { get; private set; }
public float Moved { get; private set; }
public float MoveGoalDistance => moveGoalDistance;
private Vector3 lastPosition;
private void OnEnable()
{
playerEvents.Jumped += CountJump;
playerEvents.HitLanded += CountHit;
}
private void OnDisable()
{
playerEvents.Jumped -= CountJump;
playerEvents.HitLanded -= CountHit;
}
private void Start()
{
lastPosition = player.position;
Flow.Initialize(new PlayingState(this));
}
private void Update()
{
Moved += Vector3.Distance(player.position, lastPosition);
lastPosition = player.position;
Flow.Execute();
}
public void ResetProgress() { Jumps = 0; Hits = 0; Moved = 0f; }
private void CountJump(Vector3 _) => Jumps++;
private void CountHit(Vector3 _, int __) => Hits++;
}
// Top-level state. Gameplay keeps running here the whole time; the nested machine only
// decides what is unlocked and which prompt is showing.
public class PlayingState : IState
{
private readonly GameFlow flow;
private readonly StateMachine tutorial = new StateMachine();
public PlayingState(GameFlow flow) => this.flow = flow;
public void Enter()
{
IState first = new FreePlayState(flow);
if (!flow.SkipTutorial)
{
// Build the chain backwards so each step knows the state that follows it.
for (int i = flow.Steps.Length - 1; i >= 0; i--)
first = new TutorialStepState(flow, tutorial, flow.Steps[i], first);
}
tutorial.Initialize(first);
}
public void Execute() => tutorial.Execute();
public void Exit() => flow.hidePrompt.Invoke();
}
public class TutorialStepState : IState
{
private readonly GameFlow flow;
private readonly StateMachine machine;
private readonly TutorialStepData data;
private readonly IState next;
public TutorialStepState(GameFlow flow, StateMachine machine, TutorialStepData data, IState next)
{
this.flow = flow; this.machine = machine; this.data = data; this.next = next;
}
public void Enter()
{
flow.ResetProgress();
data.onEnter.Invoke(); // unlock exactly one new thing
flow.showPrompt.Invoke(data.prompt);
}
public void Execute()
{
if (Done()) machine.TransitionTo(next); // the player's action ends the step
}
public void Exit() => flow.hidePrompt.Invoke();
private bool Done() => data.goal switch
{
TutorialGoal.Move => flow.Moved >= flow.MoveGoalDistance * data.count,
TutorialGoal.Jump => flow.Jumps >= data.count,
TutorialGoal.Hit => flow.Hits >= data.count,
_ => true
};
}
// The handover: no load, no menu. Start the core loop in place.
public class FreePlayState : IState
{
private readonly GameFlow flow;
public FreePlayState(GameFlow flow) => this.flow = flow;
public void Enter() => flow.startCoreLoop.Invoke(); // first wave, combat music, timer
public void Execute() { }
public void Exit() { }
}
}