# 2. Hierarchical FSM per Phase Part of [[Boss Battles with Phases]]. Prev: [[1. Boss Design and the Arena]]. Next: [[3. Telegraph VFX and Fair Windows]]. [[Enemy AI State Machine]] already gives you `IState`, `StateMachine`, and a `SuperState` whose child machine runs Chase / Attack while the parent only cares about Calm / Alert. A boss is the same idea with different labels: the **outer** machine is Intro / Phase1 / Phase2 / Phase3 / Death; each phase **super-state** has a child machine for Reposition / TelegraphAttack / Recover / Vulnerable. Reuse those types. Do not start a second state-machine framework. If you have not written the AI note's core yet, copy `IState` / `StateMachine` / `SuperState` from it and keep the names stable so students can jump between notes. ## 2.1 Outer graph ``` [Intro] --(Timeline stopped)--> [Phase 1] | health <= threshold 2 v [Phase 2] | health <= threshold 3 v [Phase 3] | coup de grace / health 0 v [Death] ``` `AddAnyTransition(death, () => WantsDeath)` so a freak fall or a scripted kill can leave any phase. Phase transitions are **not** Any-State spam: they are checked after damage, once, in the brain. ## 2.2 Phase super-state The child graph is intentionally small. Fancy move lists are data on `PhaseDefinition.attacks`, not twelve child states. ```csharp using System; using UnityEngine; /// <summary> /// Super-state for one boss phase. Child machine: Reposition -> TelegraphAttack -> Recover. /// NOT compiled in Unity. Depends on IState / StateMachine / SuperState from Enemy AI State Machine. /// </summary> public sealed class BossPhaseState : SuperState { public readonly PhaseDefinition Phase; readonly BossBrain brain; public BossPhaseState(BossBrain brain, PhaseDefinition phase, IState initialChild) : base(phase != null ? phase.displayName : "Phase", initialChild, resumeChild: false) { this.brain = brain; Phase = phase; } public override void Enter() { brain.ApplyPhase(Phase); base.Enter(); } } ``` If your `SuperState` methods are not `virtual` in the AI note, either make `Enter` virtual there (preferred - one line) or wrap the call: ```csharp public sealed class BossPhaseState : IState { public string Name => phase.displayName; public StateMachine Child { get; } = new(); readonly PhaseDefinition phase; readonly BossBrain brain; readonly IState initial; public BossPhaseState(BossBrain brain, PhaseDefinition phase, IState initial) { this.brain = brain; this.phase = phase; this.initial = initial; } public void Enter() { brain.ApplyPhase(phase); Child.SetState(initial); } public void Tick(float dt) => Child.Tick(dt); public void Exit() => Child.CurrentState?.Exit(); } ``` The second form does not require editing the AI note. Use it if you want this series to stay additive. ## 2.3 The brain ```csharp using System; using UnityEngine; using UnityEngine.AI; /// <summary> /// Owns health, the outer state machine, and phase application. /// NOT compiled in Unity. /// </summary> public sealed class BossBrain : MonoBehaviour, IDamageable { [SerializeField] BossDefinition definition; [SerializeField] TeamId team = TeamId.Enemy; [SerializeField] NavMeshAgent agent; [SerializeField] Animator animator; [SerializeField] BossIntroDirector intro; [SerializeField] ArenaHazardDirector hazards; [SerializeField] CombatVictim victimForward; // optional: reuse CombatVictim for feel public event Action<PhaseDefinition, PhaseDefinition> PhaseChanged; // previous, current public event Action Died; StateMachine _outer; PhaseDefinition _phase; int _health; bool _introDone; bool _coupReady; public TeamId Team => team; public bool IsAlive => _health > 0; public bool IsInvulnerable { get; set; } public BossDefinition Definition => definition; public PhaseDefinition Phase => _phase; public Transform Target { get; set; } public AttackDefinition CurrentAttack { get; set; } void Start() { _health = definition.maxHealth; BuildMachine(); if (definition.introTimeline != null && intro != null) { IsInvulnerable = true; intro.PlayIntro(definition.introTimeline, OnIntroFinished); } else OnIntroFinished(); } void OnIntroFinished() { _introDone = true; IsInvulnerable = false; EnterPhase(definition.phases[0], playSting: false); } void BuildMachine() { _outer = new StateMachine(); // Child states are constructed in the worked example; the outer graph only // needs a Death state here plus whatever EnterPhase assigns with SetState. _outer.AddAnyTransition(new BossDeathState(this), () => !IsAlive && _coupReady); } void Update() { if (_outer?.CurrentState == null) return; _outer.Tick(Time.deltaTime); } public void ApplyPhase(PhaseDefinition phase) { if (agent != null) agent.speed = phase.moveSpeed; hazards?.SetActiveHazards(phase.enableHazardIds, phase.disableHazardIds); if (animator != null) animator.SetInteger("Phase", IndexOf(phase)); } public void ApplyDamage(in DamageInfo info) { if (!IsAlive || IsInvulnerable) return; if (_health - info.amount <= 0 && definition.coupDeGraceAttack != null && info.attack != definition.coupDeGraceAttack) { // Clamp to 1 HP until the designed finishing attack lands. _health = 1; _coupReady = true; victimForward?.ApplyDamage(WithAmount(info, 0)); // feel only return; } _health = Mathf.Max(0, _health - info.amount); victimForward?.ApplyDamage(info); if (_health <= 0) { _coupReady = true; Died?.Invoke(); return; } TryAdvancePhase(); } void TryAdvancePhase() { if (definition.phases == null || definition.phases.Length == 0) return; float n = (float)_health / definition.maxHealth; PhaseDefinition next = _phase; for (int i = 0; i < definition.phases.Length; i++) { var p = definition.phases[i]; if (n <= p.enterAtHealthNormalized) next = p; } if (next != _phase) EnterPhase(next, playSting: true); } public void EnterPhase(PhaseDefinition phase, bool playSting) { var previous = _phase; _phase = phase; var child = BuildChildMachine(phase); var state = new BossPhaseState(this, phase, child.initial); // Copy transitions from the helper onto state.Child in the worked example. _outer.SetState(state); PhaseChanged?.Invoke(previous, phase); if (playSting) intro?.PlaySting(phase.phaseEnterTimeline); } (IState initial, StateMachine unused) BuildChildMachine(PhaseDefinition phase) { var reposition = new BossRepositionState(this, phase); var attack = new BossTelegraphAttackState(this, phase); var recover = new BossRecoverState(this, phase); var machine = new StateMachine(); machine.AddTransition(reposition, attack, () => InAttackRange()); machine.AddTransition(attack, recover, () => attack.Done); machine.AddTransition(recover, reposition, () => recover.Done); machine.SetState(reposition); return (reposition, machine); } public bool InAttackRange() { if (Target == null || _phase == null) return false; return Vector3.Distance(transform.position, Target.position) <= _phase.attackRange; } int IndexOf(PhaseDefinition phase) { if (definition.phases == null) return 0; return Mathf.Max(0, Array.IndexOf(definition.phases, phase)); } static DamageInfo WithAmount(in DamageInfo info, int amount) { var copy = info; copy.amount = amount; return copy; } } ``` `BuildChildMachine` as written builds a machine *and* a `BossPhaseState` that will build another child in `Enter`. That double-build is easy to mess up. The worked example (part 6) uses the cleaner form: construct children, hook transitions on `BossPhaseState.Child`, then `SetState`. Treat the snippet above as the *policy* (when to swap phases, how coup de grace works), not as the final wiring. Policy worth keeping: - **Invulnerable during intro.** The player should not cheese the cutscene. - **Coup de grace.** If `coupDeGraceAttack` is set, health will not drop below 1 until that attack lands. That is Rogers' "let the player administer the coup de grace" as a rule, not a cutscene skip. - **Phase change after damage is applied and the victim is still alive.** Do not start Phase 3 on the same frame as Death. ## 2.4 Child state sketches Keep each child short. Telegraph details live in part 3. ```csharp public sealed class BossRepositionState : IState { public string Name => "Reposition"; readonly BossBrain brain; readonly PhaseDefinition phase; public BossRepositionState(BossBrain brain, PhaseDefinition phase) { this.brain = brain; this.phase = phase; } public void Enter() { /* agent.isStopped = false; destination = flank or close */ } public void Tick(float dt) { /* face the target; retarget if needed */ } public void Exit() { /* agent.isStopped = true if the attack is rooted */ } } public sealed class BossRecoverState : IState { public string Name => "Recover"; public bool Done { get; private set; } readonly PhaseDefinition phase; float _t; public BossRecoverState(BossBrain brain, PhaseDefinition phase) { this.phase = phase; } public void Enter() { Done = false; _t = 0.4f; } public void Tick(float dt) { _t -= dt; if (_t <= 0f) Done = true; } public void Exit() { } } ``` `BossTelegraphAttackState` is the next part. ## 2.5 Exercise On a whiteboard, draw the outer graph and one phase's child graph. If you need more than five child states, you are probably encoding the *move list* as states. Put the move list in `PhaseDefinition.attacks` and keep the child graph stable. ## Sources - [[Enemy AI State Machine]] Step 6 - [[Game Programming Patterns (Review)]] - State