diff --git a/AI/CHANGELOG.md b/AI/CHANGELOG.md
index 735918e..d4c0010 100644
--- a/AI/CHANGELOG.md
+++ b/AI/CHANGELOG.md
@@ -29,6 +29,30 @@
## [Unreleased]
+### Added — Editable bot tier display names (Rookie / Rival / Ace)
+
+- **Scope:** component:PlayerVsBot
+- **What:** Each bot tier on BotDifficultyConfig now has a Display Name field. Defaults are Rookie, Rival, Ace, and Flawless so they do not collide with song difficulties (Easy / Normal / Hard / Expert). GameSession.ApplyBotGameMode stores SelectedBotLabel; the title menu, transition screen, and result banner all read that label instead of the enum name.
+- **Why:** "Easy" was used for both a bot tier and a chart difficulty, which made the transition screen hard to read, and designers could not rename tiers without a code change.
+- **Impact:** PlayerVsBot v0.2.0. TitleScreenController gained a Bot Difficulty Config slot. Blank display names fall back to BotDifficultyDefaults, never the raw enum string.
+- **Refs:** BotDifficultySettings.cs, BotDifficultyDefaults.cs, BotDifficultyConfig.cs, GameSession.cs, TitleScreenController.cs, TransitionScreenController.cs, TwoPlayerGameCoordinator.cs, PlayerVsBot.md
+
+### Fixed — Player side synthwave reacts to the song in VS Bot / 2P
+
+- **Scope:** component:PlayerVsBot
+- **What:** TwoPlayerGameCoordinator now pulses P1's synthwave floor from P1 OnBeat (P2 already had this) and retargets the shared AudioFFTAnalyzer to P1's playing AudioSource. P2 PlaySync never starts a clip, so the analyzer had been listening to a silent source.
+- **Why:** Only the bot side looked reactive during split screen matches.
+- **Impact:** Both backgrounds follow the track in 1P and 2P / VS Bot. No scene rewiring required.
+- **Refs:** TwoPlayerGameCoordinator.cs, AudioFFTAnalyzer.cs, TreeRhythmController.cs
+
+### Added — Player vs Bot mode (rule-based P2)
+
+- **Scope:** session · integration · component:TitleScreen · song-select · component:PlayerVsBot
+- **What:** Enabled title-tree VS Bot with an Easy/Medium/Hard difficulty sub-tree (same hanging-tree layout as mode select; Perfect remains in config/enum but is not offered in the menu). Selection is stored on GameSession (SelectedGameMode, SelectedBotDifficulty) and drives the existing split-screen GameScene. BotPlayerController disables P2 Input System input, rolls once per decision against rates on BotDifficultyConfig (seed values centralized in BotDifficultyDefaults), and submits through TreeRhythmController.SubmitGameplayActionAsync. Results label P2 as Bot with the chosen tier; only P1 confirms Continue/Quit. Song-select hides the P2 difficulty row in bot mode (bot mirrors P1 beatmap tier).
+- **Why:** Opponent practice without duplicating the two-player gameplay stack; error percent editable in Inspector with no inline hardcodes.
+- **Impact:** Menu to GameSession to TwoPlayerGameCoordinator; docs in AI/components/PlayerVsBot.md.
+- **Refs:** Assets/Bot/*, GameSession.cs, TwoPlayerGameCoordinator.cs, TreeRhythmController.cs, TitleScreenController.cs, BotDifficultyConfig.asset
+
### Added — End-of-level tree QR share on Result Screen
- **Scope:** component:ResultScreen · integration
diff --git a/AI/COMPONENT_INDEX.md b/AI/COMPONENT_INDEX.md
index bac5926..35fc1a5 100644
--- a/AI/COMPONENT_INDEX.md
+++ b/AI/COMPONENT_INDEX.md
@@ -17,7 +17,8 @@ Each component has a self-contained doc that lists its own files, tokens, animat
| Score HUD *(reference / "score meter")* | [components/ScoreHUD.md](./components/ScoreHUD.md) | Component | `Assets/UI/` | Stable | 1.1.0 | score, hud, combo, panel, meter |
| Main Menu | [components/MainMenu.md](./components/MainMenu.md) | Component | `Assets/UI/Style1/MainMenu/` | Stable | 1.0.0 | menu, navigation, play, songs |
| Song Carousel | [components/SongCarousel.md](./components/SongCarousel.md) | Component | `Assets/UI/Style1/SongCarousel/` | Stable | 1.0.0 | carousel, songs, selection, scroll |
-| **Title Screen** | [components/TitleScreen.md](./components/TitleScreen.md) | Component | `Assets/Menu/TitleScreen/` | Beta | 0.1.0 | title, menu, 1P, 2P, pushdown-automata |
+| **Title Screen** | [components/TitleScreen.md](./components/TitleScreen.md) | Component | `Assets/Menu/TitleScreen/` | Beta | 0.2.2 | title, menu, 1P, 2P, vs-bot, tutorial, pushdown-automata |
+| **Player vs Bot** | [components/PlayerVsBot.md](./components/PlayerVsBot.md) | Integration | `Assets/Bot/` | Beta | 0.2.0 | bot, difficulty, two-player, session, tier-names |
| **Song Select** | [components/SongSelect.md](./components/SongSelect.md) | Component | `Assets/Menu/SongSelect/` | Beta | 0.1.0 | song-select, carousel, difficulty, 2P, transition |
| Style 1 Button *(primitive)* | [components/Style1Button.md](./components/Style1Button.md) | Primitive | `Assets/UI/Style1/` | Stable | 1.0.0 | button, nav, press, hover |
| UI Component Gallery *(tool / board)* | [components/ComponentGallery.md](./components/ComponentGallery.md) | Tool | `Assets/UI/Style1/ComponentGallery/` | Stable | 1.1.0 | board, gallery, showcase, catalog |
diff --git a/AI/INDEX.md b/AI/INDEX.md
index 2998072..c9da0e5 100644
--- a/AI/INDEX.md
+++ b/AI/INDEX.md
@@ -44,6 +44,7 @@ Per-component docs tell you **which code files to read**. You can also **see and
| **Animations dead (hover/press/intro)** | [WORKFLOW.md → Interactive UI checklist](./WORKFLOW.md#interactive-ui-checklist-r-34) → R-34 | `UIEventSystemUtil`, `Style1ButtonInput`, scene EventSystem |
| **View / test a component live in Unity** | [components/README.md → Accessing in Unity](./components/README.md#accessing-a-component-in-unity) → that component's doc (**Editor & menus** / **Demo**) → [ComponentGallery.md](./components/ComponentGallery.md) if using the board | none — use `Rhythm → Create … Demo Scene` and/or `Rhythm → Setup UI Component Gallery` |
| **Modify the title screen** | [components/TitleScreen.md](./components/TitleScreen.md) | files listed in that doc |
+| **Player vs Bot / bot difficulty** | [components/PlayerVsBot.md](./components/PlayerVsBot.md) → [TitleScreen.md](./components/TitleScreen.md) | `BotPlayerController`, `GameSession`, `TwoPlayerGameCoordinator` |
| **Modify song select / carousel / difficulty** | [components/SongSelect.md](./components/SongSelect.md) | files listed in that doc |
| **Modify an existing component** | that component's doc in [components/](./components/) | only the files in that doc's "Files" section |
| **Touch layout / Awake / OnValidate / Initialize / scene UI** | [WORKFLOW.md → Runtime layout & wiring](./WORKFLOW.md#runtime-layout--wiring-mandatory) → [RULES.md](./RULES.md) R-25–R-32 | component doc Files + composition root if wiring |
@@ -91,6 +92,8 @@ This is the **single source of truth for what exists**. Each row links to a self
| --- | --- | --- | --- | --- |
| Score HUD *(reference)* | [components/ScoreHUD.md](./components/ScoreHUD.md) | Component | Stable | score, hud, combo, panel, meter |
| Main Menu | [components/MainMenu.md](./components/MainMenu.md) | Component | Stable | menu, navigation, play, songs |
+| Title Screen | [components/TitleScreen.md](./components/TitleScreen.md) | Component | Beta | title, menu, 1P, 2P, vs-bot |
+| Player vs Bot | [components/PlayerVsBot.md](./components/PlayerVsBot.md) | Integration | Beta | bot, difficulty, two-player |
| Song Carousel | [components/SongCarousel.md](./components/SongCarousel.md) | Component | Stable | carousel, songs, selection, scroll |
| Style 1 Button | [components/Style1Button.md](./components/Style1Button.md) | Primitive | Stable | button, nav, press, hover |
| UI Component Gallery | [components/ComponentGallery.md](./components/ComponentGallery.md) | Tool | Stable | board, gallery, showcase, catalog |
diff --git a/AI/components/MainMenu.md b/AI/components/MainMenu.md
index c18ba6e..d4af325 100644
--- a/AI/components/MainMenu.md
+++ b/AI/components/MainMenu.md
@@ -68,6 +68,7 @@ scene load, no `SceneTransitionManager`. `MenuSceneController` owns that swap.
| `_titleGroup` | `CanvasGroup` | Wraps all title-state Canvas elements; fades out on ShowSongSelect |
| `_songSelectGroup` | `CanvasGroup` | Wraps all song-select Canvas elements; fades in on ShowSongSelect |
| `_beatTimer` | `MenuBeatTimer` | Stopped on ShowTitle; started by `CarouselState` after song preview |
+| `_tutorialSceneName` | `string` | Scene loaded on Tutorial? → Yes (default `"Tutorial"`). Must be in Build Settings when ready. |
| `_background` | `SynthwaveBackgroundController` | Reference retained for future color-cycle toggling if needed |
---
@@ -76,16 +77,27 @@ scene load, no `SceneTransitionManager`. `MenuSceneController` owns that swap.
```csharp
public void ShowSongSelect(int playerCount);
+public void StartTutorial(int playerCount);
public void ShowTitle();
```
### ShowSongSelect(int playerCount)
-1. `GameSession.Instance.PlayerCount = playerCount`
-2. Animate `_headerLabel` text → "SELECT SONG" via [header-label-swap](../ANIMATION_INDEX.md#header-label-swap) (alpha 0→0→1, 0.4s total)
-3. `_titleGroup` fades out (0.20s, InQuad)
-4. `_songSelectGroup` fades in (0.25s, OutQuad)
-5. `_songSelect.InitialiseAndShow(playerCount)`
+1. Guard against duplicate transitions
+2. `GameSession.Instance.PlayerCount = playerCount`, `WantsTutorial = false`
+3. Animate `_headerLabel` text → "SELECT SONG" via [header-label-swap](../ANIMATION_INDEX.md#header-label-swap) (alpha 0→0→1, 0.4s total)
+4. `_titleGroup` fades out (0.20s, InQuad)
+5. `_songSelectGroup` fades in (0.25s, OutQuad)
+6. `_songSelect.ShowForPlayerCount(playerCount)` (or `InitialiseAndShow` equivalent)
+
+### StartTutorial(int playerCount)
+
+1. Guard against duplicate transitions
+2. `GameSession.Instance.PlayerCount = playerCount`, `WantsTutorial = true`
+3. If `_tutorialSceneName` is in Build Settings → fade via `SceneTransitionManager` into that scene
+4. If not yet in Build Settings → log a warning and fall back to `ShowSongSelect` so the flow stays playable while the tutorial scene is built
+
+Wire the real tutorial by creating a scene named `Tutorial` (or change **Tutorial Scene Name** on `MenuSceneController`) and adding it to **File → Build Settings**.
### ShowTitle()
diff --git a/AI/components/PlayerVsBot.md b/AI/components/PlayerVsBot.md
new file mode 100644
index 0000000..c08920e
--- /dev/null
+++ b/AI/components/PlayerVsBot.md
@@ -0,0 +1,152 @@
+# Player vs Bot
+
+> Rule-based P2 opponent that reuses the existing split-screen Player vs Player layout.
+> Pattern: session config + ScriptableObject difficulty table + driver over existing P2 controller
+> Status: `Beta` · Version: `0.2.0`
+> Routed from [../INDEX.md](../INDEX.md).
+
+---
+
+## Summary
+
+| Field | Value |
+| --- | --- |
+| **Mode** | `GameMode.PlayerVsBot` on `GameSession` |
+| **Layout** | Same two-screen GameScene as human 2P (`PlayerCount = 2`) |
+| **Bot driver** | `BotPlayerController` (auto-added on P2 `TreeRhythmController`) |
+| **Config asset** | `Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset` |
+| **Seed defaults** | `BotDifficultyDefaults` (asset creation / missing-tier fallback only) |
+| **Menu entry** | Title tree: PLAY → **VS Bot** → Rookie / Rival / Ace → Tutorial? |
+| **Tier names** | `Display Name` per tier on the config asset (renameable, distinct from song difficulty) |
+
+---
+
+## Files *(scoped reading)*
+
+| File | Role |
+| --- | --- |
+| `Assets/Bot/BotPlayerController.cs` | Decision loop: one roll → delay → wait for note → submit |
+| `Assets/Bot/BotDifficulty.cs` | Easy / Medium / Hard / Perfect enum |
+| `Assets/Bot/BotDifficultySettings.cs` | Per-tier serializable display name + error rate + delay range |
+| `Assets/Bot/BotDifficultyConfig.cs` | ScriptableObject table read at runtime |
+| `Assets/Bot/BotDifficultyDefaults.cs` | Single place for seed/fallback rates **and default tier names** |
+| `Assets/Bot/Editor/BotSetupEditorTools.cs` | `Rhythm → Setup Bot Difficulty Config` |
+| `Assets/Bot/Editor/BotDecisionSimulationTests.cs` | `Rhythm → Bot → Simulate Error Rates` |
+| `Assets/Session/GameMode.cs` | SinglePlayer / PlayerVsPlayer / PlayerVsBot |
+| `Assets/Session/GameSession.cs` | Persists mode + bot tier + resolved `SelectedBotLabel` across scenes |
+| `Assets/Integration/TwoPlayerGameCoordinator.cs` | Enables bot, disables P2 input, result labels, wires P1+P2 synthwave to the playing song |
+| `Assets/Integration/TreeRhythmController.cs` | Bot API: decision events, required action, submit; exposes `SongAudioSource` |
+| `Assets/Scripts/AudioFFTAnalyzer.cs` | FFT beat detection; `SetSource` retargets analysis to P1's audible song |
+| `Assets/Menu/TitleScreen/TitleScreenController.cs` | VS Bot + difficulty sub-tree |
+
+---
+
+## Menu flow
+
+```
+Main Menu (title BST)
+ PLAY → 1P | VS Bot | 2P
+ ↓
+ Rookie | Rival | Ace (labels from config; hanging tree as mode select)
+ ↓
+ Tutorial? → Yes / No
+ ↓
+ Song select (P2 row hidden; bot mirrors P1 song difficulty)
+ ↓
+ GameScene split-screen (P1 human, P2 bot)
+```
+
+Perfect exists on the config/enum for later but is **not** offered in the title menu.
+
+---
+
+
+| Value | Where it lives |
+| --- | --- |
+| Error rates / decision delays | `BotDifficultyConfig` asset (Inspector) |
+| Bot tier names shown on screen | `BotDifficultyConfig` → `Display Name` → `GetDisplayName` |
+| Seed values when creating that asset | `BotDifficultyDefaults` only |
+| Selected mode + bot tier + label | `GameSession` (`ApplyBotGameMode(difficulty, label)`) |
+| Correct BST action | `TreeNavigator.GetRequiredAction` → `BSTInsertionRules` |
+
+Gameplay scripts (`BotPlayerController`, coordinator) must call `GetSettings` / session APIs — never inline `0.60f` style rates.
+UI must read `GetDisplayName` or `GameSession.SelectedBotLabel` — never `BotDifficulty.ToString()`.
+
+---
+
+## Decision source of truth
+
+1. **Correct action:** `TreeNavigator.GetRequiredAction()` → `BSTInsertionRules.GetRequiredAction`
+2. **Submit path:** `TreeRhythmController.SubmitGameplayActionAsync` → `HandleLanePressed` → `RhythmManager.TryConsumeHit` → `TreeNavigator.TryAction`
+3. **Decision event:** `TreeRhythmController.OnBotDecisionPoint` (once per meaningful tree step; not per frame)
+4. **Intentional errors:** same invalid-action path as a human (score / feedback / error count)
+
+---
+
+## Inspector setup
+
+1. Open **GameScene**.
+2. Select **TwoPlayerCoordinator**.
+3. Assign **Bot Difficulty Config** → `BotDifficultyConfig` asset
+ (or run **Rhythm → Setup Bot Difficulty Config**).
+4. Leave **Bot Controller** empty (auto-added on P2 at runtime).
+5. Tune rates/delays on the config asset in the Inspector.
+6. Open **Menu** and assign the same asset on **TitleScreenController → Bot Difficulty Config**
+ so the VS Bot nodes show the configured names. Unassigned falls back to
+ `BotDifficultyDefaults` names, which means asset renames would not appear.
+
+### Default serialized rates *(from BotDifficultyDefaults / asset)*
+
+| Tier (enum) | Default display name | Error rate | Min delay | Max delay | In title menu? |
+| --- | --- | --- | --- | --- | --- |
+| Easy | **Rookie** | 0.60 | 1.00 s | 2.00 s | Yes |
+| Medium | **Rival** | 0.35 | 0.70 s | 1.50 s | Yes |
+| Hard | **Ace** | 0.10 | 0.40 s | 1.00 s | Yes |
+| Perfect | **Flawless** | 0.00 | 0.25 s | 0.75 s | No (config only) |
+
+The `BotDifficulty` enum keeps its Easy/Medium/Hard/Perfect members (serialization stability);
+only the **displayed** name is data. Defaults avoid the song difficulty words
+(`Difficulty`: Easy / Normal / Hard / Expert) so "Hard bot" and "Hard chart" never collide.
+
+**To rename a bot:** open `BotDifficultyConfig.asset` → edit **Display Name** on that tier.
+The menu node, transition screen and result banner all update from that one field.
+
+---
+
+## Editor menus
+
+| Menu | Purpose |
+| --- | --- |
+| `Rhythm → Setup Bot Difficulty Config` | Create/repair asset (incl. backfilling names) + assign to `TwoPlayerGameCoordinator` and `TitleScreenController` in the open scene |
+| `Rhythm → Bot → Simulate Error Rates (1000 rolls)` | Monte-Carlo check of configured probabilities |
+
+---
+
+## Mode separation
+
+| Mode | P1 | P2 |
+| --- | --- | --- |
+| Single Player | Human, full screen | Inactive |
+| Player vs Player | Human | Human input enabled |
+| Player vs Bot | Human | Input disabled; `BotPlayerController` drives actions |
+
+---
+
+## Results
+
+- P1 banner sub-label: `Player 1`
+- P2 banner sub-label: `Bot — {SelectedBotLabel}` (e.g. `Bot — Rival`)
+- Transition screen shows `BOT {SELECTEDBOTLABEL}` next to the chart difficulty
+- Only P1 confirms Continue / Quit (bot has no menu input)
+
+---
+
+## Quick test plan
+
+1. PLAY → VS Bot → Rival → Tutorial? No → song → play: split screen, P1 human, P2 moves alone.
+2. Rookie / Ace: bot still plays; Console `[Bot]` logs show matching `errorRate` in Editor.
+3. Player vs Player: both humans; bot disabled.
+4. Single Player: unchanged full-screen.
+5. `Rhythm → Bot → Simulate Error Rates`: measured rates near configured values.
+6. Restart / return to menu: no duplicate bot decisions; fresh match uses current session difficulty.
+7. Rename a tier's **Display Name** on the config: menu node, transition screen and result banner all show the new name.
diff --git a/AI/components/TitleScreen.md b/AI/components/TitleScreen.md
index 9895733..6758f24 100644
--- a/AI/components/TitleScreen.md
+++ b/AI/components/TitleScreen.md
@@ -2,7 +2,7 @@
> World-space BST tree entry point. Player navigates a 3D node tree (PLAY / SETTINGS / CREDITS)
> via a generic level-stack system; mode select (1P / VS AI / 2P) is a sub-tree pushed on Confirm.
-> Pattern: [World-Space Tree Navigation](../PATTERNS.md) · Status: `Beta` · Version: `0.2.0`
+> Pattern: [World-Space Tree Navigation](../PATTERNS.md) · Status: `Beta` · Version: `0.2.2`
> Routed from [../INDEX.md](../INDEX.md).
---
@@ -17,10 +17,10 @@
| **Prefab** | — (procedural; tree nodes are world-space GameObjects) |
| **Pattern** | PushdownAutomata + World-Space Tree Navigation |
| **Status** | Beta |
-| **Version** | 0.2.0 |
+| **Version** | 0.2.2 |
| **Author** | Pranav Sukesh |
-| **Last Updated** | 2026-07-05 |
-| **Tags** | title, menu, navigation, bst, tree, world-space, pushdown-automata |
+| **Last Updated** | 2026-07-18 |
+| **Tags** | title, menu, navigation, bst, tree, world-space, pushdown-automata, vs-bot |
---
@@ -45,7 +45,8 @@
- `Style1Palette` — all colors applied via `MaterialPropertyBlock` on world-space nodes
- `Style1Typography` — TMP labels on nodes and Canvas header
- `IMenuInputProvider` / `UnityMenuInputProvider` — input abstraction
-- `GameSession` — persists PlayerCount across scenes
+- `GameSession` — persists `PlayerCount`, `SelectedGameMode`, `SelectedBotDifficulty`, `WantsTutorial`
+- [PlayerVsBot.md](./PlayerVsBot.md) — VS Bot difficulty sub-tree + session wiring
- PrimeTween, UniTask, TextMeshPro
---
@@ -67,16 +68,29 @@ The header label ("TREEFORMANCE") is owned by `MenuSceneController` on the Canva
```
PLAY [SubTreeRoot = mode-select]
-├── SETTINGS [OnConfirmLeaf = placeholder]
-└── CREDITS [OnConfirmLeaf = placeholder]
+├── SETTINGS [Disabled placeholder]
+└── CREDITS [Disabled placeholder]
mode-select sub-tree:
- 1P [OnConfirmLeaf = MenuSceneController.ShowSongSelect(1)]
- ├── VS AI [OnConfirmLeaf = noop]
- └── 2P [OnConfirmLeaf = MenuSceneController.ShowSongSelect(2)]
+ 1P [SubTreeRoot = tutorial-prompt for SinglePlayer]
+ ├── VS Bot [SubTreeRoot = bot-difficulty]
+ └── 2P [SubTreeRoot = tutorial-prompt for PlayerVsPlayer]
+
+bot-difficulty sub-tree (after VS Bot) — same hanging-tree layout as mode select:
+ Medium [SubTreeRoot = tutorial-prompt for BotDifficulty.Medium]
+ ├── Easy [SubTreeRoot = tutorial for Easy]
+ └── Hard [SubTreeRoot = tutorial for Hard]
+ (Perfect is kept in BotDifficultyConfig / enum but not shown in the menu yet)
+
+tutorial-prompt sub-tree (after choosing a mode / bot difficulty):
+ Tutorial? [Disabled — question / title only]
+ ├── Yes [OnConfirmLeaf → ApplyHumanGameMode / ApplyBotGameMode + StartTutorial]
+ └── No [OnConfirmLeaf → ApplyHumanGameMode / ApplyBotGameMode + ShowSongSelect]
```
-Adding a Settings sub-tree in the future requires only adding `SubTreeRoot` to the SETTINGS node data — no state class changes, no controller changes.
+Back from Tutorial? pops to the previous level. Back from bot difficulty pops to mode select.
+`GameSession.SelectedGameMode`, `SelectedBotDifficulty`, and `PlayerCount` persist into GameScene.
+Player vs Bot reuses the existing two-screen layout (`PlayerCount = 2`) with `BotPlayerController` driving P2.
---
diff --git a/Assets/Bot.meta b/Assets/Bot.meta
new file mode 100644
index 0000000..d21c182
--- /dev/null
+++ b/Assets/Bot.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5fc0601f5a104673bf9eeecdc8108b6f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/BotDifficulty.cs b/Assets/Bot/BotDifficulty.cs
new file mode 100644
index 0000000..5989967
--- /dev/null
+++ b/Assets/Bot/BotDifficulty.cs
@@ -0,0 +1,13 @@
+/**
+ * Bot accuracy tier selected after choosing Player vs Bot.
+ * Error rates and decision delays live in ,
+ * not as hardcoded values on this enum.
+ *
+ */
+public enum BotDifficulty
+{
+ Easy = 0,
+ Medium = 1,
+ Hard = 2,
+ Perfect = 3
+}
diff --git a/Assets/Bot/BotDifficulty.cs.meta b/Assets/Bot/BotDifficulty.cs.meta
new file mode 100644
index 0000000..76c5a59
--- /dev/null
+++ b/Assets/Bot/BotDifficulty.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: af454eb1ac294b52a0bd655b78b6e6ab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/BotDifficultyConfig.cs b/Assets/Bot/BotDifficultyConfig.cs
new file mode 100644
index 0000000..298762b
--- /dev/null
+++ b/Assets/Bot/BotDifficultyConfig.cs
@@ -0,0 +1,139 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+/**
+ * Editable bot difficulty table: display name, error rate and decision delays per tier.
+ * Create via Assets → Create → Treeformance → Bot Difficulty Config.
+ * Assign on or
+ * for behaviour, and on for the menu labels.
+ *
+ * Gameplay code must call and UI must call
+ * . Never hardcode rates or tier names inline.
+ *
+ */
+[CreateAssetMenu(
+ fileName = "BotDifficultyConfig",
+ menuName = "Treeformance/Bot Difficulty Config")]
+public class BotDifficultyConfig : ScriptableObject
+{
+ [SerializeField]
+ [Tooltip("One entry per BotDifficulty. Edit rates/delays here. Missing tiers fall back to Perfect.")]
+ private List _difficulties = new List();
+
+ /**
+ * Returns settings for from this asset.
+ * If the tier is missing, logs an error and returns .
+ *
+ */
+ public BotDifficultySettings GetSettings(BotDifficulty difficulty)
+ {
+ if (_difficulties != null)
+ {
+ for (int i = 0; i < _difficulties.Count; i++)
+ {
+ BotDifficultySettings entry = _difficulties[i];
+ if (entry != null && entry.Difficulty == difficulty)
+ return entry;
+ }
+ }
+
+ Debug.LogError(
+ $"[BotDifficultyConfig] No settings for {difficulty} on '{name}'. " +
+ "Using Perfect fallback (errorRate=0). Edit this asset or run Rhythm → Setup Bot Difficulty Config.");
+ return BotDifficultyDefaults.SafeFallback;
+ }
+
+ /**
+ * Menu label for . Rename tiers by editing the
+ * Display Name field on this asset. The VS Bot menu reads it from here, so
+ * no label is duplicated in scene or menu code.
+ *
+ */
+ public string GetDisplayName(BotDifficulty difficulty)
+ {
+ if (_difficulties != null)
+ {
+ for (int i = 0; i < _difficulties.Count; i++)
+ {
+ BotDifficultySettings entry = _difficulties[i];
+ if (entry != null && entry.Difficulty == difficulty)
+ return entry.DisplayName;
+ }
+ }
+
+ return BotDifficultyDefaults.DisplayNameOf(difficulty);
+ }
+
+ /**
+ * Ensures the four default tiers exist when the asset is first created or repaired,
+ * and backfills labels on assets saved before Display Name existed.
+ * Values come from — change them there or in the Inspector after.
+ *
+ */
+ public void EnsureDefaultEntries()
+ {
+ if (_difficulties == null)
+ _difficulties = new List();
+
+ BotDifficultySettings[] seeds = BotDifficultyDefaults.All;
+ for (int i = 0; i < seeds.Length; i++)
+ EnsureEntry(seeds[i]);
+ }
+
+ private void EnsureEntry(BotDifficultySettings seed)
+ {
+ if (seed == null) return;
+
+ for (int i = 0; i < _difficulties.Count; i++)
+ {
+ BotDifficultySettings existing = _difficulties[i];
+ if (existing == null || existing.Difficulty != seed.Difficulty) continue;
+
+ existing.ApplyDefaultDisplayNameIfBlank(seed.DisplayName);
+ return;
+ }
+
+ _difficulties.Add(new BotDifficultySettings(
+ seed.Difficulty,
+ seed.DisplayName,
+ seed.ErrorRate,
+ seed.MinimumDecisionDelay,
+ seed.MaximumDecisionDelay));
+ }
+
+#if UNITY_EDITOR
+ /**
+ * Gives every existing tier a label without adding or removing tiers, so a
+ * config authored before Display Name existed still shows real menu text.
+ *
+ */
+ private void BackfillDisplayNames()
+ {
+ if (_difficulties == null) return;
+
+ for (int i = 0; i < _difficulties.Count; i++)
+ {
+ BotDifficultySettings entry = _difficulties[i];
+ entry?.ApplyDefaultDisplayNameIfBlank(
+ BotDifficultyDefaults.DisplayNameOf(entry.Difficulty));
+ }
+ }
+
+ private void OnValidate()
+ {
+ if (_difficulties == null || _difficulties.Count == 0)
+ {
+ EnsureDefaultEntries();
+ return;
+ }
+
+ BackfillDisplayNames();
+ }
+
+ private void Reset()
+ {
+ _difficulties = new List();
+ EnsureDefaultEntries();
+ }
+#endif
+}
diff --git a/Assets/Bot/BotDifficultyConfig.cs.meta b/Assets/Bot/BotDifficultyConfig.cs.meta
new file mode 100644
index 0000000..7dc9690
--- /dev/null
+++ b/Assets/Bot/BotDifficultyConfig.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: beaeca0a70bf431cbca1a21b30cbe6c5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/BotDifficultyDefaults.cs b/Assets/Bot/BotDifficultyDefaults.cs
new file mode 100644
index 0000000..394c1bb
--- /dev/null
+++ b/Assets/Bot/BotDifficultyDefaults.cs
@@ -0,0 +1,44 @@
+/**
+ * Seed / fallback values for bot difficulty tiers.
+ *
+ * Runtime gameplay always reads from the asset
+ * in the Inspector. These constants exist only to:
+ * - populate a new config asset ()
+ * - provide a safe Perfect fallback when a tier is missing from the asset
+ * - label the VS Bot menu when no config is assigned
+ *
+ * Default labels deliberately avoid the song difficulty names
+ * (: Easy / Normal / Hard / Expert) so "Hard bot" and
+ * "Hard chart" are never the same word on screen. Rename them per-tier on the
+ * config asset; do not scatter error rates or delays elsewhere in gameplay code.
+ *
+ */
+public static class BotDifficultyDefaults
+{
+ public const string EasyName = "Rookie";
+ public const string MediumName = "Rival";
+ public const string HardName = "Ace";
+ public const string PerfectName = "Flawless";
+
+ public static BotDifficultySettings Easy => new BotDifficultySettings(BotDifficulty.Easy, EasyName, 0.60f, 1.00f, 2.00f);
+ public static BotDifficultySettings Medium => new BotDifficultySettings(BotDifficulty.Medium, MediumName, 0.35f, 0.70f, 1.50f);
+ public static BotDifficultySettings Hard => new BotDifficultySettings(BotDifficulty.Hard, HardName, 0.10f, 0.40f, 1.00f);
+ public static BotDifficultySettings Perfect => new BotDifficultySettings(BotDifficulty.Perfect, PerfectName, 0.00f, 0.25f, 0.75f);
+
+ /** Safe fallback when config is missing or a tier is absent (0% intentional error). */
+ public static BotDifficultySettings SafeFallback => Perfect;
+
+ /** All seed tiers in menu / Inspector order. */
+ public static BotDifficultySettings[] All => new[] { Easy, Medium, Hard, Perfect };
+
+ /** Default label for one tier, used when no config asset is assigned. */
+ public static string DisplayNameOf(BotDifficulty difficulty) =>
+ difficulty switch
+ {
+ BotDifficulty.Easy => EasyName,
+ BotDifficulty.Medium => MediumName,
+ BotDifficulty.Hard => HardName,
+ BotDifficulty.Perfect => PerfectName,
+ _ => difficulty.ToString()
+ };
+}
diff --git a/Assets/Bot/BotDifficultyDefaults.cs.meta b/Assets/Bot/BotDifficultyDefaults.cs.meta
new file mode 100644
index 0000000..31802b0
--- /dev/null
+++ b/Assets/Bot/BotDifficultyDefaults.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6a8011e80aca41bc963380696240bc9b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/BotDifficultySettings.cs b/Assets/Bot/BotDifficultySettings.cs
new file mode 100644
index 0000000..8623ad7
--- /dev/null
+++ b/Assets/Bot/BotDifficultySettings.cs
@@ -0,0 +1,88 @@
+using System;
+using UnityEngine;
+
+/**
+ * Per-difficulty bot behaviour: menu label, intentional-error probability and
+ * reaction delay range. Edited on the asset
+ * in the Inspector.
+ *
+ */
+[Serializable]
+public sealed class BotDifficultySettings
+{
+ [SerializeField] private BotDifficulty _difficulty = BotDifficulty.Medium;
+
+ [SerializeField]
+ [Tooltip("Name shown on the VS Bot menu node. Rename freely. Keep it distinct from the song " +
+ "difficulties (Easy / Normal / Hard / Expert) so players can tell the two apart. " +
+ "Blank falls back to BotDifficultyDefaults (Rookie / Rival / Ace / Flawless).")]
+ private string _displayName;
+
+ [SerializeField]
+ [Range(0f, 1f)]
+ [Tooltip("Chance the bot picks a valid incorrect action at each decision point. Edit on BotDifficultyConfig — do not hardcode in scripts. 0.60 = 60%.")]
+ private float _errorRate = 0.35f;
+
+ [SerializeField]
+ [Min(0f)]
+ [Tooltip("Shortest think time (seconds) before the bot submits an action.")]
+ private float _minimumDecisionDelay = 0.7f;
+
+ [SerializeField]
+ [Min(0f)]
+ [Tooltip("Longest think time (seconds) before the bot submits an action.")]
+ private float _maximumDecisionDelay = 1.5f;
+
+ public BotDifficultySettings() { }
+
+ /** Creates settings with clamped rates and ordered delay bounds. */
+ public BotDifficultySettings(
+ BotDifficulty difficulty,
+ string displayName,
+ float errorRate,
+ float minimumDecisionDelay,
+ float maximumDecisionDelay)
+ {
+ _difficulty = difficulty;
+ _displayName = displayName;
+ _errorRate = Mathf.Clamp01(errorRate);
+ _minimumDecisionDelay = Mathf.Max(0f, minimumDecisionDelay);
+ _maximumDecisionDelay = Mathf.Max(_minimumDecisionDelay, maximumDecisionDelay);
+ }
+
+ /** Difficulty tier these settings apply to. */
+ public BotDifficulty Difficulty => _difficulty;
+
+ /**
+ * Label shown to the player. Blank fields fall back to
+ * (Rookie / Rival / …),
+ * never the raw enum name, so "Easy" cannot reappear as a bot label.
+ *
+ */
+ public string DisplayName =>
+ string.IsNullOrWhiteSpace(_displayName)
+ ? BotDifficultyDefaults.DisplayNameOf(_difficulty)
+ : _displayName.Trim();
+
+ /** Normalized intentional-error probability in [0, 1]. */
+ public float ErrorRate => Mathf.Clamp01(_errorRate);
+
+ /** Minimum seconds to wait before acting (≤ max). */
+ public float MinimumDecisionDelay =>
+ Mathf.Min(Mathf.Max(0f, _minimumDecisionDelay), MaximumDecisionDelay);
+
+ /** Maximum seconds to wait before acting. */
+ public float MaximumDecisionDelay =>
+ Mathf.Max(Mathf.Max(0f, _minimumDecisionDelay), Mathf.Max(0f, _maximumDecisionDelay));
+
+ /**
+ * Fills in a default label for configs saved before names existed, without
+ * touching a name the designer already typed.
+ *
+ */
+ internal void ApplyDefaultDisplayNameIfBlank(string defaultName)
+ {
+ if (string.IsNullOrWhiteSpace(_displayName))
+ _displayName = defaultName;
+ }
+}
diff --git a/Assets/Bot/BotDifficultySettings.cs.meta b/Assets/Bot/BotDifficultySettings.cs.meta
new file mode 100644
index 0000000..5c2d97e
--- /dev/null
+++ b/Assets/Bot/BotDifficultySettings.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a1cef73ed8004f20902ef5eb4d233aab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/BotPlayerController.cs b/Assets/Bot/BotPlayerController.cs
new file mode 100644
index 0000000..9a14a51
--- /dev/null
+++ b/Assets/Bot/BotPlayerController.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Cysharp.Threading.Tasks;
+using UnityEngine;
+
+/**
+ * Rule-based Player 2 bot for .
+ *
+ * At each tree decision point it:
+ * 1. Reads the correct from the existing navigator rules
+ * 2. Rolls once against the configured error rate
+ * 3. Picks the correct action, or a valid incorrect alternative
+ * 4. Waits a per-decision delay, then waits for a hittable rhythm note
+ * 5. Submits through so scoring,
+ * animations, and mistake tracking stay identical to a human P2
+ *
+ * Error rates and decision delays come from .
+ * Never hardcode percentages in this class.
+ *
+ */
+[AddComponentMenu("Treeformance/Bot Player Controller")]
+public sealed class BotPlayerController : MonoBehaviour
+{
+ #region Inspector
+
+ [Header("Config")]
+ [Tooltip("Error rates and decision delays. Required.")]
+ [SerializeField] private BotDifficultyConfig _difficultyConfig;
+
+ [Header("Target")]
+ [Tooltip("P2 TreeRhythmController the bot drives. Auto-found on this GameObject if empty.")]
+ [SerializeField] private TreeRhythmController _playerController;
+
+ [Header("Debug")]
+ [Tooltip("Logs every decision roll (Editor / Development builds only). Off by default because a match produces two lines per node.")]
+ [SerializeField] private bool _debugLogDecisions;
+
+ #endregion
+
+ #region State
+
+ private BotDifficulty _selectedDifficulty = BotDifficulty.Medium;
+ private BotDifficultySettings _settings;
+ private bool _gameIsRunning;
+ private bool _decisionInProgress;
+ private bool _pendingDecision;
+ private CancellationTokenSource _cts;
+
+ private static readonly NavigationAction[] AllActions =
+ {
+ NavigationAction.Left,
+ NavigationAction.Right,
+ NavigationAction.Place
+ };
+
+ #endregion
+
+ #region Public API
+
+ /** Assigns config when the component is added at runtime by the coordinator. */
+ public void SetDifficultyConfig(BotDifficultyConfig config)
+ {
+ _difficultyConfig = config;
+ }
+
+ /**
+ * Prepares the bot for a match. Call once when entering Player vs Bot gameplay.
+ * Does not start listening until .
+ *
+ */
+ public void Initialize(BotDifficulty difficulty, TreeRhythmController playerController = null)
+ {
+ if (playerController != null)
+ _playerController = playerController;
+
+ if (_playerController == null)
+ _playerController = GetComponent();
+
+ _selectedDifficulty = difficulty;
+
+ if (_difficultyConfig == null)
+ {
+ Debug.LogError("[BotPlayerController] Missing BotDifficultyConfig — bot will not act.");
+ _settings = BotDifficultyDefaults.SafeFallback;
+ return;
+ }
+
+ _settings = _difficultyConfig.GetSettings(difficulty);
+
+#if UNITY_EDITOR || DEVELOPMENT_BUILD
+ if (_debugLogDecisions)
+ {
+ Debug.Log(
+ $"[Bot] difficulty={difficulty} errorRate={_settings.ErrorRate:F2} " +
+ $"delay=[{_settings.MinimumDecisionDelay:F2},{_settings.MaximumDecisionDelay:F2}]");
+ }
+#endif
+ }
+
+ /** Subscribes to decision points and begins autonomous play. */
+ public void StartBot()
+ {
+ if (_playerController == null)
+ {
+ Debug.LogError("[BotPlayerController] No TreeRhythmController — cannot start bot.");
+ return;
+ }
+
+ StopBot();
+
+ _cts = new CancellationTokenSource();
+ _gameIsRunning = true;
+ _decisionInProgress = false;
+ _pendingDecision = false;
+
+ _playerController.OnBotDecisionPoint += OnDecisionPointReached;
+ _playerController.OnSongFinished += OnSongFinished;
+
+ // If navigation already started before we subscribed, kick one decision.
+ if (_playerController.HasActiveInsertion)
+ OnDecisionPointReached();
+ }
+
+ /** Unsubscribes, cancels pending decisions, and clears in-progress state. */
+ public void StopBot()
+ {
+ _gameIsRunning = false;
+ _decisionInProgress = false;
+ _pendingDecision = false;
+
+ if (_playerController != null)
+ {
+ _playerController.OnBotDecisionPoint -= OnDecisionPointReached;
+ _playerController.OnSongFinished -= OnSongFinished;
+ }
+
+ if (_cts != null)
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ _cts = null;
+ }
+ }
+
+ #endregion
+
+ #region Lifecycle
+
+ private void OnDisable() => StopBot();
+
+ private void OnDestroy() => StopBot();
+
+ #endregion
+
+ #region Decision Loop
+
+ private void OnSongFinished(RhythmScoreTracker _)
+ {
+ _gameIsRunning = false;
+ _cts?.Cancel();
+ }
+
+ private void OnDecisionPointReached()
+ {
+ if (!_gameIsRunning || _playerController == null)
+ return;
+
+ // One roll / one action at a time — queue if a decision is already running.
+ if (_decisionInProgress)
+ {
+ _pendingDecision = true;
+ return;
+ }
+
+ CancellationToken token = _cts != null ? _cts.Token : CancellationToken.None;
+ PerformDecisionAsync(token).Forget();
+ }
+
+ private async UniTaskVoid PerformDecisionAsync(CancellationToken token)
+ {
+ if (_decisionInProgress)
+ {
+ _pendingDecision = true;
+ return;
+ }
+
+ _decisionInProgress = true;
+
+ try
+ {
+ if (!_gameIsRunning || _playerController == null)
+ return;
+
+ // Wait until the navigator is idle (animations / hooks finished).
+ while (_gameIsRunning && _playerController.IsNavigatorBusy)
+ {
+ await UniTask.Yield(PlayerLoopTiming.Update, token);
+ }
+
+ if (!_gameIsRunning || token.IsCancellationRequested)
+ return;
+
+ NavigationAction correct = _playerController.GetRequiredAction();
+ NavigationAction selected = ChooseAction(correct);
+
+ float delay = UnityEngine.Random.Range(
+ _settings.MinimumDecisionDelay,
+ _settings.MaximumDecisionDelay);
+
+ if (delay > 0f)
+ await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token);
+
+ if (!_gameIsRunning || token.IsCancellationRequested)
+ return;
+
+ // Wait for a hittable note so the bot obeys rhythm timing (not instant).
+ while (_gameIsRunning
+ && _playerController.IsSongPlaying
+ && !_playerController.HasHittableRhythmNote())
+ {
+ await UniTask.Yield(PlayerLoopTiming.Update, token);
+ }
+
+ if (!_gameIsRunning || token.IsCancellationRequested)
+ return;
+
+ if (!_playerController.HasHittableRhythmNote())
+ return;
+
+ await _playerController.SubmitGameplayActionAsync(selected);
+
+ // Let navigator hooks finish before accepting another decision point.
+ while (_gameIsRunning && _playerController.IsNavigatorBusy)
+ {
+ await UniTask.Yield(PlayerLoopTiming.Update, token);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on StopBot / scene teardown.
+ }
+ finally
+ {
+ _decisionInProgress = false;
+
+ if (_pendingDecision && _gameIsRunning)
+ {
+ _pendingDecision = false;
+ OnDecisionPointReached();
+ }
+ }
+ }
+
+ /**
+ * One error roll per decision. If the roll says "err" but no incorrect
+ * alternative exists, falls back to the correct action.
+ *
+ */
+ private NavigationAction ChooseAction(NavigationAction correct)
+ {
+ float roll = UnityEngine.Random.value;
+ bool shouldErr = roll < _settings.ErrorRate;
+
+ NavigationAction selected = correct;
+
+ if (shouldErr)
+ {
+ List incorrect = BuildIncorrectActions(correct);
+ if (incorrect.Count > 0)
+ selected = incorrect[UnityEngine.Random.Range(0, incorrect.Count)];
+ else
+ shouldErr = false;
+ }
+
+#if UNITY_EDITOR || DEVELOPMENT_BUILD
+ if (_debugLogDecisions)
+ {
+ Debug.Log(
+ $"[Bot] roll={roll:F2} intentionalError={shouldErr} " +
+ $"correct={correct} selected={selected}");
+ }
+#endif
+
+ return selected;
+ }
+
+ private static List BuildIncorrectActions(NavigationAction correct)
+ {
+ var list = new List(AllActions.Length - 1);
+ for (int i = 0; i < AllActions.Length; i++)
+ {
+ if (AllActions[i] != correct)
+ list.Add(AllActions[i]);
+ }
+ return list;
+ }
+
+ #endregion
+}
diff --git a/Assets/Bot/BotPlayerController.cs.meta b/Assets/Bot/BotPlayerController.cs.meta
new file mode 100644
index 0000000..5ce2d04
--- /dev/null
+++ b/Assets/Bot/BotPlayerController.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f1843a44670444df9c2f6a8bb97e77b6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/Editor.meta b/Assets/Bot/Editor.meta
new file mode 100644
index 0000000..60802ae
--- /dev/null
+++ b/Assets/Bot/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5f1960806b714dcabede5cf0be50972e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/Editor/BotDecisionSimulationTests.cs b/Assets/Bot/Editor/BotDecisionSimulationTests.cs
new file mode 100644
index 0000000..cc741f6
--- /dev/null
+++ b/Assets/Bot/Editor/BotDecisionSimulationTests.cs
@@ -0,0 +1,49 @@
+#if UNITY_EDITOR
+using UnityEditor;
+using UnityEngine;
+
+/**
+ * Quick Monte-Carlo check that intentional-error rates match the config
+ * (Tests 3–6 from the Player vs Bot design). Does not enter Play mode.
+ *
+ */
+public static class BotDecisionSimulationTests
+{
+ private const int SampleCount = 1000;
+
+ [MenuItem("Rhythm/Bot/Simulate Error Rates (1000 rolls)")]
+ public static void SimulateErrorRates()
+ {
+ var config = AssetDatabase.LoadAssetAtPath(
+ "Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset");
+ if (config == null)
+ {
+ Debug.LogError("[BotDecisionSimulationTests] Missing BotDifficultyConfig asset. " +
+ "Run Rhythm → Setup Bot Difficulty Config first.");
+ return;
+ }
+
+ Simulate(config, BotDifficulty.Easy);
+ Simulate(config, BotDifficulty.Medium);
+ Simulate(config, BotDifficulty.Hard);
+ Simulate(config, BotDifficulty.Perfect);
+ }
+
+ private static void Simulate(BotDifficultyConfig config, BotDifficulty difficulty)
+ {
+ BotDifficultySettings settings = config.GetSettings(difficulty);
+ int errors = 0;
+
+ for (int i = 0; i < SampleCount; i++)
+ {
+ if (Random.value < settings.ErrorRate)
+ errors++;
+ }
+
+ float measured = errors / (float)SampleCount;
+ Debug.Log(
+ $"[BotSim] {difficulty}: configured={settings.ErrorRate:P0} " +
+ $"measured={measured:P1} ({errors}/{SampleCount})");
+ }
+}
+#endif
diff --git a/Assets/Bot/Editor/BotDecisionSimulationTests.cs.meta b/Assets/Bot/Editor/BotDecisionSimulationTests.cs.meta
new file mode 100644
index 0000000..8856bce
--- /dev/null
+++ b/Assets/Bot/Editor/BotDecisionSimulationTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e7b1a94d814f4cf4a8a6eea3f7de7472
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Bot/Editor/BotSetupEditorTools.cs b/Assets/Bot/Editor/BotSetupEditorTools.cs
new file mode 100644
index 0000000..eae38c9
--- /dev/null
+++ b/Assets/Bot/Editor/BotSetupEditorTools.cs
@@ -0,0 +1,87 @@
+#if UNITY_EDITOR
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+
+/**
+ * Editor helpers for Player vs Bot setup (R-17).
+ * Creates the default asset and wires it onto
+ * (rates/delays, GameScene) and
+ * (tier labels, Menu). Only components in the
+ * currently open scene are wired, so run it once per scene.
+ *
+ */
+public static class BotSetupEditorTools
+{
+ private const string ConfigAssetPath =
+ "Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset";
+
+ [MenuItem("Rhythm/Setup Bot Difficulty Config")]
+ public static void SetupBotDifficultyConfig()
+ {
+ BotDifficultyConfig config = EnsureConfigAsset();
+
+ // GameScene reads rates/delays; Menu reads the tier labels.
+ int wired = AssignConfigTo(config, "_botDifficultyConfig")
+ + AssignConfigTo(config, "_botDifficultyConfig");
+
+ AssetDatabase.SaveAssets();
+ EditorGUIUtility.PingObject(config);
+ Debug.Log(
+ $"[BotSetupEditorTools] BotDifficultyConfig ready at {ConfigAssetPath}. " +
+ $"Assigned to {wired} component(s) in the open scene " +
+ "(TwoPlayerGameCoordinator in GameScene, TitleScreenController in Menu — " +
+ "run this once per scene).");
+ }
+
+ private static BotDifficultyConfig EnsureConfigAsset()
+ {
+ var existing = AssetDatabase.LoadAssetAtPath(ConfigAssetPath);
+ if (existing != null)
+ {
+ existing.EnsureDefaultEntries();
+ EditorUtility.SetDirty(existing);
+ return existing;
+ }
+
+ string folder = "Assets/ScriptableObjects/Configs";
+ if (!AssetDatabase.IsValidFolder(folder))
+ {
+ if (!AssetDatabase.IsValidFolder("Assets/ScriptableObjects"))
+ AssetDatabase.CreateFolder("Assets", "ScriptableObjects");
+ AssetDatabase.CreateFolder("Assets/ScriptableObjects", "Configs");
+ }
+
+ var config = ScriptableObject.CreateInstance();
+ config.EnsureDefaultEntries();
+ AssetDatabase.CreateAsset(config, ConfigAssetPath);
+ return config;
+ }
+
+ /**
+ * Assigns to every in the open
+ * scene. Returns how many were wired so the caller can report it.
+ *
+ */
+ private static int AssignConfigTo(BotDifficultyConfig config, string propertyName)
+ where T : Component
+ {
+ int wired = 0;
+ foreach (var target in Object.FindObjectsByType(
+ FindObjectsInactive.Include, FindObjectsSortMode.None))
+ {
+ var so = new SerializedObject(target);
+ SerializedProperty prop = so.FindProperty(propertyName);
+ if (prop == null) continue;
+
+ prop.objectReferenceValue = config;
+ so.ApplyModifiedPropertiesWithoutUndo();
+ EditorUtility.SetDirty(target);
+ EditorSceneManager.MarkSceneDirty(target.gameObject.scene);
+ wired++;
+ }
+
+ return wired;
+ }
+}
+#endif
diff --git a/Assets/Bot/Editor/BotSetupEditorTools.cs.meta b/Assets/Bot/Editor/BotSetupEditorTools.cs.meta
new file mode 100644
index 0000000..7fa412f
--- /dev/null
+++ b/Assets/Bot/Editor/BotSetupEditorTools.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 356464f4846b4ab6ab75bb924e1789a2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Integration/TreeRhythmController.cs b/Assets/Integration/TreeRhythmController.cs
index ca70104..8c08bb7 100644
--- a/Assets/Integration/TreeRhythmController.cs
+++ b/Assets/Integration/TreeRhythmController.cs
@@ -127,6 +127,57 @@ public class TreeRhythmController : MonoBehaviour, IRhythmGameAdapter
/** Per-player run tracker used to build the end-of-level tree QR URL. */
public TreeRunTracker RunTracker => _runTracker;
+ /**
+ * Fired when the tree is ready for a new Left / Right / Place decision.
+ * BotPlayerController subscribes once and waits for this instead of polling every frame.
+ *
+ */
+ public event System.Action OnBotDecisionPoint;
+
+ /** True while the navigator is awaiting hooks or executing an action. */
+ public bool IsNavigatorBusy => _navigator != null && _navigator.IsBusy;
+
+ /** True when an insertion session is active (carrier on the tree). */
+ public bool HasActiveInsertion =>
+ _navigator != null && _navigator.CurrentContext != null;
+
+ /** True while the rhythm engine is playing. */
+ public bool IsSongPlaying =>
+ _rhythmManager != null && _rhythmManager.State == PlaybackState.Playing;
+
+ /** Required BST action at the current cursor (same rules as a human). */
+ public NavigationAction GetRequiredAction() =>
+ _navigator != null
+ ? _navigator.GetRequiredAction()
+ : NavigationAction.Place;
+
+ /** True when a note on the rhythm lane is inside the Ok window. */
+ public bool HasHittableRhythmNote() =>
+ _rhythmManager != null && _rhythmManager.HasHittableNote(_rhythmNoteLane);
+
+ /**
+ * Submits a tree action through the same rhythm and navigator path as player input.
+ * Used by the bot so it does not need the Input System map enabled.
+ *
+ */
+ public UniTask SubmitGameplayActionAsync(NavigationAction action)
+ {
+ int lane = ActionToLane(action);
+ return HandleLanePressed(lane);
+ }
+
+ /** Fire and forget wrapper for callers that do not await. */
+ public void SubmitGameplayAction(NavigationAction action) =>
+ SubmitGameplayActionAsync(action).Forget();
+
+ /**
+ * AudioSource this controller's rhythm engine plays through. In split screen, P1 owns
+ * the audible song clip while P2 only syncs timing, so shared systems like the
+ * synthwave FFT analyzer should listen here.
+ *
+ */
+ public AudioSource SongAudioSource => _audioSource;
+
#endregion
#region Private Systems
@@ -140,6 +191,7 @@ public class TreeRhythmController : MonoBehaviour, IRhythmGameAdapter
private TreeLayoutManager _layout;
private int _lastPulseSubdivision = -1;
private InputSystem_Actions _p2InputActions;
+ private bool _botControlled;
#endregion
@@ -235,7 +287,15 @@ private void Awake()
_visualizer.Initialize(_rhythmManager);
_cameraRig.Initialize(_config);
- _rawInput.Enable();
+ _botControlled = _isSecondaryPlayer
+ && GameSession.Instance != null
+ && GameSession.Instance.IsPlayerVsBot;
+
+ // Bot mode: keep the P2 input map disabled; BotPlayerController calls SubmitGameplayAction.
+ if (_botControlled)
+ _rawInput.Disable();
+ else
+ _rawInput.Enable();
}
private void Start()
@@ -371,7 +431,7 @@ private void SubscribeRhythmEvents()
#region Rhythm Input Gate
- private async UniTaskVoid HandleLanePressed(int lane)
+ private async UniTask HandleLanePressed(int lane)
{
NavigationAction action = LaneToAction(lane);
bool actionMatchesRequired = action == _navigator.GetRequiredAction();
@@ -403,6 +463,25 @@ private NavigationAction LaneToAction(int lane)
return _laneToAction[lane];
}
+ private int ActionToLane(NavigationAction action)
+ {
+ if (_laneToAction != null)
+ {
+ for (int i = 0; i < _laneToAction.Length; i++)
+ {
+ if (_laneToAction[i] == action)
+ return i;
+ }
+ }
+
+ return action switch
+ {
+ NavigationAction.Place => 1,
+ NavigationAction.Right => 2,
+ _ => 0
+ };
+ }
+
#endregion
#region IRhythmGameAdapter
@@ -526,6 +605,7 @@ private void OnNavigationBegan(NavigationContext context)
}
UpdateEdgePreviews(context);
+ NotifyBotDecisionPoint();
}
private void OnMoved(NavigationContext context)
@@ -535,6 +615,7 @@ private void OnMoved(NavigationContext context)
if (context.IsAtEmptySlot)
{
HideEdgePreviews();
+ // Decision point fires from OnReachedEmptySlotAsync after the settle animation.
return;
}
@@ -543,6 +624,7 @@ private void OnMoved(NavigationContext context)
MoveCarrierAndCamera(context);
UpdateEdgePreviews(context);
+ NotifyBotDecisionPoint();
}
private async UniTask OnReachedEmptySlotAsync(NavigationContext context)
@@ -559,12 +641,20 @@ await UniTask.WhenAll(
HideEdgePreviews();
ShowNextValuePreview();
+ NotifyBotDecisionPoint();
}
private void OnInvalidAction(NavigationContext context, NavigationAction attempted)
{
if (_errorCountLabel != null)
_errorCountLabel.text = $"Errors: {_navigator.ErrorCount}";
+ NotifyBotDecisionPoint();
+ }
+
+ private void NotifyBotDecisionPoint()
+ {
+ if (_botControlled)
+ OnBotDecisionPoint?.Invoke();
}
private async UniTask OnNodeInsertedAsync(TreeNode node)
diff --git a/Assets/Integration/TwoPlayerGameCoordinator.cs b/Assets/Integration/TwoPlayerGameCoordinator.cs
index 032de47..6398615 100644
--- a/Assets/Integration/TwoPlayerGameCoordinator.cs
+++ b/Assets/Integration/TwoPlayerGameCoordinator.cs
@@ -3,12 +3,12 @@
using UnityEngine;
/**
- * Configures the game scene for 1P or 2P split-screen based on
- * . In 1P mode the P2 hierarchy
- * and camera are deactivated. In 2P mode both cameras get viewport
- * rects for a vertical split, and the secondary player's rhythm engine
- * is synced to the primary player's scheduled DSP start time.
- * Also triggers the result screen when the song finishes.
+ * Configures the game scene for 1P, 2P, or Player vs Bot split-screen based on
+ * . In 1P mode the P2 hierarchy and camera are deactivated.
+ * In 2P / vs-bot mode both cameras get viewport rects for a vertical split, and the
+ * secondary player's rhythm engine is synced to the primary player's scheduled DSP
+ * start time. Player vs Bot disables P2 human input and starts
+ * . Also triggers the result screen when the song finishes.
*
*/
public class TwoPlayerGameCoordinator : MonoBehaviour
@@ -58,17 +58,27 @@ public class TwoPlayerGameCoordinator : MonoBehaviour
[Tooltip("P1 synthwave background controller (auto-found if left null).")]
[SerializeField] private SynthwaveBackgroundController _p1Background;
- [Tooltip("FFT analyzer on the game AudioSource — shared for both P1 and P2 audio reactivity.")]
+ [Tooltip("FFT analyzer shared by both synthwave backgrounds. Retargeted at runtime to P1's " +
+ "playing AudioSource because P2 PlaySync does not start a clip.")]
[SerializeField] private AudioFFTAnalyzer _audioAnalyzer;
+ [Header("Player vs Bot")]
+ [Tooltip("Error rates / decision delays for the P2 bot. Required for Player vs Bot mode.")]
+ [SerializeField] private BotDifficultyConfig _botDifficultyConfig;
+
+ [Tooltip("Optional. Auto-added on the P2 controller when Player vs Bot starts.")]
+ [SerializeField] private BotPlayerController _botController;
+
#endregion
#region Private State
private bool _twoPlayer;
+ private bool _vsBot;
private bool _resultsShown;
private InputSystem_Actions _resultInputActions;
private SynthwaveBackgroundController _p2BackgroundCtrl;
+ private Action _p1BeatHandler;
private Action _p2BeatHandler;
#endregion
@@ -80,6 +90,7 @@ private void Awake()
_twoPlayer = GameSession.Instance?.PlayerCount == 2
&& _p2Camera != null
&& _p2Controller != null;
+ _vsBot = _twoPlayer && GameSession.Instance != null && GameSession.Instance.IsPlayerVsBot;
if (_p2Camera != null) _p2Camera.gameObject.SetActive(_twoPlayer);
if (_p2Controller != null) _p2Controller.gameObject.SetActive(_twoPlayer);
@@ -102,8 +113,14 @@ private void Awake()
if (p2Layer >= 0 && _p2Camera != null) _p2Camera.cullingMask &= ~(1 << p1Layer);
SetupP2Background();
+ SetupBotIfNeeded();
}
+ // P1 owns the audible song. Point the FFT at that source and pulse P1's floor on
+ // every beat. Without this, only the bot side reacts because P2 already has its
+ // own OnBeat to Pulse wire and the analyzer used to sit on P2's silent AudioSource.
+ WirePrimaryBackgroundReaction();
+
if (_p1Controller != null)
_p1Controller.OnSongFinished += OnSongFinished;
@@ -126,6 +143,43 @@ private void Awake()
DisableOverlaySparkles();
}
+ /**
+ * Disables P2 human input and starts when
+ * is true. No-ops for human 2P.
+ *
+ */
+ private void SetupBotIfNeeded()
+ {
+ if (!_vsBot || _p2Controller == null)
+ {
+ if (_botController != null)
+ _botController.enabled = false;
+ return;
+ }
+
+ _p2Controller.DisablePlayerInput();
+
+ if (_botDifficultyConfig == null)
+ {
+ Debug.LogError(
+ "[TwoPlayerGameCoordinator] Player vs Bot is active but BotDifficultyConfig " +
+ "is not assigned. P2 will not act.");
+ return;
+ }
+
+ if (_botController == null)
+ _botController = _p2Controller.GetComponent();
+ if (_botController == null)
+ _botController = _p2Controller.gameObject.AddComponent();
+
+ _botController.enabled = true;
+ _botController.SetDifficultyConfig(_botDifficultyConfig);
+ _botController.Initialize(
+ GameSession.Instance.SelectedBotDifficulty,
+ _p2Controller);
+ _botController.StartBot();
+ }
+
private static void DisableOverlaySparkles()
{
foreach (var emitter in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None))
@@ -138,10 +192,14 @@ private static void DisableOverlaySparkles()
private void OnDestroy()
{
+ _botController?.StopBot();
+
if (_p1Controller != null)
{
_p1Controller.OnSongScheduled -= OnP1SongScheduled;
_p1Controller.OnSongFinished -= OnSongFinished;
+ if (_p1BeatHandler != null)
+ _p1Controller.OnBeat -= _p1BeatHandler;
}
if (_p2Controller != null && _p2BeatHandler != null)
_p2Controller.OnBeat -= _p2BeatHandler;
@@ -211,6 +269,17 @@ private async UniTaskVoid ShowResultsAsync(RhythmScoreTracker p1Score)
_p1Controller?.DisablePlayerInput();
if (_twoPlayer) _p2Controller?.DisablePlayerInput();
+ _botController?.StopBot();
+
+ // Identity labels on the result banners.
+ _resultScreen.SetIdentityLabel("Player 1");
+ if (_twoPlayer && _p2ResultScreen != null)
+ {
+ string p2Label = _vsBot
+ ? $"Bot — {GameSession.Instance.SelectedBotLabel}"
+ : "Player 2";
+ _p2ResultScreen.SetIdentityLabel(p2Label);
+ }
// Freeze placement recording immediately so late inputs cannot append after song end.
_p1Controller?.RunTracker?.FinishLevel();
@@ -229,28 +298,49 @@ private async UniTaskVoid ShowResultsAsync(RhythmScoreTracker p1Score)
if (_twoPlayer && _p2ResultScreen != null)
{
- var p2Input = new UnityMenuInputProvider(_resultInputActions.Menu2);
RhythmScoreTracker p2Score = _p2Controller?.FinalScore;
// Both screens must intercept confirm so the coordinator can require agreement.
_resultScreen.UseCoordinatedTransition = true;
_p2ResultScreen.UseCoordinatedTransition = true;
- // Animate both result screens in parallel, each zooming its own camera.
- await UniTask.WhenAll(
- _resultScreen.ShowAsync(p1Score, p1Input, playerCamera: _p1Camera),
- _p2ResultScreen.ShowAsync(p2Score, p2Input, playerCamera: _p2Camera));
+ if (_vsBot)
+ {
+ // Bot side has no human — P1 alone chooses Continue / Quit; P2 mirrors.
+ await UniTask.WhenAll(
+ _resultScreen.ShowAsync(p1Score, p1Input, playerCamera: _p1Camera),
+ _p2ResultScreen.ShowAsync(p2Score, null, allowNavigation: false, playerCamera: _p2Camera));
- // Block until both players confirm the same option.
- string sceneName = await WaitForAgreementAsync(_resultScreen, _p2ResultScreen);
+ string sceneName = await WaitForPlayerOneOnlyAsync(_resultScreen);
- _resultScreen.SetInputEnabled(false);
- _p2ResultScreen.SetInputEnabled(false);
+ _resultScreen.SetInputEnabled(false);
+ _p2ResultScreen.SetInputEnabled(false);
- if (SceneTransitionManager.Instance != null)
- await SceneTransitionManager.Instance.TransitionToAsync(sceneName, 0.35f);
+ if (SceneTransitionManager.Instance != null)
+ await SceneTransitionManager.Instance.TransitionToAsync(sceneName, 0.35f);
+ else
+ UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName);
+ }
else
- UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName);
+ {
+ var p2Input = new UnityMenuInputProvider(_resultInputActions.Menu2);
+
+ // Animate both result screens in parallel, each zooming its own camera.
+ await UniTask.WhenAll(
+ _resultScreen.ShowAsync(p1Score, p1Input, playerCamera: _p1Camera),
+ _p2ResultScreen.ShowAsync(p2Score, p2Input, playerCamera: _p2Camera));
+
+ // Block until both players confirm the same option.
+ string sceneName = await WaitForAgreementAsync(_resultScreen, _p2ResultScreen);
+
+ _resultScreen.SetInputEnabled(false);
+ _p2ResultScreen.SetInputEnabled(false);
+
+ if (SceneTransitionManager.Instance != null)
+ await SceneTransitionManager.Instance.TransitionToAsync(sceneName, 0.35f);
+ else
+ UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName);
+ }
}
else
{
@@ -259,6 +349,29 @@ await UniTask.WhenAll(
}
}
+ /**
+ * Player vs Bot results: only P1 confirms Continue / Quit.
+ *
+ */
+ private async UniTask WaitForPlayerOneOnlyAsync(ResultScreenController p1Screen)
+ {
+ var tcs = new UniTaskCompletionSource();
+
+ Action onP1 = v =>
+ {
+ if (GameSession.Instance != null)
+ GameSession.Instance.PostGameIntent = v
+ ? PostGameIntent.SongSelect
+ : PostGameIntent.Title;
+ tcs.TrySetResult("Menu");
+ };
+
+ p1Screen.OnConfirmRequested += onP1;
+ string result = await tcs.Task;
+ p1Screen.OnConfirmRequested -= onP1;
+ return result;
+ }
+
/**
* Waits until P1 and P2 have both confirmed the same button.
* Either player can freely navigate or re-confirm until both selections match.
@@ -317,6 +430,33 @@ void Check()
#region P2 Background Setup
+ /**
+ * Points the shared FFT at P1's playing song and pulses P1's synthwave floor on every
+ * rhythm beat. Needed because RhythmManager.PlaySync never starts P2's AudioSource, so
+ * without this only the bot side (which already has OnBeat to Pulse) appears reactive.
+ *
+ */
+ private void WirePrimaryBackgroundReaction()
+ {
+ if (_p1Background == null)
+ _p1Background = FindFirstObjectByType();
+
+ if (_audioAnalyzer != null && _p1Controller != null && _p1Controller.SongAudioSource != null)
+ _audioAnalyzer.SetSource(_p1Controller.SongAudioSource);
+
+ if (_p1Background == null || _p1Controller == null) return;
+
+ var p1Reactive = _p1Background.GetComponent();
+ if (p1Reactive != null && _audioAnalyzer != null)
+ p1Reactive.SetAnalyzer(_audioAnalyzer);
+
+ if (_p1BeatHandler != null)
+ _p1Controller.OnBeat -= _p1BeatHandler;
+
+ _p1BeatHandler = _ => _p1Background.Pulse();
+ _p1Controller.OnBeat += _p1BeatHandler;
+ }
+
private void SetupP2Background()
{
if (_p1Camera == null || _p2Camera == null) return;
diff --git a/Assets/Menu/Data/MenuNodeData.cs b/Assets/Menu/Data/MenuNodeData.cs
index aeb350d..8a7f1c8 100644
--- a/Assets/Menu/Data/MenuNodeData.cs
+++ b/Assets/Menu/Data/MenuNodeData.cs
@@ -29,7 +29,7 @@ public class MenuNodeData
/**
* When true the node is navigable but confirm is blocked.
- * Used for placeholder features (Settings, Credits, VS AI) that are not yet implemented.
+ * Used for placeholder features (Settings, Credits) that are not yet implemented.
*
*/
public bool Disabled;
diff --git a/Assets/Menu/MenuSceneController.cs b/Assets/Menu/MenuSceneController.cs
index b418e3c..a1946a1 100644
--- a/Assets/Menu/MenuSceneController.cs
+++ b/Assets/Menu/MenuSceneController.cs
@@ -42,6 +42,11 @@ public class MenuSceneController : MonoBehaviour
[Header("Beat Timer")]
[SerializeField] private MenuBeatTimer _beatTimer;
+ [Header("Tutorial")]
+ [Tooltip("Scene name loaded when the player chooses Yes on Tutorial?. " +
+ "Add that scene to Build Settings when it exists. Until then, Yes falls back to song select.")]
+ [SerializeField] private string _tutorialSceneName = "Tutorial";
+
[Header("Timing")]
[SerializeField] private float _fadeOutDuration = 0.20f;
[SerializeField] private float _fadeInDuration = 0.25f;
@@ -60,9 +65,10 @@ public class MenuSceneController : MonoBehaviour
#region Lifecycle
- private PostGameIntent _pendingIntent;
+ private PostGameIntent _pendingIntent;
private InputSystem_Actions _overlayInputActions;
private IMenuInputProvider _overlayInput;
+ private bool _flowTransitionBusy;
private void Awake()
{
@@ -108,16 +114,64 @@ private void Start()
*/
public void ShowSongSelect(int playerCount)
{
+ if (_flowTransitionBusy) return;
+ _flowTransitionBusy = true;
+
_titleScreen?.HideTree();
- GameSession.Instance.PlayerCount = playerCount;
+ if (GameSession.Instance != null)
+ {
+ GameSession.Instance.PlayerCount = playerCount;
+ GameSession.Instance.WantsTutorial = false;
+ }
TransitionAsync(
outGroup: _titleGroup,
inGroup: _songSelectGroup,
inHeader: SelectHeaderText,
- onInStart: () => _songSelect.ShowForPlayerCount(playerCount)
+ onInStart: () =>
+ {
+ _flowTransitionBusy = false;
+ _songSelect.ShowForPlayerCount(playerCount);
+ }
).Forget();
}
+ /**
+ * Player chose Yes on Tutorial?. Stores the selected mode on
+ * and loads the tutorial scene when it is in
+ * Build Settings. If the scene is not ready yet, falls back to song select
+ * so the flow stays playable while the tutorial is being built.
+ *
+ */
+ public void StartTutorial(int playerCount)
+ {
+ if (_flowTransitionBusy) return;
+ _flowTransitionBusy = true;
+
+ if (GameSession.Instance != null)
+ {
+ GameSession.Instance.PlayerCount = playerCount;
+ GameSession.Instance.WantsTutorial = true;
+ }
+
+ string sceneName = string.IsNullOrWhiteSpace(_tutorialSceneName)
+ ? "Tutorial"
+ : _tutorialSceneName.Trim();
+
+ if (!Application.CanStreamedLevelBeLoaded(sceneName))
+ {
+ Debug.LogWarning(
+ $"[MenuSceneController] Tutorial scene '{sceneName}' is not in Build Settings yet. " +
+ "Add the scene (File → Build Settings) or set Tutorial Scene Name on MenuSceneController. " +
+ "Falling back to song select for now.");
+ _flowTransitionBusy = false;
+ ShowSongSelect(playerCount);
+ return;
+ }
+
+ _titleScreen?.HideTree();
+ LoadTutorialSceneAsync(sceneName).Forget();
+ }
+
/**
* Jumps straight to song select without any outgoing title fade.
* Used when the scene was entered via PostGameIntent — the title group is already
@@ -126,7 +180,11 @@ public void ShowSongSelect(int playerCount)
*/
private void ShowSongSelectDirect(int playerCount)
{
- GameSession.Instance.PlayerCount = playerCount;
+ if (GameSession.Instance != null)
+ {
+ GameSession.Instance.PlayerCount = playerCount;
+ GameSession.Instance.WantsTutorial = false;
+ }
_songSelect?.ShowForPlayerCount(playerCount);
SetGroupVisible(_songSelectGroup, true);
if (_songSelectGroup != null)
@@ -189,6 +247,21 @@ public void ShowTitleFromOverlay()
).Forget();
}
+ private async UniTaskVoid LoadTutorialSceneAsync(string sceneName)
+ {
+ try
+ {
+ if (SceneTransitionManager.Instance != null)
+ await SceneTransitionManager.Instance.TransitionToAsync(sceneName, _fadeOutDuration);
+ else
+ UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName);
+ }
+ finally
+ {
+ _flowTransitionBusy = false;
+ }
+ }
+
/**
* Fade back to the title tree. Stops the beat timer and re-enters the title screen.
*
diff --git a/Assets/Menu/SongSelect/DifficultySelectState.cs b/Assets/Menu/SongSelect/DifficultySelectState.cs
index 2616af0..7e8e86b 100644
--- a/Assets/Menu/SongSelect/DifficultySelectState.cs
+++ b/Assets/Menu/SongSelect/DifficultySelectState.cs
@@ -33,7 +33,8 @@ public override void OnEnter()
Host.P1Input.OnConfirm += P1Confirm;
Host.P1Input.OnBack += OnBack;
- if (Host.P2Input != null && GameSession.Instance?.PlayerCount == 2)
+ if (Host.P2Input != null && GameSession.Instance != null
+ && GameSession.Instance.IsHumanVersusHuman)
{
Host.P2Input.OnLeft += P2Left;
Host.P2Input.OnRight += P2Right;
@@ -130,8 +131,10 @@ private void P2Confirm()
private void CheckBothReady()
{
- bool twoPlayer = GameSession.Instance?.PlayerCount == 2 && Host.P2Input != null;
- bool allReady = twoPlayer ? (_p1Ready && _p2Ready) : _p1Ready;
+ // Player vs Bot: only P1 confirms song difficulty; bot mirrors P1's beatmap tier.
+ bool needsP2 = GameSession.Instance != null && GameSession.Instance.IsHumanVersusHuman
+ && Host.P2Input != null;
+ bool allReady = needsP2 ? (_p1Ready && _p2Ready) : _p1Ready;
if (allReady && !_committing)
{
_committing = true;
@@ -157,6 +160,10 @@ private async UniTaskVoid CommitAsync()
Difficulty p1Diff = Host.DifficultyView.GetP1Selection();
Difficulty p2Diff = Host.DifficultyView.GetP2Selection();
+ // Bot reuses P1's song difficulty so both screens share the same beatmap tier.
+ if (GameSession.Instance != null && GameSession.Instance.IsPlayerVsBot)
+ p2Diff = p1Diff;
+
GameSession.Instance.P1Difficulty = p1Diff;
GameSession.Instance.P2Difficulty = p2Diff;
GameSession.Instance.SelectedSong = song;
diff --git a/Assets/Menu/SongSelect/DifficultySelectView.cs b/Assets/Menu/SongSelect/DifficultySelectView.cs
index 538e780..e36144f 100644
--- a/Assets/Menu/SongSelect/DifficultySelectView.cs
+++ b/Assets/Menu/SongSelect/DifficultySelectView.cs
@@ -117,7 +117,11 @@ public void Populate(SongManifest song, int playerCount, MenuVisualConfig cfg, I
if (_authorLabel != null) _authorLabel.text = primary?.Author ?? string.Empty;
if (_albumLabel != null) _albumLabel.text = primary?.Album ?? string.Empty;
- if (_p2Row != null) _p2Row.SetActive(playerCount == 2);
+ // Hide P2 difficulty row for 1P and Player vs Bot (bot mirrors P1's song difficulty).
+ bool showP2Row = playerCount == 2
+ && GameSession.Instance != null
+ && GameSession.Instance.IsHumanVersusHuman;
+ if (_p2Row != null) _p2Row.SetActive(showP2Row);
CacheHierarchy();
RefreshP1Row();
diff --git a/Assets/Menu/SongSelect/SongSelectController.cs b/Assets/Menu/SongSelect/SongSelectController.cs
index eb4d61b..05435eb 100644
--- a/Assets/Menu/SongSelect/SongSelectController.cs
+++ b/Assets/Menu/SongSelect/SongSelectController.cs
@@ -150,8 +150,9 @@ private void Awake()
_inputActions = new InputSystem_Actions();
P1Input = new UnityMenuInputProvider(_inputActions.Menu1);
- bool twoPlayer = GameSession.Instance?.PlayerCount == 2;
- P2Input = twoPlayer ? new UnityMenuInputProvider(_inputActions.Menu2) : null;
+ // Human 2P only — bot mode does not need a second menu input map.
+ bool twoHuman = GameSession.Instance != null && GameSession.Instance.IsHumanVersusHuman;
+ P2Input = twoHuman ? new UnityMenuInputProvider(_inputActions.Menu2) : null;
}
}
@@ -384,8 +385,8 @@ public void ShowForPlayerCount(int playerCount)
{
if (!Application.isPlaying) return;
GameSession.Instance.PlayerCount = playerCount;
- bool twoPlayer = playerCount == 2;
- if (P2Input == null && twoPlayer && _inputActions != null)
+ bool twoHuman = GameSession.Instance.IsHumanVersusHuman;
+ if (P2Input == null && twoHuman && _inputActions != null)
P2Input = new UnityMenuInputProvider(_inputActions.Menu2);
P1Input.Enable();
P2Input?.Enable();
diff --git a/Assets/Menu/SongSelect/TransitionScreenController.cs b/Assets/Menu/SongSelect/TransitionScreenController.cs
index 4daa63e..b1bed59 100644
--- a/Assets/Menu/SongSelect/TransitionScreenController.cs
+++ b/Assets/Menu/SongSelect/TransitionScreenController.cs
@@ -79,9 +79,13 @@ public async UniTask RunTransitionAsync(SongManifest song, Difficulty p1Diff, Di
_diffLabel.color = Color.white;
string p1Hex = ColorUtility.ToHtmlStringRGB(GetDifficultyColor(p1Diff));
string p2Hex = ColorUtility.ToHtmlStringRGB(GetDifficultyColor(p2Diff));
+ bool vsBot = GameSession.Instance != null && GameSession.Instance.IsPlayerVsBot;
+ string p2Name = vsBot
+ ? $"BOT {GameSession.Instance.SelectedBotLabel.ToUpperInvariant()}"
+ : "P2";
_diffLabel.text = $"P1: {p1Diff.ToString().ToUpperInvariant()}"
+ $" · "
- + $"P2: {p2Diff.ToString().ToUpperInvariant()}";
+ + $"{p2Name}: {p2Diff.ToString().ToUpperInvariant()}";
}
else
{
diff --git a/Assets/Menu/TitleScreen/TitleScreenController.cs b/Assets/Menu/TitleScreen/TitleScreenController.cs
index d4ca516..613bead 100644
--- a/Assets/Menu/TitleScreen/TitleScreenController.cs
+++ b/Assets/Menu/TitleScreen/TitleScreenController.cs
@@ -6,9 +6,9 @@
using UnityEngine.UI;
/**
- * Drives the title screen. Handles press-start and 1P/2P selection via a
- * PushdownAutomata state machine. Builds its own child hierarchy procedurally
- * (R-19, R-36) and applies Style1 tokens for all colours and typography (R-07).
+ * Drives the title screen. Handles press-start and mode selection (1P / VS Bot / 2P)
+ * via a PushdownAutomata state machine over a world-space BST tree. Builds its own
+ * child hierarchy procedurally (R-19, R-36) and applies Style1 tokens (R-07).
*
*/
[ExecuteAlways]
@@ -41,6 +41,11 @@ public class TitleScreenController : MonoBehaviour
[Header("Tree")]
[SerializeField] private MenuTreeController _treeController;
+ [Header("Player vs Bot")]
+ [Tooltip("Supplies the VS Bot tier labels shown on the title tree. Rename tiers on this " +
+ "asset rather than in code. Falls back to BotDifficultyDefaults when unassigned.")]
+ [SerializeField] private BotDifficultyConfig _botDifficultyConfig;
+
#endregion
#region Internal Properties (accessed by states)
@@ -140,8 +145,9 @@ public void ReEnter()
public void HideTree() => _treeController?.BeginExitAll();
/**
- * Constructs the tree for the title screen. The tree
- * structure is: PLAY (sub-tree: 1P/VS AI/2P), SETTINGS (placeholder), CREDITS (placeholder).
+ * Constructs the tree for the title screen.
+ * Structure: PLAY → mode select (1P / VS Bot / 2P) → bot difficulty (bot only)
+ * → Tutorial? (Yes / No).
*
*/
internal MenuNodeData BuildMenuTree()
@@ -150,18 +156,18 @@ internal MenuNodeData BuildMenuTree()
var modeSelectRoot = new MenuNodeData
{
- Label = "1P",
- Left = new MenuNodeData
+ Label = "1P",
+ SubTreeRoot = BuildTutorialPrompt(menuScene, playerCount: 1),
+ Left = new MenuNodeData
{
- Label = "VS AI",
- Disabled = true,
+ Label = "VS Bot",
+ SubTreeRoot = BuildBotDifficultySelect(menuScene),
},
Right = new MenuNodeData
{
- Label = "2P",
- OnConfirmLeaf = () => { menuScene?.ShowSongSelect(2); return UniTask.CompletedTask; },
+ Label = "2P",
+ SubTreeRoot = BuildTutorialPrompt(menuScene, playerCount: 2),
},
- OnConfirmLeaf = () => { menuScene?.ShowSongSelect(1); return UniTask.CompletedTask; },
};
return new MenuNodeData
@@ -181,6 +187,110 @@ internal MenuNodeData BuildMenuTree()
};
}
+ /**
+ * Bot difficulty sub-tree shown after confirming VS Bot.
+ * Same hanging-tree layout as mode select: medium tier in the centre, easier left,
+ * harder right. Labels come from BotDifficultyConfig so opponent names can differ
+ * from song difficulty words (Easy / Normal / Hard). Perfect stays in the config
+ * and enum for later but is not offered in the menu yet.
+ *
+ */
+ private MenuNodeData BuildBotDifficultySelect(MenuSceneController menuScene)
+ {
+ return new MenuNodeData
+ {
+ Label = BotTierLabel(BotDifficulty.Medium),
+ SubTreeRoot = BuildTutorialPromptForBot(menuScene, BotDifficulty.Medium),
+ Left = new MenuNodeData
+ {
+ Label = BotTierLabel(BotDifficulty.Easy),
+ SubTreeRoot = BuildTutorialPromptForBot(menuScene, BotDifficulty.Easy),
+ },
+ Right = new MenuNodeData
+ {
+ Label = BotTierLabel(BotDifficulty.Hard),
+ SubTreeRoot = BuildTutorialPromptForBot(menuScene, BotDifficulty.Hard),
+ },
+ };
+ }
+
+ /**
+ * Tutorial confirmation after a human 1P / 2P mode is chosen.
+ * Writes on Yes / No.
+ *
+ */
+ private static MenuNodeData BuildTutorialPrompt(MenuSceneController menuScene, int playerCount)
+ {
+ return new MenuNodeData
+ {
+ Label = "Tutorial?",
+ Disabled = true, // title / question only — navigate Left/Right to answer
+ Left = new MenuNodeData
+ {
+ Label = "Yes",
+ OnConfirmLeaf = () =>
+ {
+ GameSession.Instance?.ApplyHumanGameMode(playerCount);
+ menuScene?.StartTutorial(playerCount);
+ return UniTask.CompletedTask;
+ },
+ },
+ Right = new MenuNodeData
+ {
+ Label = "No",
+ OnConfirmLeaf = () =>
+ {
+ GameSession.Instance?.ApplyHumanGameMode(playerCount);
+ menuScene?.ShowSongSelect(playerCount);
+ return UniTask.CompletedTask;
+ },
+ },
+ };
+ }
+
+ /**
+ * Tutorial confirmation after a bot difficulty is chosen.
+ * Persists Player vs Bot plus the chosen tier (and its display label) on GameSession
+ * before continuing into tutorial or song select.
+ *
+ */
+ private MenuNodeData BuildTutorialPromptForBot(
+ MenuSceneController menuScene, BotDifficulty difficulty)
+ {
+ string label = BotTierLabel(difficulty);
+ return new MenuNodeData
+ {
+ Label = "Tutorial?",
+ Disabled = true,
+ Left = new MenuNodeData
+ {
+ Label = "Yes",
+ OnConfirmLeaf = () =>
+ {
+ GameSession.Instance?.ApplyBotGameMode(difficulty, label);
+ menuScene?.StartTutorial(2);
+ return UniTask.CompletedTask;
+ },
+ },
+ Right = new MenuNodeData
+ {
+ Label = "No",
+ OnConfirmLeaf = () =>
+ {
+ GameSession.Instance?.ApplyBotGameMode(difficulty, label);
+ menuScene?.ShowSongSelect(2);
+ return UniTask.CompletedTask;
+ },
+ },
+ };
+ }
+
+ /** Label for one bot tier from the config, or the seeded default name. */
+ private string BotTierLabel(BotDifficulty difficulty) =>
+ _botDifficultyConfig != null
+ ? _botDifficultyConfig.GetDisplayName(difficulty)
+ : BotDifficultyDefaults.DisplayNameOf(difficulty);
+
private void OnDestroy()
{
P1Input?.Disable();
diff --git a/Assets/Menu/TitleScreen/TitleScreenPlayerModeSelectState.cs b/Assets/Menu/TitleScreen/TitleScreenPlayerModeSelectState.cs
index cf2ee8e..a45315e 100644
--- a/Assets/Menu/TitleScreen/TitleScreenPlayerModeSelectState.cs
+++ b/Assets/Menu/TitleScreen/TitleScreenPlayerModeSelectState.cs
@@ -46,7 +46,7 @@ private void SelectTwoPlayer()
private async UniTaskVoid CommitAsync()
{
int playerCount = _selection + 1;
- GameSession.Instance.PlayerCount = playerCount;
+ GameSession.Instance.ApplyHumanGameMode(playerCount);
await Host.Hooks.OnPlayerModeChosen(playerCount);
await Host.Hooks.OnTransitionOut();
diff --git a/Assets/Rhythm/Core/RhythmManager.cs b/Assets/Rhythm/Core/RhythmManager.cs
index 1c26c08..84c6468 100644
--- a/Assets/Rhythm/Core/RhythmManager.cs
+++ b/Assets/Rhythm/Core/RhythmManager.cs
@@ -410,6 +410,32 @@ public void Tick()
#region Hit Evaluation
+ /**
+ * True when at least one active note in is currently
+ * inside the Ok hit window. Used by the bot to time submissions on-beat.
+ *
+ */
+ public bool HasHittableNote(int lane)
+ {
+ if (_hitWindows == null || _state != PlaybackState.Playing)
+ return false;
+
+ double dspNow = AudioSettings.dspTime;
+ float calibOffset = _calibration != null ? _calibration.CalibrationOffsetSeconds : 0f;
+
+ for (int i = 0; i < _activeNotes.Count; i++)
+ {
+ if (_activeNotes[i].Note.Lane != lane) continue;
+
+ float absError = Mathf.Abs(
+ (float)(dspNow - _activeNotes[i].HitDspTime + calibOffset));
+ if (absError <= _hitWindows.OkWindow)
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Attempts to consume the nearest hittable note in the given lane.
* Returns a with IsHit = false when no
diff --git a/Assets/Scenes/GameScene.unity b/Assets/Scenes/GameScene.unity
index 1488ab9..532f3f4 100644
--- a/Assets/Scenes/GameScene.unity
+++ b/Assets/Scenes/GameScene.unity
@@ -164,6 +164,8 @@ MonoBehaviour:
_p2TreeLayer: Player2
_p1Background: {fileID: 0}
_audioAnalyzer: {fileID: 805334469}
+ _botDifficultyConfig: {fileID: 11400000, guid: 85aef53b455b420a96aa661729fd7b14, type: 2}
+ _botController: {fileID: 0}
--- !u!4 &6013698
Transform:
m_ObjectHideFlags: 0
diff --git a/Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset b/Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset
new file mode 100644
index 0000000..726d072
--- /dev/null
+++ b/Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset
@@ -0,0 +1,35 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: beaeca0a70bf431cbca1a21b30cbe6c5, type: 3}
+ m_Name: BotDifficultyConfig
+ m_EditorClassIdentifier:
+ _difficulties:
+ - _difficulty: 0
+ _displayName: Rookie
+ _errorRate: 0.6
+ _minimumDecisionDelay: 1
+ _maximumDecisionDelay: 2
+ - _difficulty: 1
+ _displayName: Rival
+ _errorRate: 0.35
+ _minimumDecisionDelay: 0.7
+ _maximumDecisionDelay: 1.5
+ - _difficulty: 2
+ _displayName: Ace
+ _errorRate: 0.1
+ _minimumDecisionDelay: 0.4
+ _maximumDecisionDelay: 1
+ - _difficulty: 3
+ _displayName: Flawless
+ _errorRate: 0
+ _minimumDecisionDelay: 0.25
+ _maximumDecisionDelay: 0.75
diff --git a/Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset.meta b/Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset.meta
new file mode 100644
index 0000000..0cce08d
--- /dev/null
+++ b/Assets/ScriptableObjects/Configs/BotDifficultyConfig.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 85aef53b455b420a96aa661729fd7b14
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/AudioFFTAnalyzer.cs b/Assets/Scripts/AudioFFTAnalyzer.cs
index 800ebc4..6c03140 100644
--- a/Assets/Scripts/AudioFFTAnalyzer.cs
+++ b/Assets/Scripts/AudioFFTAnalyzer.cs
@@ -54,7 +54,25 @@ public class AudioFFTAnalyzer : MonoBehaviour
public bool IsPreviewWarming =>
_source != null && _source.isPlaying && _source.clip != null;
- private void Awake() => _source = GetComponent();
+ private void Awake()
+ {
+ // Keep a source already assigned via SetSource (coordinator may run first).
+ if (_source == null)
+ _source = GetComponent();
+ }
+
+ /**
+ * Points analysis at a specific . Needed in split-screen
+ * because P2's PlaySync never starts its own clip — only P1's source plays
+ * the song, so the FFT must listen there for both backgrounds to react.
+ *
+ */
+ public void SetSource(AudioSource source)
+ {
+ if (source == null) return;
+ _source = source;
+ _trackedClip = null;
+ }
private void Update()
{
diff --git a/Assets/Session/GameMode.cs b/Assets/Session/GameMode.cs
new file mode 100644
index 0000000..ec48a5b
--- /dev/null
+++ b/Assets/Session/GameMode.cs
@@ -0,0 +1,20 @@
+/**
+ * High-level play mode selected on the title screen.
+ * Persisted on across the Menu → GameScene transition.
+ *
+ */
+public enum GameMode
+{
+ /** One human player, full-screen layout. */
+ SinglePlayer = 0,
+
+ /** Two human players, split-screen layout. */
+ PlayerVsPlayer = 1,
+
+ /**
+ * One human (P1) plus a rule-based bot (P2).
+ * Uses the same split-screen layout as .
+ *
+ */
+ PlayerVsBot = 2
+}
diff --git a/Assets/Session/GameMode.cs.meta b/Assets/Session/GameMode.cs.meta
new file mode 100644
index 0000000..6b972cb
--- /dev/null
+++ b/Assets/Session/GameMode.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: dd5ffe3de1e741b8aa7de88a70ca4e0e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Session/GameSession.cs b/Assets/Session/GameSession.cs
index e4eb62b..71a6b89 100644
--- a/Assets/Session/GameSession.cs
+++ b/Assets/Session/GameSession.cs
@@ -23,9 +23,41 @@ public class GameSession : PersistentSingleton
#region Session State
- /** Number of active players (1 or 2). */
+ /** Number of active players (1 or 2). Bot mode also uses 2 for split-screen. */
public int PlayerCount { get; set; } = 1;
+ /**
+ * Play mode chosen on the title tree (1P / VS Bot / 2P).
+ * Survives Menu → GameScene; read by .
+ *
+ */
+ public GameMode SelectedGameMode { get; set; } = GameMode.SinglePlayer;
+
+ /**
+ * Bot accuracy tier chosen after Player vs Bot. Ignored unless
+ * is .
+ *
+ */
+ public BotDifficulty SelectedBotDifficulty { get; set; } = BotDifficulty.Medium;
+
+ /**
+ * Player facing name for the chosen bot tier. Captured from BotDifficultyConfig when
+ * the player picks a tier so every screen can show the same label without reading the
+ * enum name. Rename a tier on the config asset and the menu, transition screen, and
+ * result banner all stay in sync.
+ *
+ */
+ public string SelectedBotLabel { get; private set; } =
+ BotDifficultyDefaults.DisplayNameOf(BotDifficulty.Medium);
+
+ /**
+ * True when the player chose Yes on the title-screen Tutorial? prompt.
+ * The tutorial scene (when added) can read this along with .
+ * Cleared when starting a normal song-select flow without tutorial.
+ *
+ */
+ public bool WantsTutorial { get; set; }
+
/** Set before loading Menu so MenuSceneController routes to the correct view. */
public PostGameIntent PostGameIntent { get; set; } = PostGameIntent.None;
@@ -35,9 +67,17 @@ public class GameSession : PersistentSingleton
/** Difficulty chosen for player 1. */
public Difficulty P1Difficulty { get; set; } = Difficulty.Normal;
- /** Difficulty chosen for player 2. */
+ /** Difficulty chosen for player 2 (or bot beatmap difficulty). */
public Difficulty P2Difficulty { get; set; } = Difficulty.Normal;
+ /** True when P2 is a rule-based bot on the split-screen layout. */
+ public bool IsPlayerVsBot =>
+ SelectedGameMode == GameMode.PlayerVsBot && PlayerCount == 2;
+
+ /** True when two human players are expected (not bot). */
+ public bool IsHumanVersusHuman =>
+ SelectedGameMode == GameMode.PlayerVsPlayer && PlayerCount == 2;
+
/** Resolved beatmap for player 1 (set by ). */
public BeatmapData P1BeatmapData { get; private set; }
@@ -64,9 +104,38 @@ public void ResolveBeatmaps()
{
if (SelectedSong == null) return;
P1BeatmapData = SelectedSong.GetBeatmap(P1Difficulty);
+ // Bot mode still needs a P2 beatmap so the secondary rhythm engine can run.
P2BeatmapData = PlayerCount == 2 ? SelectedSong.GetBeatmap(P2Difficulty) : null;
}
+ /**
+ * Applies a human 1P / 2P choice from the title tree, clearing bot state when needed.
+ *
+ */
+ public void ApplyHumanGameMode(int playerCount)
+ {
+ PlayerCount = Mathf.Clamp(playerCount, 1, 2);
+ SelectedGameMode = PlayerCount == 2
+ ? GameMode.PlayerVsPlayer
+ : GameMode.SinglePlayer;
+ }
+
+ /**
+ * Applies Player vs Bot: split-screen player count plus the chosen bot tier.
+ * Pass from the config so on-screen text matches the menu.
+ * When label is omitted, the default display name for that tier is used.
+ *
+ */
+ public void ApplyBotGameMode(BotDifficulty difficulty, string label = null)
+ {
+ PlayerCount = 2;
+ SelectedGameMode = GameMode.PlayerVsBot;
+ SelectedBotDifficulty = difficulty;
+ SelectedBotLabel = string.IsNullOrWhiteSpace(label)
+ ? BotDifficultyDefaults.DisplayNameOf(difficulty)
+ : label.Trim();
+ }
+
/**
* Returns true when a second player can participate. True if two or more
* input devices are connected, or if
diff --git a/Assets/Tree/Navigation/TreeNavigator.cs b/Assets/Tree/Navigation/TreeNavigator.cs
index 9305f46..bef3ae1 100644
--- a/Assets/Tree/Navigation/TreeNavigator.cs
+++ b/Assets/Tree/Navigation/TreeNavigator.cs
@@ -28,6 +28,13 @@ public class TreeNavigator
/** Running count of invalid actions the player has taken across all sessions. */
public int ErrorCount { get; private set; }
+ /**
+ * True while is awaiting hooks / navigation.
+ * Used by the bot so it does not stack decisions during animations.
+ *
+ */
+ public bool IsBusy => _isProcessingAction;
+
/** Returns the action required at the current cursor position. */
public NavigationAction GetRequiredAction() => _rules.GetRequiredAction(CurrentContext);
diff --git a/Assets/UI/Style1/ResultScreenController.cs b/Assets/UI/Style1/ResultScreenController.cs
index 630ae58..8df2300 100644
--- a/Assets/UI/Style1/ResultScreenController.cs
+++ b/Assets/UI/Style1/ResultScreenController.cs
@@ -144,6 +144,18 @@ public void SetTreeRunTracker(TreeRunTracker tracker)
_treeQrDisplay?.SetTracker(tracker);
}
+ /**
+ * Sets the banner sub-label (e.g. "Player 1", "Bot — Hard") before .
+ *
+ */
+ public void SetIdentityLabel(string label)
+ {
+ if (_subLabel == null)
+ _subLabel = transform.Find("Banner/SubLabel")?.GetComponent();
+ if (_subLabel != null && !string.IsNullOrEmpty(label))
+ _subLabel.text = label;
+ }
+
public void SetInputProvider(IMenuInputProvider input)
{
_input = input;