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() { }
    }
}