23 KiB
Tutorial System Architecture
This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs:
TUTORIAL_CONTENT.md— human-facing copy for tutorial steps and dialoguesTUTORIAL_BATTLE_SYSTEM.md— the scripted opening battle at Onmaa
It also overlaps slightly with the in-tree Assets/Tutorial/TUTORIAL_PLAN.md, which is an older implementation plan.
All paths below are relative to src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/.
Current State (important)
There are two parallel subsystems that share the same trigger plumbing:
| Subsystem | Status | Lives in |
|---|---|---|
| Step-based tutorial UI (modals, overlays, hints) | Dormant | Tutorial/TutorialManager.cs, Tutorial/UI/*, Tutorial/Content/* |
| Narrative dialogue system | Live | Tutorial/Dialogue/*, Resources/Dialogues/*.json |
TutorialContentDefinitions.RegisterAll() returns early with the comment "Old tutorial content suppressed — replaced by narrative dialogue system." As a result:
OnboardingSequenceis never assigned, soStartOnboarding()no-ops at the "No onboarding sequence assigned" branch.- No contextual tutorial sequences are registered with the trigger registry.
- The trigger registry still fires events normally, but nothing on the step-UI side is listening.
DialogueManagerconsumes those same events and matches them against scripts inResources/Dialogues/(tutorial_strategic.json,tutorial_battle.json).- Dialogue completion is persisted per game through
TutorialDialogueProgressStore, so completed scripts do not replay after a reconnect or scene reload for the same game.
In practice today, the entire active tutorial experience runs through the dialogue system. The step-UI machinery is preserved for future use — content definitions still exist below the early return in TutorialContentDefinitions.cs for reference.
Class Map
Orchestration
Tutorial/TutorialManager.cs— Singleton. OwnsOnboardingSequence, the active step queue, the trigger registry, and the dialogue manager handle. Initialized byEagleGameController.SetUpGame()andShardokGameController.SetUpGame().Tutorial/TutorialState.cs— PlayerPrefs persistence. TracksOnboardingCompleted,OnboardingStepReached, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference.Tutorial/TutorialTargetRegistry.cs— Maps string IDs toRectTransforms for highlighting. Static targets are Inspector-assigned (ProvinceInfoPanel,SupportField,CommitButton, etc.); dynamic targets register at runtime (e.g., hero rows fromHeroesAndBattalionsPanelController).Tutorial/TutorialDialogueCoordinator.cs— Recomputes durable dialogue triggers from the current strategic model and sends them directly toDialogueManager. This covers reconnects, phase-start tutorial games, and strategic beats that should be recoverable from state.
Triggers
Tutorial/Triggers/TutorialTriggerRegistry.cs(~1100 lines) — Routes game events to both the step UI (when sequences are registered) andDialogueManager. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice.
Content (step UI — dormant)
Tutorial/Content/TutorialStep.cs— Per-step record:DisplayMode,CompletionType, target path, copy, panel anchor, highlight options.Tutorial/Content/TutorialSequence.cs—ScriptableObjectholding orderedTutorialSteps plusIsOnboardingflag and lifecycle callbacks.Tutorial/Content/TutorialContentDefinitions.cs— Static class that would register all sequences. Currently short-circuits before any registration.
UI (step UI — dormant)
Tutorial/UI/TutorialUIManager.cs— Coordinates which presenter renders each step.Tutorial/UI/TutorialCanvasBuilder.cs— Builds the Canvas at runtime (no prefab dependency).Tutorial/UI/TutorialModalPanel.cs— Full-screen blocking modal.Tutorial/UI/TutorialOverlayController.cs+TutorialOverlayBuilder.cs— Dimmer with highlighted cutout, gold border, pulsing animation, and adjacent tooltip text.Tutorial/UI/TutorialHintIndicator.cs— Stub for pulsing-dot mode.
Dialogue (live)
Tutorial/Dialogue/DialogueManager.cs— Loads allResources/Dialogues/*.jsonscripts at startup, indexes by trigger ID, drives the panel.Tutorial/Dialogue/DialogueScript.cs/DialogueStep.cs— JSON-shaped records for scripts and steps.Tutorial/Dialogue/DialoguePanelController.cs— Renders the speaker headshot, body text, instruction line, optional highlight target, and Continue button.- Scripts:
Assets/Resources/Dialogues/tutorial_strategic.json,tutorial_battle.json.
Step Lifecycle (Step-UI System)
- Triggered —
TutorialTriggerRegistryraises an event (e.g.,game_started,battle_entered). - Queued or shown — If no active sequence, show immediately. If an active step is hidden (
DisplayMode.None), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue. - Rendered —
TutorialUIManager.ShowCurrentStep()dispatches to the modal/overlay/hint presenter based onDisplayMode. - Awaiting completion — One of:
ButtonClick— user dismissesGameEvent— wait for a named event (e.g.,province_selected)Timer— auto-advance after delayUIInteraction— wait for the highlighted target to be interacted withCondition— custom predicate (rare)
- Advance —
AdvanceStep()moves to the next step or completes the sequence; completion writes toTutorialState.
Hidden steps (DisplayMode.None) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event.
Display Modes
| Mode | Renderer | Notes |
|---|---|---|
| Modal | TutorialModalPanel |
Full blocking dialog with dim background |
| Overlay | TutorialOverlayController |
Dimmer with cutout + tooltip near a target |
| Tooltip | TutorialOverlayController |
Currently same renderer as overlay |
| Hint | TutorialHintIndicator |
Pulsing dot only (stub) |
| None | (invisible) | Waits for a completion event |
Panels can be anchored via TutorialPanelAnchor (Center / Left / Right / Top / Bottom).
Trigger Catalog
Most event-style triggers are raised via TutorialTriggerRegistry. Durable strategic dialogue beats are also recomputed by TutorialDialogueCoordinator on each model update and sent directly to DialogueManager.TriggerDialogue(). Both paths match against the dialogue JSON; if step-UI sequences are re-registered, only the registry path will route to them. File:line citations are for the registry unless noted; line numbers may drift.
Bootstrap / first-session
| Trigger | Fires from | When |
|---|---|---|
game_started |
TutorialDialogueCoordinator.EligibleTriggers() |
Any tutorial strategic model update; dialogue progress keeps it from replaying |
first_battle_available |
OnModelUpdated() ~L204 |
A RunningShardokGameModel first appears |
battle_entered |
OnBattleEntered() ~L793 |
User clicks Fight! |
tutorial_battle_ended |
CheckTutorialBattleEnded() ~L594 |
Tutorial battle removed from running models |
tutorial_rebuild_support |
CheckTutorialRebuildSupport() ~L616 |
Captured-heroes phase done |
tutorial_taxes_collected |
CheckTaxesCollected() ~L680 |
New Year action with positive tax delta |
tutorial_in_town |
CheckInTown() ~L500 |
Return command becomes available |
Strategic-map contextual
| Trigger | Site | When |
|---|---|---|
province_selected |
EagleGameController.ProvinceWasSelected() |
Province click |
command_issued |
EagleGameController.PostCommittedCommand() |
User commits |
diplomacy_available |
CheckStrategicCommandsAvailable() ~L243 |
Diplomacy command appears |
weather_control_available |
CheckStrategicCommandsAvailable() ~L249 |
Weather command appears |
hero_recruitment_available |
CheckHeroRecruitmentAvailable() ~L266 |
Free heroes detected |
tutorial_ready_to_join |
CheckReadyToJoinHero() ~L479 |
Free hero with WouldJoin |
profession_<X>_encountered |
CheckProfessionTutorial() ~L726 |
First time seeing each profession |
Strategic guidance (state-derived, conditions checked each model update)
| Trigger | Site | Condition |
|---|---|---|
guidance_loyalty_danger |
CheckLoyaltyDanger() ~L339 |
November + hero loyalty < 70 |
guidance_neighbor_danger |
CheckNeighborDanger() ~L383 |
Hostile faction adjacent |
guidance_recruit_heroes |
CheckRecruitHeroesGuidance() ~L420 |
Province with support ≥40 has free heroes |
guidance_expand |
CheckExpandGuidance() ~L449 |
One stable province, hasn't expanded |
guidance_sworn_kinship |
CheckSwornKinshipGuidance() ~L549 |
Good candidate or 4+ provinces |
tutorial_loyalty_warning |
CheckTutorialLoyaltyWarning() ~L634 |
November + hero loyalty < 70 |
tutorial_support_deadline |
CheckTutorialSupportDeadline() ~L654 |
December + province support < 40 |
Strategic action-result triggers (fired from OnStrategicActionResult())
| Trigger | Site | When |
|---|---|---|
hero_stat_gained |
~L281 | HeroStatGained action result |
hero_profession_gained |
~L284 | ProfessionGained action result |
tutorial_faction_appears |
~L287 | TutorialFactionAppears action — the Fracture Covenant landing |
tutorial_hero_faction_appears |
~L290 | TutorialHeroFactionAppears action; has a four-step strategic dialogue today |
tutorial_hero_departed |
~L299 | First HeroesDeparted action against another player (King's hero abandons service) |
tutorial_hero_departed_again |
~L302 | Second HeroesDeparted in a different month than the first |
Tactical (battle) — abilities and spells
Combat triggers are paced: _combatTutorialPending ensures only one fires per action, with priority archery > duel > melee > charge > fire.
| Trigger | Site | When |
|---|---|---|
shardok_placement_started |
OnBattleEntered() ~L796 |
Battle in setup phase |
shardok_battle_started |
OnBattleEntered() ~L793 |
Battle begins |
shardok_battle_running |
OnTacticalCommandsAvailable() ~L948 |
Player's first turn |
archery_available |
OnTacticalCommandsAvailable() ~L990 |
Archery available |
melee_available |
OnTacticalCommandsAvailable() ~L991 |
Melee available |
duel_available |
OnTacticalCommandsAvailable() ~L1005 |
Duel available (non-Tarn target) |
ability_charge_available |
OnTacticalCommandsAvailable() ~L1001 |
Charge for Old Marek |
start_fire_available |
OnTacticalCommandsAvailable() ~L1021 |
Start Fire on enemy |
hide_available |
OnTacticalCommandsAvailable() ~L985 |
Hedrick can hide |
thunderstorm |
OnTacticalCommandsAvailable() ~L1056 |
Weather is thunderstorm |
spell_lightning_available |
OnTacticalCommandsAvailable() ~L960 |
Lightning available |
spell_meteor_available |
OnTacticalCommandsAvailable() ~L963 |
Meteor available |
spell_holywave_available |
OnTacticalCommandsAvailable() ~L965 |
Holy Wave available |
spell_raisedead_available |
OnTacticalCommandsAvailable() ~L969 |
Raise Dead available |
spell_lightning_cast / spell_meteor_cast / spell_holywave_cast / spell_raisedead_cast |
CheckTacticalActionType() ~L830-838 |
Spell action observed |
ability_charge_used |
CheckTacticalActionType() ~L842 |
Charge attack executed |
terrain_fire_encountered |
CheckTacticalActionType() ~L864 |
Fire damage / spread |
terrain_water_encountered |
CheckTacticalActionType() ~L867 |
Water crossing |
engineer_near_enemy |
CheckEngineerNearEnemy() ~L1105 |
John Ranil within 3 hexes of enemy |
tutorial_reinforcement_engineer / tutorial_reinforcement_paladin |
CheckTacticalActionType() ~L857 |
Reinforcement arrival |
friendly_hero_captured |
CheckHeroRemovals() ~L919 |
Friendly hero unit removed |
enemy_hero_captured |
CheckHeroRemovals() ~L928 |
Enemy hero unit removed |
battle_action |
ShardokGameController.OnBattleAction() |
Any action result |
turn_ended |
ShardokGameController.OnTurnEnded() ~L1137 |
Battle turn ends |
shardok_battle_reset |
ShardokGameController.cs:627 (fired directly to DialogueManager, bypassing the registry) |
Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire |
One-shot semantics
Most contextual triggers are guarded by booleans on the registry (e.g., a _diplomacyShown flag). These are session-local — they don't persist via TutorialState, but the registry has ResetBattleTriggerFlags() for replays. The strategic completion list does persist across sessions.
Highlight Targets
Steps reference UI by string ID through TutorialTargetRegistry:
- Static targets are wired in the Inspector on the registry.
- Dynamic targets register at runtime (
RegisterTarget(id, rectTransform)). - Lookup falls back to
GameObject.Find()if not registered.
Step options:
TargetGameObjectPath— primary highlightAdditionalHighlightTargets[]— multiple at onceHighlightBoundsFromChildren— use children's bounding boxHighlightPulsing— strobe animation
Dialogue scripts use the same registry via highlightTarget (and persistHighlight: true to keep the highlight after the dialogue closes).
Sequences vs. Contextual Dispatch
- Onboarding sequence (
IsOnboarding = true) — Single linear sequence, started byStartOnboarding(). Resumes fromOnboardingStepReached. Counts only visible steps for progress. - Contextual (
IsOnboarding = false) — Single- or multi-step, dispatched on demand byTriggerContextualTutorial(). - Dispatch rules in
TriggerContextualTutorial():- No active sequence → show immediately.
- Active step is hidden (
None) → interrupt. - Both are command tutorials → interrupt.
- Otherwise → queue.
Dialogue System (Currently Live)
DialogueManager is a parallel system, not a step-UI subclass. It loads every TextAsset in Resources/Dialogues/ at startup, parses them as DialogueScript objects, and indexes by trigger.
Script shape (see tutorial_strategic.json):
{
"scripts": [{
"id": "tutorial_opening",
"trigger": "game_started",
"panelPosition": null,
"steps": [{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "...",
"instructionText": "Click <b>Fight!</b> to enter tactical combat.",
"highlightTarget": "FightButton",
"persistHighlight": true,
"highlightProvince": "Onmaa",
"completionEvent": null
}]
}]
}
When a registry trigger fires, OnGameEvent() calls DialogueManager.TriggerDialogue(triggerId). If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a completionEvent (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes.
Dialogue panel position defaults sensibly per scene (top during combat) and can be overridden per script.
Persistence
TutorialState (PlayerPrefs JSON):
OnboardingCompleted(bool)OnboardingStepReached(int)CompletedTutorials(List → HashSet at load)DismissedHints(List → HashSet at load)AllTutorialsDisabled(bool)
Reset paths:
TutorialState.Reset()clears everything.- Entering a tutorial game (
GameType.Tutorial) resetsTutorialState; dialogue replay is controlled separately by the per-game dialogue progress key. - Settings → "Reset Tutorials" calls
TutorialManager.ResetAllProgress(), which also callsDialogueManager.ResetCompletedScripts().
TutorialDialogueProgressStore separately stores completed dialogue script IDs per game under PlayerPrefs keys named Eagle0_TutorialDialogueProgress_<gameId>.
DialogueManager keeps the active game's completed script IDs in memory and saves them through TutorialDialogueProgressStore. ResetCompletedScripts() clears the current game's dialogue progress; ClearCurrentGameProgress() deletes the current game's persisted dialogue key.
Bootstrap
TutorialManageris in the scene as a singleton;Awakeloads state and creates the trigger registry.RegisterAll()is called here but currently no-ops.ConnectionHandlersetsIsTutorialGameandIsMultiplayerGamewhen the game is created/joined.EagleGameController.SetUpGame()callsTutorialManager.Instance.Initialize(this, null). IfIsTutorialGame && !OnboardingCompleted, it would callStartOnboarding()— currently a no-op becauseOnboardingSequenceis null.- The first model update raises
game_started.DialogueManagermatchestutorial_openingfromtutorial_strategic.jsonand the dialogue runs. ShardokGameController.SetUpGame()callsTutorialManager.Instance.Initialize(null, this)when battle starts; raisesbattle_enteredand battle-only triggers from there.
Expected Tutorial Flow (today)
Driven by tutorial_strategic.json + tutorial_battle.json. Old Marek the Learned narrates almost everything.
Narrative arc
You play Sadar Rakon, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the Reclamation. He's been pushed back to Onmaa for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — The Fracture Covenant, led by a mysterious figure called The Eagle — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion."
Strategic-map flow (tutorial_strategic.json)
| # | Trigger | What happens |
|---|---|---|
| 1 | game_started |
Opening monologue (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the Fight! button (FightButton, persistHighlight) |
| 2 | tutorial_battle_ended |
Aftermath: Tarn has vanished; you've captured his lieutenants. Recruit them or hold them |
| 3 | tutorial_rebuild_support |
Rebuild Onmaa (3 steps): Marek frames it; John Ranil introduces Improve (engineer bonus); Elena Fyar introduces Give Alms (paladin bonus). Goal: 40 support by January |
| 4 | tutorial_loyalty_warning |
November-ish, low-loyalty hero may leave at year end → use Give Gold / Feast |
| 5 | tutorial_support_deadline |
December nag if support < 40: have Elena give alms now |
| 6 | guidance_expand (script tutorial_expansion) |
Once support is stable: keep developing, taxes coming in January |
| 7 | tutorial_taxes_collected |
January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 1–2 behind |
| 8 | tutorial_ready_to_join |
A free hero in {provinceName} is ready to recruit → Travel + Recruit |
| 9 | tutorial_in_town |
When you Travel: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; Return ends the turn |
| 10 | tutorial_faction_appears |
The twist: Fracture Covenant lands at Ingia and Soria. The Eagle commands them. Tarn may be with them |
| 11 | tutorial_hero_departed |
First King's hero abandons service — "something deeper is at work" |
| 12 | tutorial_hero_departed_again |
Second one in a different month — "something is wrong, I can feel it" (foreshadowing) |
| 13 | tutorial_hero_faction_appears |
John Ranil and Elena Fyar leave Sadar's service and reappear as independent tutorial factions |
Tactical battle flow (tutorial_battle.json)
| # | Trigger | What happens |
|---|---|---|
| 1 | shardok_placement_started |
Placement (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit |
| 2 | shardok_battle_running |
Turn 1 strategy (2 steps): victory conditions for both sides, advice to hold castles and End Turn |
| 3 | archery_available |
First archery opportunity → purple outlines, longbows vs. armor |
| 4 | melee_available |
First melee opportunity → right-click adjacent enemy |
| 5 | ability_charge_available |
Charge mechanics (Marek hams it up: "old scholar without a horse") |
| 6 | start_fire_available |
Start Fire on enemy hex, fire spread mechanics |
| 7 | thunderstorm |
Weather: archery disabled, fires extinguished |
| 8 | tutorial_reinforcement_paladin |
Elena Fyar arrives mid-battle (2-step exchange) → Paladin profession intro (Holy Wave) |
| 9 | tutorial_reinforcement_engineer |
John Ranil arrives (2-step exchange) → Engineer profession intro |
| 10 | engineer_near_enemy |
Once Ranil is close to enemy: Fortify + Reduce (siege bombardment) |
| 11 | duel_available |
Champion duel mechanics (Marek warns not to duel Tarn himself) |
| 12 | hide_available |
Hedrick the Hedge-merchant: forest/swamp Hide + ambush |
| 13 | enemy_hero_captured / friendly_hero_captured |
Capture mechanics, with {heroName} substitution |
| 14 | shardok_battle_reset |
If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing |
Most remaining "Tutorial / first-session onboarding" items in SMALL_EAGLE_TODO.md (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to adding new dialogue scripts against existing trigger IDs, or — if the step-UI is revived — to populating TutorialContentDefinitions.RegisterAll() and removing the early return.
Notable Behaviors and Gotchas
IsTutorialGamegates everything. Outside tutorial games, both subsystems short-circuit early.- Combat tutorials are deliberately paced. Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them.
- One-shot flags are session-local. A trigger that "already fired" will not re-fire even if
TutorialStateis reset, until the registry is recreated. TutorialTargetRegistrylookups silently fall back toGameObject.Find. Typos in target IDs will render with no highlight rather than throwing.TutorialContentDefinitions.RegisterAll()returning early is load-bearing. If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger.Tutorial/TUTORIAL_PLAN.mdin the Unity project is an older implementation plan and partially overlaps this doc.