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