mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:35:42 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de22df3848 | ||
|
|
e281f3b012 | ||
|
|
9695db2536 | ||
|
|
aaba34e7de | ||
|
|
9e37236fe3 |
@@ -0,0 +1,230 @@
|
||||
# Tutorial Battle System
|
||||
|
||||
This document describes the tutorial battle system for first-time players. The system provides a scripted introductory battle that teaches combat basics while telling a story.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
When a new player starts their first game in tutorial mode, they experience:
|
||||
|
||||
1. **Narrative Intro** - Story screens introducing the scenario
|
||||
2. **Immediate Battle** - Skip strategic map, start directly in combat
|
||||
3. **Scripted Flee** - Enemy flees when player is "on the ropes"
|
||||
4. **Reinforcements** - Allied heroes arrive mid-battle
|
||||
5. **Heroes Join** - Reinforcement heroes join player's faction after battle
|
||||
|
||||
---
|
||||
|
||||
## Battle Configuration
|
||||
|
||||
### Defender (Player - John Ranil)
|
||||
- **Heroes**: John Ranil + 2 random sworn brothers (3 total)
|
||||
- **Units**:
|
||||
- 2x Light Infantry (300 troops each, 60 training/armament)
|
||||
- 1x Longbowmen (200 troops, 60 training/armament)
|
||||
- **Province**: 14 (Onmaa) with 40 support, 500 gold, 3000 food
|
||||
- **Restriction**: Cannot flee (must defend)
|
||||
|
||||
### Attacker (Ikhaan Tarn)
|
||||
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
|
||||
- **Units**:
|
||||
- 1x Heavy Cavalry (400 troops, 80 training/armament)
|
||||
- 1x Heavy Infantry (500 troops, 80 training/armament)
|
||||
- 1x Longbowmen (300 troops, 80 training/armament)
|
||||
- **Origin Province**: 32
|
||||
|
||||
### Flee Trigger
|
||||
Attacker flees when ANY of these conditions are met:
|
||||
- Player loses 1 unit
|
||||
- 5 rounds have passed
|
||||
|
||||
### Reinforcements
|
||||
When Tarn flees, these heroes arrive as player reinforcements:
|
||||
- Elena Fyar
|
||||
- Hedrick
|
||||
- The Boulder
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### Phase 1: Narrative Intro System
|
||||
|
||||
**Proto Changes:**
|
||||
- `game_state_view.proto`: Added `TutorialNarrativeScreen` message and `pending_narrative_screens` field
|
||||
- `game_parameters.proto`: Added `TutorialNarrativeScreen` and `tutorial_narrative_screens` field
|
||||
|
||||
**Unity Client:**
|
||||
- `NarrativeScreenController.cs`: Modal screen with title, body text, optional image
|
||||
- `EagleGameController.cs`: Checks for pending narrative screens on game start
|
||||
|
||||
**Narrative Content** (defined in `tutorial_game_parameters.json`):
|
||||
```
|
||||
Screen 1: "The Engineer of Onmaa" - Player backstory
|
||||
Screen 2: "The Traitor Arrives" - Tarn's attack
|
||||
Screen 3: "Defend Your Home" - Call to action
|
||||
```
|
||||
|
||||
### Phase 2: Auto-Start Battle
|
||||
|
||||
**Configuration:**
|
||||
- `tutorial_game_parameters.json`: Contains `tutorialBattle` config with attacker, target, flee conditions
|
||||
- `game_parameters.proto`: `TutorialBattleConfig` message
|
||||
|
||||
**Game Creation:**
|
||||
- `NewGameCreation.scala`: `setupTutorialBattle()` method creates:
|
||||
- `HostileArmyGroup` with `Attacking` status (bypasses decision phase)
|
||||
- `Army` for defender
|
||||
- Both added to target province
|
||||
|
||||
**Battle Creation:**
|
||||
- When round advances to `BattleRequest` phase, `RequestBattlesAction` automatically creates the `ShardokBattle`
|
||||
|
||||
### Phase 3: Scripted Flee Mechanism
|
||||
|
||||
**C++ Component:**
|
||||
- `TutorialBattleController.hpp/cpp`: Controller for scripted battle logic
|
||||
|
||||
**Key Methods:**
|
||||
```cpp
|
||||
// Check if attacker should flee
|
||||
bool ShouldTriggerScriptedFlee(const GameStateW& state);
|
||||
|
||||
// Check if player (defender) can flee
|
||||
bool PlayerCanFlee(PlayerId playerId);
|
||||
|
||||
// Execute the scripted flee (100% success rate)
|
||||
std::vector<ActionResult> ExecuteScriptedFlee(...);
|
||||
```
|
||||
|
||||
**Integration Points (TODO):**
|
||||
- `ShardokEngine.cpp`: Check `ShouldTriggerScriptedFlee()` during turn processing
|
||||
- `FleeCommandFactory.cpp`: Disable flee commands for defender in tutorial mode
|
||||
|
||||
### Phase 4: Mid-Battle Reinforcements
|
||||
|
||||
**Action Types** (in `action_type.proto`):
|
||||
- `TUTORIAL_ENEMY_FLED = 78`
|
||||
- `TUTORIAL_REINFORCEMENTS_ARRIVED = 79`
|
||||
|
||||
**Implementation (TODO):**
|
||||
- When scripted flee triggers, create reinforcement units in defender's reserves
|
||||
- Units have `UnitStatus_RESERVE_UNIT` status
|
||||
- Reinforcements enter battle on subsequent turns
|
||||
|
||||
### Phase 5: Tutorial Popups
|
||||
|
||||
**Content Definitions** (in `TutorialContentDefinitions.cs`):
|
||||
- `tutorial_battle_started`: "Defend your province!"
|
||||
- `tutorial_enemy_fled`: "The enemy retreats!"
|
||||
- `tutorial_reinforcements_arrived`: "Allies have arrived!"
|
||||
- `tutorial_battle_victory`: Victory celebration
|
||||
|
||||
**Trigger Integration:**
|
||||
- `TutorialTriggerRegistry.cs`: Handles tutorial action types from `ActionResultView`
|
||||
- `ShardokGameController.cs`: Detects tutorial events and triggers popups
|
||||
|
||||
### Phase 6: Post-Battle Hero Joining
|
||||
|
||||
**Implementation (TODO):**
|
||||
- `ResolveBattleAction.scala`: Check if battle was tutorial and defender won
|
||||
- Create `TutorialHeroJoined` action results adding reinforcement heroes to player faction
|
||||
- Set appropriate loyalty, vigor, location for joined heroes
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
### New Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `TutorialBattleController.hpp/cpp` | C++ scripted flee and reinforcement logic |
|
||||
| `NarrativeScreenController.cs` | Unity narrative screen display |
|
||||
|
||||
### Modified Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `tutorial_game_parameters.json` | Battle configuration and narrative content |
|
||||
| `game_state_view.proto` | Narrative screens field |
|
||||
| `game_parameters.proto` | Tutorial battle config |
|
||||
| `player_info.proto` | Tutorial battle flags for Shardok |
|
||||
| `action_type.proto` | Tutorial action types |
|
||||
| `NewGameCreation.scala` | Auto-start battle setup |
|
||||
| `EagleGameController.cs` | Narrative display integration |
|
||||
| `TutorialContentDefinitions.cs` | Battle tutorial content |
|
||||
| `TutorialTriggerRegistry.cs` | Tutorial event triggers |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Implementation
|
||||
|
||||
### ShardokEngine Integration
|
||||
Integrate `TutorialBattleController` into the Shardok battle loop:
|
||||
1. Initialize controller from battle config when battle starts
|
||||
2. Check `ShouldTriggerScriptedFlee()` at end of each round
|
||||
3. If true, call `ExecuteScriptedFlee()` and add results to action queue
|
||||
|
||||
### Disable Player Flee
|
||||
In `FleeCommandFactory.cpp`:
|
||||
- Check if tutorial mode is enabled
|
||||
- If defender, don't offer flee commands
|
||||
|
||||
### Reinforcement Units
|
||||
In `TutorialBattleController::ExecuteScriptedFlee()`:
|
||||
- Create `Unit` objects for Elena Fyar, Hedrick, The Boulder
|
||||
- Add to defender's reserve units
|
||||
- Generate `TUTORIAL_REINFORCEMENTS_ARRIVED` action
|
||||
|
||||
### Post-Battle Hero Joining
|
||||
In Eagle's battle resolution:
|
||||
- Detect tutorial battle completion
|
||||
- Create heroes in player's faction with proper stats
|
||||
- Generate notification action results
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] New user sees narrative screens before battle
|
||||
- [ ] Battle starts immediately after narratives (no strategic map)
|
||||
- [ ] Player has correct units (3 heroes, 3 battalions)
|
||||
- [ ] Attacker has correct units (3 heroes, 3 battalions)
|
||||
- [ ] Player cannot flee
|
||||
- [ ] Tarn flees after player loses 1 unit
|
||||
- [ ] Tarn flees after 5 rounds (if no units lost)
|
||||
- [ ] Reinforcements appear when Tarn flees
|
||||
- [ ] Tutorial popups appear at correct moments
|
||||
- [ ] Reinforcement heroes join faction after battle victory
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### tutorial_game_parameters.json
|
||||
```json
|
||||
{
|
||||
"tutorialBattle": {
|
||||
"attackerFactionHead": "Ikhaan Tarn",
|
||||
"targetProvinceId": 14,
|
||||
"fleeAfterDefenderUnitsLost": 1,
|
||||
"fleeAfterRounds": 5,
|
||||
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
|
||||
},
|
||||
"tutorialNarrativeScreens": [
|
||||
{ "title": "...", "bodyText": "...", "imagePath": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### TutorialBattleConfig Proto (Shardok)
|
||||
```protobuf
|
||||
message TutorialBattleConfig {
|
||||
bool enabled = 1;
|
||||
int32 flee_after_defender_units_lost = 2;
|
||||
int32 flee_after_rounds = 3;
|
||||
int32 attacker_player_id = 4;
|
||||
bool defender_can_flee = 5;
|
||||
repeated string reinforcement_hero_names = 6;
|
||||
}
|
||||
```
|
||||
+29
-8
@@ -806,7 +806,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-create and launch a 7-faction game for first-time users.
|
||||
/// Auto-create and launch a tutorial game for first-time users.
|
||||
/// Tutorial mode: 3 factions (player, King, weak rival), player spawns at province 14 with
|
||||
/// quest.
|
||||
/// </summary>
|
||||
private void AutoCreateFirstGame() {
|
||||
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
|
||||
@@ -818,9 +820,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
|
||||
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
|
||||
|
||||
// Create a 7-faction game (1 human + 6 AI) - same as lobby default
|
||||
Debug.Log($"[ConnectionHandler] Auto-creating 7-faction game with leader {leaderTextId}");
|
||||
CreateGame(leaderTextId, 1, 7);
|
||||
// Create a tutorial game (1 human, 3 total factions - player, King, and weak rival)
|
||||
Debug.Log($"[ConnectionHandler] Auto-creating tutorial game with leader {leaderTextId}");
|
||||
CreateGame(leaderTextId, 1, 3, isTutorialMode: true);
|
||||
}
|
||||
|
||||
private void StartListeningForLobbyUpdates() {
|
||||
@@ -946,10 +948,25 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
private void ConfirmDropGame(long gameId) { _internalDropGame(gameId); }
|
||||
|
||||
/// <summary>
|
||||
/// 3-parameter overload for CreateCallback delegate compatibility.
|
||||
/// </summary>
|
||||
private void CreateGame(string leaderNameTextId, int humanPlayerCount, int totalPlayerCount) {
|
||||
CreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount, isTutorialMode: false);
|
||||
}
|
||||
|
||||
private void CreateGame(
|
||||
string leaderNameTextId,
|
||||
int humanPlayerCount,
|
||||
int totalPlayerCount,
|
||||
bool isTutorialMode) {
|
||||
// Track multiplayer status for tutorial system
|
||||
TutorialManager.IsMultiplayerGame = humanPlayerCount > 1;
|
||||
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
|
||||
var game = _internalCreateGame(
|
||||
leaderNameTextId,
|
||||
humanPlayerCount,
|
||||
totalPlayerCount,
|
||||
isTutorialMode);
|
||||
}
|
||||
|
||||
public void QuitButtonClicked() { Application.Quit(); }
|
||||
@@ -988,8 +1005,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
} catch (Exception e) { Console.WriteLine(e.ToString()); }
|
||||
}
|
||||
|
||||
private async Task<bool>
|
||||
_internalCreateGame(string desiredLeaderTextId, int humanPlayerCount, int totalPlayerCount) {
|
||||
private async Task<bool> _internalCreateGame(
|
||||
string desiredLeaderTextId,
|
||||
int humanPlayerCount,
|
||||
int totalPlayerCount,
|
||||
bool isTutorialMode = false) {
|
||||
try {
|
||||
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
|
||||
new UpdateStreamRequest {
|
||||
@@ -997,7 +1017,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
new CreateGameRequest {
|
||||
DesiredLeaderTextId = desiredLeaderTextId,
|
||||
TotalPlayerCount = totalPlayerCount,
|
||||
HumanPlayerCount = humanPlayerCount
|
||||
HumanPlayerCount = humanPlayerCount,
|
||||
IsTutorialMode = isTutorialMode
|
||||
}
|
||||
});
|
||||
} catch (WebException e) {
|
||||
|
||||
+37
-1
@@ -77,6 +77,10 @@ namespace eagle {
|
||||
public Button gameIdButton;
|
||||
public Button nextActiveProvinceButton;
|
||||
|
||||
[Header("Tutorial Narrative")]
|
||||
public NarrativeScreenController narrativeScreenController;
|
||||
private bool _narrativeScreensShown = false;
|
||||
|
||||
[Header("Commands")]
|
||||
public MouseHandler mouseHandlerPrefab;
|
||||
public GameObject commandPanel;
|
||||
@@ -524,6 +528,29 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows tutorial narrative screens before gameplay.
|
||||
/// After all screens are dismissed, proceeds to battle if one is waiting.
|
||||
/// </summary>
|
||||
private void ShowNarrativeScreens(IList<TutorialNarrativeScreen> screens) {
|
||||
if (narrativeScreenController == null) {
|
||||
Debug.LogWarning("EagleGameController: NarrativeScreenController not assigned");
|
||||
OnNarrativeComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
narrativeScreenController.Show(screens, OnNarrativeComplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when narrative screens are complete.
|
||||
/// Proceeds to tutorial battle or normal game flow.
|
||||
/// </summary>
|
||||
private void OnNarrativeComplete() {
|
||||
// If there's a battle waiting, go directly to it
|
||||
if (Model != null && Model.RunningShardokGameModels.Count > 0) { GoToBattle(); }
|
||||
}
|
||||
|
||||
void SetHeaderString() {
|
||||
var date = Model.CurrentDate;
|
||||
if (date != null) {
|
||||
@@ -583,9 +610,18 @@ namespace eagle {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for tutorial narrative screens on first model load
|
||||
if (oldModel == null && !_narrativeScreensShown &&
|
||||
Model.PendingNarrativeScreens != null && Model.PendingNarrativeScreens.Count > 0) {
|
||||
_narrativeScreensShown = true;
|
||||
ShowNarrativeScreens(Model.PendingNarrativeScreens);
|
||||
return; // Wait for narrative to complete before proceeding
|
||||
}
|
||||
|
||||
// Start onboarding after first model update (UI panels are now populated)
|
||||
// Skip onboarding if we had narrative screens (tutorial battle mode)
|
||||
if (oldModel == null && TutorialManager.Instance != null &&
|
||||
!TutorialManager.Instance.State.OnboardingCompleted) {
|
||||
!TutorialManager.Instance.State.OnboardingCompleted && !_narrativeScreensShown) {
|
||||
TutorialManager.Instance.StartOnboarding();
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ namespace eagle {
|
||||
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
|
||||
|
||||
public List<ChronicleEntry> ChronicleEntries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Narrative screens to display before gameplay (tutorial intro).
|
||||
/// </summary>
|
||||
public IList<TutorialNarrativeScreen> PendingNarrativeScreens { get; }
|
||||
}
|
||||
|
||||
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
|
||||
@@ -201,6 +206,9 @@ namespace eagle {
|
||||
|
||||
public List<ChronicleEntry> ChronicleEntries { get; set; }
|
||||
|
||||
public IList<TutorialNarrativeScreen> PendingNarrativeScreens =>
|
||||
GsView.PendingNarrativeScreens;
|
||||
|
||||
public List<AvailableCommand> AvailableCommandsForProvince(ProvinceId pid) {
|
||||
if (AvailableCommandsByProvince.TryGetValue(pid, out var cmds)) {
|
||||
return cmds.Commands.ToList();
|
||||
|
||||
+56
@@ -34,6 +34,9 @@ namespace Eagle0.Tutorial {
|
||||
// Register Shardok (tactical battle) tutorials
|
||||
RegisterShardokTutorials(registry);
|
||||
|
||||
// Register tutorial battle tutorials (scripted first-time battle)
|
||||
RegisterTutorialBattleTutorials(registry);
|
||||
|
||||
// Register lobby tutorials (pre-game)
|
||||
RegisterLobbyTutorials(registry);
|
||||
}
|
||||
@@ -1102,6 +1105,59 @@ namespace Eagle0.Tutorial {
|
||||
return sequence;
|
||||
}
|
||||
|
||||
// ========== TUTORIAL BATTLE TUTORIALS ==========
|
||||
// These appear during the scripted tutorial battle for first-time players.
|
||||
|
||||
private static void RegisterTutorialBattleTutorials(TutorialTriggerRegistry registry) {
|
||||
// Tutorial battle started - special intro for the scripted battle
|
||||
var battleStarted = CreateSingleStepTutorial(
|
||||
"tutorial_battle_started",
|
||||
"Defend Your Home!",
|
||||
"The traitor Ikhaan Tarn attacks your province with his veterans. " +
|
||||
"Your small garrison is outnumbered, but you must hold the line.\n\n" +
|
||||
"Fight bravely! Position your units wisely and look for opportunities " +
|
||||
"to strike at the enemy's weak points.\n\n" +
|
||||
"<color=#FFCC00>Help is on the way...</color>",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(battleStarted, "tutorial_battle_started");
|
||||
|
||||
// Tutorial enemy fled - when Tarn flees the battle
|
||||
var enemyFled = CreateSingleStepTutorial(
|
||||
"tutorial_enemy_fled",
|
||||
"The Enemy Retreats!",
|
||||
"Ikhaan Tarn has fled the battlefield! Your brave defense has " +
|
||||
"bought precious time.\n\n" +
|
||||
"Perhaps he realized that you are more formidable than he expected, " +
|
||||
"or perhaps something else has scared him off...",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(enemyFled, "tutorial_enemy_fled");
|
||||
|
||||
// Tutorial reinforcements arrived - when heroes arrive to help
|
||||
var reinforcementsArrived = CreateSingleStepTutorial(
|
||||
"tutorial_reinforcements_arrived",
|
||||
"Allies Arrive!",
|
||||
"Reinforcements have arrived just in time! " +
|
||||
"<b>Elena Fyar</b>, <b>Hedrick</b>, and <b>The Boulder</b> have joined " +
|
||||
"your forces.\n\n" +
|
||||
"These heroes heard of your stand against the traitor and rode hard " +
|
||||
"to aid you. They will now fight under your banner.\n\n" +
|
||||
"<color=#00FF00>Check your reserves - new units are available!</color>",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(reinforcementsArrived, "tutorial_reinforcements_arrived");
|
||||
|
||||
// Tutorial battle victory - when the player wins the tutorial battle
|
||||
var battleVictory = CreateSingleStepTutorial(
|
||||
"tutorial_battle_victory",
|
||||
"Victory!",
|
||||
"You have defended your home against the traitor's attack!\n\n" +
|
||||
"The heroes who came to your aid have sworn to serve you. " +
|
||||
"With their help, you can now begin building your power and " +
|
||||
"eventually bring Ikhaan Tarn to justice.\n\n" +
|
||||
"<b>Your journey as a ruler begins now.</b>",
|
||||
TutorialDisplayMode.Modal);
|
||||
registry.RegisterTutorial(battleVictory, "tutorial_battle_victory");
|
||||
}
|
||||
|
||||
// ========== PRE-GAME TUTORIALS ==========
|
||||
// These appear before the user enters a game (sign-in, lobby).
|
||||
|
||||
|
||||
+8
@@ -558,6 +558,14 @@ namespace Eagle0.Tutorial {
|
||||
case ActionType.CrossWaterFailed:
|
||||
OnGameEvent("terrain_water_encountered", result);
|
||||
break;
|
||||
|
||||
// Tutorial battle events
|
||||
case ActionType.TutorialEnemyFled:
|
||||
OnGameEvent("tutorial_enemy_fled", result);
|
||||
break;
|
||||
case ActionType.TutorialReinforcementsArrived:
|
||||
OnGameEvent("tutorial_reinforcements_arrived", result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Controller for displaying narrative screens before tutorial battles.
|
||||
/// Shows story screens with title, body text, and optional hero portraits.
|
||||
/// Chains through multiple screens before proceeding to gameplay.
|
||||
/// </summary>
|
||||
public class NarrativeScreenController : MonoBehaviour {
|
||||
[Header("Content")]
|
||||
[Tooltip("Title text")]
|
||||
public TextMeshProUGUI TitleText;
|
||||
|
||||
[Tooltip("Body text")]
|
||||
public TextMeshProUGUI BodyText;
|
||||
|
||||
[Tooltip("Optional hero portrait or scene image")]
|
||||
public Image SceneImage;
|
||||
|
||||
[Tooltip("Container for the scene image")]
|
||||
public GameObject SceneImageContainer;
|
||||
|
||||
[Header("Buttons")]
|
||||
[Tooltip("Continue button")]
|
||||
public Button ContinueButton;
|
||||
|
||||
[Tooltip("Text on continue button")]
|
||||
public TextMeshProUGUI ContinueButtonText;
|
||||
|
||||
[Tooltip("Skip button to skip all narrative")]
|
||||
public Button SkipButton;
|
||||
|
||||
[Header("Progress")]
|
||||
[Tooltip("Progress indicator (e.g., dots or page numbers)")]
|
||||
public TextMeshProUGUI ProgressText;
|
||||
|
||||
[Header("Background")]
|
||||
[Tooltip("Background panel container")]
|
||||
public GameObject PanelContainer;
|
||||
|
||||
[Tooltip("Fade overlay for transitions")]
|
||||
public CanvasGroup FadeOverlay;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Animator for screen transitions")]
|
||||
public Animator ScreenAnimator;
|
||||
|
||||
[Tooltip("Fade duration in seconds")]
|
||||
public float FadeDuration = 0.3f;
|
||||
|
||||
// Internal state
|
||||
private List<TutorialNarrativeScreen> _screens;
|
||||
private int _currentScreenIndex;
|
||||
private Action _onComplete;
|
||||
private bool _isTransitioning;
|
||||
|
||||
private void Awake() {
|
||||
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
|
||||
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
|
||||
|
||||
Hide();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the narrative screen sequence.
|
||||
/// </summary>
|
||||
/// <param name="screens">List of narrative screens to display</param>
|
||||
/// <param name="onComplete">Callback when all screens are dismissed</param>
|
||||
public void Show(IEnumerable<TutorialNarrativeScreen> screens, Action onComplete) {
|
||||
_screens = new List<TutorialNarrativeScreen>(screens);
|
||||
_onComplete = onComplete;
|
||||
_currentScreenIndex = 0;
|
||||
|
||||
if (_screens.Count == 0) {
|
||||
_onComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
if (PanelContainer != null) { PanelContainer.SetActive(true); }
|
||||
|
||||
DisplayCurrentScreen();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the current screen in the sequence.
|
||||
/// </summary>
|
||||
private void DisplayCurrentScreen() {
|
||||
if (_currentScreenIndex >= _screens.Count) {
|
||||
CompleteNarrative();
|
||||
return;
|
||||
}
|
||||
|
||||
var screen = _screens[_currentScreenIndex];
|
||||
|
||||
// Set title
|
||||
if (TitleText != null) { TitleText.text = screen.Title ?? ""; }
|
||||
|
||||
// Set body text
|
||||
if (BodyText != null) {
|
||||
BodyText.text = screen.BodyText ?? "";
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(BodyText.GetComponent<RectTransform>());
|
||||
}
|
||||
|
||||
// Set scene image if provided
|
||||
if (SceneImageContainer != null) {
|
||||
bool hasImage = !string.IsNullOrEmpty(screen.ImagePath);
|
||||
SceneImageContainer.SetActive(hasImage);
|
||||
|
||||
if (hasImage && SceneImage != null) {
|
||||
// Load image from resources
|
||||
var sprite = Resources.Load<Sprite>(screen.ImagePath);
|
||||
if (sprite != null) {
|
||||
SceneImage.sprite = sprite;
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"NarrativeScreenController: Could not load image at '{screen.ImagePath}'");
|
||||
SceneImageContainer.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update button text based on whether this is the last screen
|
||||
if (ContinueButtonText != null) {
|
||||
bool isLastScreen = _currentScreenIndex >= _screens.Count - 1;
|
||||
ContinueButtonText.text = isLastScreen ? "Begin Battle" : "Continue";
|
||||
}
|
||||
|
||||
// Update progress indicator
|
||||
if (ProgressText != null) {
|
||||
if (_screens.Count > 1) {
|
||||
ProgressText.text = $"{_currentScreenIndex + 1} / {_screens.Count}";
|
||||
ProgressText.gameObject.SetActive(true);
|
||||
} else {
|
||||
ProgressText.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger show animation if available
|
||||
if (ScreenAnimator != null) { ScreenAnimator.SetTrigger("Show"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when Continue button is clicked.
|
||||
/// </summary>
|
||||
private void OnContinueClicked() {
|
||||
if (_isTransitioning) return;
|
||||
|
||||
_currentScreenIndex++;
|
||||
|
||||
if (_currentScreenIndex >= _screens.Count) {
|
||||
CompleteNarrative();
|
||||
} else {
|
||||
// Animate transition to next screen
|
||||
StartTransition(() => DisplayCurrentScreen());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when Skip button is clicked.
|
||||
/// </summary>
|
||||
private void OnSkipClicked() {
|
||||
if (_isTransitioning) return;
|
||||
CompleteNarrative();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a transition animation between screens.
|
||||
/// </summary>
|
||||
private void StartTransition(Action onTransitionComplete) {
|
||||
if (FadeOverlay == null || FadeDuration <= 0) {
|
||||
onTransitionComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
_isTransitioning = true;
|
||||
StartCoroutine(TransitionCoroutine(onTransitionComplete));
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator TransitionCoroutine(Action onComplete) {
|
||||
// Fade out
|
||||
float elapsed = 0f;
|
||||
while (elapsed < FadeDuration) {
|
||||
elapsed += Time.deltaTime;
|
||||
FadeOverlay.alpha = Mathf.Lerp(0f, 1f, elapsed / FadeDuration);
|
||||
yield return null;
|
||||
}
|
||||
FadeOverlay.alpha = 1f;
|
||||
|
||||
// Call the transition action
|
||||
onComplete?.Invoke();
|
||||
|
||||
// Fade in
|
||||
elapsed = 0f;
|
||||
while (elapsed < FadeDuration) {
|
||||
elapsed += Time.deltaTime;
|
||||
FadeOverlay.alpha = Mathf.Lerp(1f, 0f, elapsed / FadeDuration);
|
||||
yield return null;
|
||||
}
|
||||
FadeOverlay.alpha = 0f;
|
||||
|
||||
_isTransitioning = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the narrative sequence and invokes the callback.
|
||||
/// </summary>
|
||||
private void CompleteNarrative() {
|
||||
var callback = _onComplete;
|
||||
Hide();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides the narrative screen.
|
||||
/// </summary>
|
||||
public void Hide() {
|
||||
if (ScreenAnimator != null) { ScreenAnimator.SetTrigger("Hide"); }
|
||||
if (PanelContainer != null) { PanelContainer.SetActive(false); }
|
||||
gameObject.SetActive(false);
|
||||
|
||||
_screens = null;
|
||||
_onComplete = null;
|
||||
_currentScreenIndex = 0;
|
||||
_isTransitioning = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the controller is currently showing screens.
|
||||
/// </summary>
|
||||
public bool IsShowing => gameObject.activeSelf && _screens != null && _screens.Count > 0;
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,7 @@ message CreateGameRequest {
|
||||
string desired_leader_text_id = 1;
|
||||
int32 total_player_count = 2;
|
||||
int32 human_player_count = 3;
|
||||
bool is_tutorial_mode = 4;
|
||||
}
|
||||
|
||||
message CreateGameResponse {
|
||||
|
||||
@@ -52,6 +52,22 @@ message SetFaction {
|
||||
int32 earliest_round_for_invitation = 5;
|
||||
}
|
||||
|
||||
// Configuration for tutorial battle that triggers at game start
|
||||
message TutorialBattleConfig {
|
||||
string attacker_faction_head = 1; // Faction head who attacks
|
||||
int32 target_province_id = 2; // Province to attack
|
||||
int32 flee_after_defender_units_lost = 3; // Attacker flees after defender loses N units
|
||||
int32 flee_after_rounds = 4; // Attacker flees after N rounds
|
||||
repeated string reinforcements = 5; // Hero names for reinforcements when attacker flees
|
||||
}
|
||||
|
||||
// Narrative screen shown before tutorial battle
|
||||
message TutorialNarrativeScreen {
|
||||
string title = 1;
|
||||
string body_text = 2;
|
||||
string image_path = 3;
|
||||
}
|
||||
|
||||
message GameParameters {
|
||||
string map_file_path = 2;
|
||||
|
||||
@@ -59,4 +75,8 @@ message GameParameters {
|
||||
ProvinceOverrides occupied_province_overrides = 4;
|
||||
|
||||
repeated SetFaction set_factions = 5;
|
||||
|
||||
// Tutorial-specific configuration
|
||||
TutorialBattleConfig tutorial_battle = 6;
|
||||
repeated TutorialNarrativeScreen tutorial_narrative_screens = 7;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ option java_package = "net.eagle0.eagle.views";
|
||||
option java_outer_classname = "GameStateView";
|
||||
option objc_class_prefix = "E0G";
|
||||
|
||||
// Narrative screen for tutorial and story sequences
|
||||
message TutorialNarrativeScreen {
|
||||
string title = 1;
|
||||
string body_text = 2;
|
||||
string image_path = 3; // Optional hero portrait or scene image
|
||||
}
|
||||
|
||||
message GameStateView {
|
||||
int32 current_round_id = 1;
|
||||
.net.eagle0.eagle.common.RoundPhase current_phase = 2;
|
||||
@@ -43,6 +50,9 @@ message GameStateView {
|
||||
repeated .net.eagle0.eagle.common.BattalionType battalion_types = 12;
|
||||
|
||||
repeated .net.eagle0.eagle.common.ChronicleEntry chronicle_entries = 14;
|
||||
|
||||
// Tutorial narrative screens to display before gameplay
|
||||
repeated TutorialNarrativeScreen pending_narrative_screens = 15;
|
||||
}
|
||||
|
||||
message GameStateViewDiff {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"mapFilePath": "/net/eagle0/eagle/province_map.tsv",
|
||||
"genericProvinceOverrides": {
|
||||
"economy": 20.0,
|
||||
"agriculture": 20.0,
|
||||
"infrastructure": 20.0,
|
||||
"specialBattalionTypes": []
|
||||
},
|
||||
"occupiedProvinceOverrides": {
|
||||
"randomRulingPlayerHeroCount": 0,
|
||||
"economy": 40.0,
|
||||
"agriculture": 40.0,
|
||||
"infrastructure": 45.0,
|
||||
"gold": 500,
|
||||
"food": 3000,
|
||||
"support": 40,
|
||||
"orders": "ENTRUST",
|
||||
"specialBattalionTypes": [],
|
||||
"battalions": [
|
||||
{
|
||||
"type": "LIGHT_INFANTRY",
|
||||
"size": 300,
|
||||
"training": 60,
|
||||
"armament": 60,
|
||||
"name": "Ranil's Spearmen"
|
||||
},
|
||||
{
|
||||
"type": "LIGHT_INFANTRY",
|
||||
"size": 300,
|
||||
"training": 60,
|
||||
"armament": 60,
|
||||
"name": "Ranil's Guard"
|
||||
},
|
||||
{
|
||||
"type": "LONGBOWMEN",
|
||||
"size": 200,
|
||||
"training": 60,
|
||||
"armament": 60,
|
||||
"name": "Ranil's Archers"
|
||||
}
|
||||
]
|
||||
},
|
||||
"setFactions": [
|
||||
{
|
||||
"factionHeadName": "Ikhaan Tarn",
|
||||
"swornBrotherNames": [],
|
||||
"occupiedProvinces": [
|
||||
{
|
||||
"provinceId": 32,
|
||||
"economy": 85.0,
|
||||
"agriculture": 91.0,
|
||||
"infrastructure": 66.0,
|
||||
"gold": 4400,
|
||||
"food": 5500,
|
||||
"support": 35,
|
||||
"orders": "ENTRUST",
|
||||
"rulingPlayerHeroNames": ["Ikhaan Tarn"],
|
||||
"randomRulingPlayerHeroCount": 2,
|
||||
"specialBattalionTypes": [],
|
||||
"battalions": [
|
||||
{
|
||||
"type": "HEAVY_CAVALRY",
|
||||
"size": 400,
|
||||
"training": 80,
|
||||
"armament": 80,
|
||||
"name": "Tarn's Knights"
|
||||
},
|
||||
{
|
||||
"type": "HEAVY_INFANTRY",
|
||||
"size": 500,
|
||||
"training": 80,
|
||||
"armament": 80,
|
||||
"name": "Tarn's Stormtroopers"
|
||||
},
|
||||
{
|
||||
"type": "LONGBOWMEN",
|
||||
"size": 300,
|
||||
"training": 80,
|
||||
"armament": 80,
|
||||
"name": "Tarn's Sharpshooters"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"startingTrusts": [],
|
||||
"earliestRoundForInvitation": 999
|
||||
}
|
||||
],
|
||||
"tutorialBattle": {
|
||||
"attackerFactionHead": "Ikhaan Tarn",
|
||||
"targetProvinceId": 14,
|
||||
"fleeAfterDefenderUnitsLost": 1,
|
||||
"fleeAfterRounds": 5,
|
||||
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
|
||||
},
|
||||
"tutorialNarrativeScreens": [
|
||||
{
|
||||
"title": "The Engineer of Onmaa",
|
||||
"bodyText": "You are John Ranil, an engineer who once served the crown. When civil war erupted, you retreated to Onmaa, your ancestral home, hoping to wait out the conflict in peace.",
|
||||
"imagePath": ""
|
||||
},
|
||||
{
|
||||
"title": "The Traitor Arrives",
|
||||
"bodyText": "The traitor Ikhaan Tarn has arrived at Onmaa with his army. He once served King Bregos Fyar but betrayed him to join The Eagle, a mysterious mage from distant lands. Now Tarn seeks to expand his new master's domain.",
|
||||
"imagePath": ""
|
||||
},
|
||||
{
|
||||
"title": "Defend Your Home",
|
||||
"bodyText": "Against impossible odds, you must defend your home. Your small garrison is no match for Tarn's veterans, but you must hold the line until help arrives. Fight bravely, engineer.",
|
||||
"imagePath": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -65,11 +65,12 @@ class EagleServiceImpl(
|
||||
): Future[CreateGameResponse] =
|
||||
lockAndUpdateGamesWith(uid =>
|
||||
gamesManager.createGameForUser(
|
||||
expandedGameParameters = GameParametersUtils.defaultExpandedGameParameters,
|
||||
expandedGameParameters = GameParametersUtils.parametersFor(request.isTutorialMode),
|
||||
userId = uid,
|
||||
desiredLeaderTextId = request.desiredLeaderTextId,
|
||||
maxHumanPlayers = request.humanPlayerCount,
|
||||
totalPlayers = request.totalPlayerCount
|
||||
totalPlayers = request.totalPlayerCount,
|
||||
isTutorialMode = request.isTutorialMode
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ case class GameAwaitingPlayers(
|
||||
existingHumanPlayers: Vector[WaitingGamePlayerInfo],
|
||||
totalPlayerCount: Int,
|
||||
humanPlayerCount: Int,
|
||||
greatPeople: Vector[Hero]
|
||||
greatPeople: Vector[Hero],
|
||||
isTutorialMode: Boolean = false
|
||||
) {
|
||||
private def claimedNameTextIds: Vector[String] =
|
||||
existingHumanPlayers.map(_.leaderNameTextId)
|
||||
@@ -85,7 +86,8 @@ case class GameAwaitingPlayers(
|
||||
existingHumanPlayers = existingHumanPlayers :+ waitingGamePlayerInfo,
|
||||
totalPlayerCount = totalPlayerCount,
|
||||
humanPlayerCount = humanPlayerCount,
|
||||
greatPeople = greatPeople
|
||||
greatPeople = greatPeople,
|
||||
isTutorialMode = isTutorialMode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1104,7 +1106,8 @@ class GamesManager(
|
||||
expandedGameParameters = gap.expandedGameParameters,
|
||||
gameId = newGap.gameId,
|
||||
humanPlayers = newGap.existingHumanPlayers,
|
||||
aiPlayerCount = newGap.totalPlayerCount - newGap.existingHumanPlayers.size
|
||||
aiPlayerCount = newGap.totalPlayerCount - newGap.existingHumanPlayers.size,
|
||||
isTutorialMode = newGap.isTutorialMode
|
||||
): Unit
|
||||
} else
|
||||
gamesAwaitingPlayers = gamesAwaitingPlayers
|
||||
@@ -1143,7 +1146,8 @@ class GamesManager(
|
||||
userId: UserId,
|
||||
desiredLeaderTextId: String,
|
||||
maxHumanPlayers: Int,
|
||||
totalPlayers: Int
|
||||
totalPlayers: Int,
|
||||
isTutorialMode: Boolean = false
|
||||
): CreateGameResponse =
|
||||
if maxHumanPlayers > totalPlayers || totalPlayers > MaxSupportedPlayers.intValue
|
||||
then CreateGameResponse(INVALID_OPTIONS_RESULT, 0)
|
||||
@@ -1156,7 +1160,8 @@ class GamesManager(
|
||||
existingHumanPlayers = Vector.empty,
|
||||
totalPlayerCount = totalPlayers,
|
||||
humanPlayerCount = maxHumanPlayers,
|
||||
greatPeople = expandedGameParameters.greatPeopleHeroProtos
|
||||
greatPeople = expandedGameParameters.greatPeopleHeroProtos,
|
||||
isTutorialMode = isTutorialMode
|
||||
)
|
||||
|
||||
val result = joinGame(
|
||||
@@ -1209,7 +1214,8 @@ class GamesManager(
|
||||
expandedGameParameters: ExpandedGameParameters,
|
||||
gameId: GameId,
|
||||
humanPlayers: Vector[WaitingGamePlayerInfo],
|
||||
aiPlayerCount: Int
|
||||
aiPlayerCount: Int,
|
||||
isTutorialMode: Boolean = false
|
||||
): GameControllerWithPostResults = {
|
||||
val humanPlayerCount = humanPlayers.size
|
||||
val initialEar = gameCreation
|
||||
@@ -1219,7 +1225,8 @@ class GamesManager(
|
||||
persister = gamePersisterCreation.persisterForGame(gameId),
|
||||
humanPlayerFactionLeaderNameTextIds = humanPlayers.map(_.leaderNameTextId),
|
||||
playerCount = humanPlayerCount + aiPlayerCount,
|
||||
allBattalionTypes = GamesManager.battalionTypes
|
||||
allBattalionTypes = GamesManager.battalionTypes,
|
||||
isTutorialMode = isTutorialMode
|
||||
)
|
||||
|
||||
val unrequestedTexts = initialEar.results
|
||||
|
||||
@@ -33,6 +33,9 @@ object GameParametersUtils {
|
||||
private val defaultResourceLocation: String =
|
||||
"/net/eagle0/eagle/game_parameters.json"
|
||||
|
||||
private val tutorialResourceLocation: String =
|
||||
"/net/eagle0/eagle/tutorial_game_parameters.json"
|
||||
|
||||
private val fixedHeroFilePath: String = "/net/eagle0/eagle/heroes.tsv"
|
||||
private val generatedHeroFilePath: String =
|
||||
"/net/eagle0/eagle/generated_heroes.tsv"
|
||||
@@ -74,6 +77,12 @@ object GameParametersUtils {
|
||||
val defaultExpandedGameParameters: ExpandedGameParameters =
|
||||
expandedGameParametersFrom(defaultResourceLocation).get
|
||||
|
||||
lazy val tutorialExpandedGameParameters: ExpandedGameParameters =
|
||||
expandedGameParametersFrom(tutorialResourceLocation).get
|
||||
|
||||
def parametersFor(isTutorialMode: Boolean): ExpandedGameParameters =
|
||||
if isTutorialMode then tutorialExpandedGameParameters else defaultExpandedGameParameters
|
||||
|
||||
private def heroNamesMap(fixedHeroes: FixedHeroes): Map[String, String] = {
|
||||
val heroes = fixedHeroes.greatPeople.map(
|
||||
_.greatPerson.hero
|
||||
|
||||
@@ -21,11 +21,13 @@ import net.eagle0.eagle.model.action_result.generated_text_request.{
|
||||
}
|
||||
import net.eagle0.eagle.model.action_result.types.ActionResultType
|
||||
import net.eagle0.eagle.model.proto_converters.BattalionConverter
|
||||
import net.eagle0.eagle.model.state.{Army, CombatUnit, HostileArmyGroup, HostileArmyGroupStatus, MovingArmy, Supplies}
|
||||
import net.eagle0.eagle.model.state.chronicle_entry.ChronicleEntry
|
||||
import net.eagle0.eagle.model.state.faction.concrete.FactionC
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Hostile
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.model.state.quest.DefeatFactionQuest
|
||||
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
|
||||
import net.eagle0.eagle.model.state.unaffiliated_hero.concrete.UnaffiliatedHeroC
|
||||
import net.eagle0.eagle.model.state.BattalionType
|
||||
@@ -63,7 +65,8 @@ trait NewGameCreation {
|
||||
persister: Persister,
|
||||
humanPlayerFactionLeaderNameTextIds: Vector[String],
|
||||
playerCount: Int,
|
||||
allBattalionTypes: Vector[BattalionType]
|
||||
allBattalionTypes: Vector[BattalionType],
|
||||
isTutorialMode: Boolean = false
|
||||
): EngineAndResults
|
||||
}
|
||||
|
||||
@@ -264,20 +267,23 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
expandedGameParameters: ExpandedGameParameters,
|
||||
headName: String,
|
||||
provinceId: ProvinceId,
|
||||
gameId: GameId
|
||||
gameId: GameId,
|
||||
isTutorialMode: Boolean = false
|
||||
): RandomState[ActionResultC] =
|
||||
withFactionHead(
|
||||
expandedGameParameters = expandedGameParameters,
|
||||
headName = headName,
|
||||
provinceId = provinceId,
|
||||
gameId = gameId
|
||||
gameId = gameId,
|
||||
isTutorialMode = isTutorialMode
|
||||
)
|
||||
|
||||
def withHumanPlayerFactions(
|
||||
expandedGameParameters: ExpandedGameParameters,
|
||||
factionHeadNameTextIds: Vector[String],
|
||||
provinceIds: Vector[ProvinceId],
|
||||
gameId: GameId
|
||||
gameId: GameId,
|
||||
isTutorialMode: Boolean = false
|
||||
): RandomState[ActionResultC] =
|
||||
factionHeadNameTextIds
|
||||
.map(nameTextId =>
|
||||
@@ -295,7 +301,8 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
expandedGameParameters = expandedGameParameters,
|
||||
headName = name,
|
||||
provinceId = pid,
|
||||
gameId = gameId
|
||||
gameId = gameId,
|
||||
isTutorialMode = isTutorialMode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,7 +310,8 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
expandedGameParameters: ExpandedGameParameters,
|
||||
headName: String,
|
||||
provinceId: ProvinceId,
|
||||
gameId: GameId
|
||||
gameId: GameId,
|
||||
isTutorialMode: Boolean = false
|
||||
): RandomState[ActionResultC] = {
|
||||
val fid = value.newValue.nextFactionId
|
||||
|
||||
@@ -315,6 +323,10 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
greatPerson = gp.greatPerson.copy(hero = gp.greatPerson.hero.withFactionId(fid))
|
||||
)
|
||||
|
||||
// Tutorial mode: 4 heroes (faction head + 3 sworn brothers)
|
||||
// Normal mode: uses randomRulingPlayerHeroCount from occupiedProvinceOverrides (default 2)
|
||||
val tutorialExtraHeroCount = if isTutorialMode then 3 else 0
|
||||
|
||||
value
|
||||
.map(_.withLoadedHero(gpWithFid.greatPerson.hero.withFactionId(fid)))
|
||||
.map {
|
||||
@@ -330,14 +342,22 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
)
|
||||
} match {
|
||||
case RandomState((ar, hid), nr) =>
|
||||
RandomState(ar, nr).withProvinceOverrides(
|
||||
province = value.newValue.provinceMap(provinceId),
|
||||
provinceOverrides = expandedGameParameters.gameParameters.getOccupiedProvinceOverrides,
|
||||
extraHeroIds = Vector(hid),
|
||||
factionId = fid,
|
||||
battalionName = Some(s"$headName's Loyalists"),
|
||||
gameId = gameId
|
||||
)
|
||||
// In tutorial mode, add extra sworn brothers for a total of 4 heroes
|
||||
val withExtraTutorialHeroes =
|
||||
if isTutorialMode then RandomState(ar, nr).withRandomHeroes(fid, gameId, tutorialExtraHeroCount)
|
||||
else RandomState((ar, Vector[HeroId]()), nr)
|
||||
|
||||
withExtraTutorialHeroes.continue {
|
||||
case ((arWithHeroes, extraHids), nextRandom) =>
|
||||
RandomState(arWithHeroes, nextRandom).withProvinceOverrides(
|
||||
province = value.newValue.provinceMap(provinceId),
|
||||
provinceOverrides = expandedGameParameters.gameParameters.getOccupiedProvinceOverrides,
|
||||
extraHeroIds = Vector(hid) ++ extraHids,
|
||||
factionId = fid,
|
||||
battalionName = Some(s"$headName's Loyalists"),
|
||||
gameId = gameId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,13 +553,115 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
)
|
||||
}
|
||||
|
||||
// Tutorial mode constants
|
||||
private val tutorialPlayerStartProvinceId: ProvinceId = 14 // Onmaa
|
||||
private val tutorialRivalFactionId: FactionId = 2 // Norfolk's faction
|
||||
|
||||
/// Sets up a tutorial battle by adding a hostile army group with Attacking status.
|
||||
/// When the round advances to BattleRequest phase, the battle will be automatically created.
|
||||
private def setupTutorialBattle(
|
||||
ar: ActionResultC,
|
||||
attackerFactionHeadName: String,
|
||||
targetProvinceId: ProvinceId
|
||||
): ActionResultC = {
|
||||
// Find the attacker faction by their faction head's name (using nameTextId)
|
||||
val attackerHero = ar.newHeroes.find(_.nameTextId == attackerFactionHeadName)
|
||||
val attackerFactionId = attackerHero.flatMap(_.factionId)
|
||||
|
||||
// Find the defender (player) faction - should be the ruling faction of the target province
|
||||
val targetProvince = ar.newProvinces.find(_.id == targetProvinceId)
|
||||
val defenderFactionId = targetProvince.flatMap(_.rulingFactionId)
|
||||
|
||||
(attackerFactionId, defenderFactionId, targetProvince) match {
|
||||
case (Some(attackerFid), Some(defenderFid), Some(province)) =>
|
||||
// Get attacker heroes and their battalions
|
||||
val attackerHeroes = ar.newHeroes.filter(_.factionId.contains(attackerFid))
|
||||
val attackerUnits = attackerHeroes.map { hero =>
|
||||
// Find battalions for this hero (at their province)
|
||||
val heroBattalionIds = ar.newProvinces
|
||||
.filter(_.rulingFactionId.contains(attackerFid))
|
||||
.flatMap(_.battalionIds)
|
||||
CombatUnit(
|
||||
factionId = attackerFid,
|
||||
heroId = hero.id,
|
||||
battalionId = heroBattalionIds.headOption
|
||||
)
|
||||
}
|
||||
|
||||
// Get defender heroes and their battalions
|
||||
val defenderHeroes = ar.newHeroes.filter(_.factionId.contains(defenderFid))
|
||||
val defenderUnits = defenderHeroes.map { hero =>
|
||||
val heroBattalionIds = province.battalionIds
|
||||
CombatUnit(
|
||||
factionId = defenderFid,
|
||||
heroId = hero.id,
|
||||
battalionId = heroBattalionIds.headOption
|
||||
)
|
||||
}
|
||||
|
||||
// Find the origin province (attacker's province)
|
||||
val attackerProvince = ar.newProvinces
|
||||
.find(_.rulingFactionId.contains(attackerFid))
|
||||
.map(_.id)
|
||||
.getOrElse(32) // Default to province 32 if not found
|
||||
|
||||
// Get starting position index from neighbor info
|
||||
val startingPositionIndex = province.neighbors
|
||||
.find(_.provinceId == attackerProvince)
|
||||
.map(_.startingPositionIndex)
|
||||
.getOrElse(0)
|
||||
|
||||
// Create hostile army group with Attacking status (bypasses decision phase)
|
||||
val hostileArmyGroup = HostileArmyGroup(
|
||||
factionId = attackerFid,
|
||||
armies = Vector(
|
||||
MovingArmy(
|
||||
id = 1,
|
||||
army = Army(
|
||||
factionId = attackerFid,
|
||||
units = attackerUnits,
|
||||
fleeProvinceId = Some(attackerProvince)
|
||||
),
|
||||
arrivalRound = 1,
|
||||
destinationProvinceId = targetProvinceId,
|
||||
originProvinceId = attackerProvince,
|
||||
supplies = Supplies(gold = 500, food = 1000),
|
||||
suppliesLoss = 0.0,
|
||||
startingPositionIndex = Some(startingPositionIndex)
|
||||
)
|
||||
),
|
||||
status = HostileArmyGroupStatus.Attacking
|
||||
)
|
||||
|
||||
// Create defending army for the player
|
||||
val defendingArmy = Army(
|
||||
factionId = defenderFid,
|
||||
units = defenderUnits,
|
||||
fleeProvinceId = None
|
||||
)
|
||||
|
||||
// Add the hostile army and defending army to the province
|
||||
val updatedProvince = province.updateWith(
|
||||
hostileArmies = province.hostileArmies :+ hostileArmyGroup,
|
||||
defendingArmy = Some(defendingArmy)
|
||||
)
|
||||
|
||||
ar.withProvince(updatedProvince)
|
||||
|
||||
case _ =>
|
||||
// If we can't find the factions or province, return unchanged
|
||||
ar
|
||||
}
|
||||
}
|
||||
|
||||
override def createGame(
|
||||
expandedGameParameters: ExpandedGameParameters,
|
||||
gameId: GameId,
|
||||
persister: Persister,
|
||||
humanPlayerFactionLeaderNameTextIds: Vector[String],
|
||||
playerCount: Int,
|
||||
allBattalionTypes: Vector[BattalionType]
|
||||
allBattalionTypes: Vector[BattalionType],
|
||||
isTutorialMode: Boolean = false
|
||||
): EngineAndResults = {
|
||||
val gameParameters = expandedGameParameters.gameParameters
|
||||
|
||||
@@ -554,13 +676,22 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
gameId = gameId
|
||||
)
|
||||
|
||||
// In tutorial mode, player spawns at province 14 (Onmaa) instead of random
|
||||
val splitPids = setFactionsResult.continue {
|
||||
case (sfr, nextRandom) =>
|
||||
randomProvinceIdSet(
|
||||
sfr.newProvinces.toVector,
|
||||
playerCount,
|
||||
nextRandom
|
||||
).map(_.splitAt(humanPlayerFactionLeaderNameTextIds.size))
|
||||
if isTutorialMode then {
|
||||
// Tutorial: force player to province 14, no AI provinces needed (they're all set factions)
|
||||
RandomState(
|
||||
(Vector(tutorialPlayerStartProvinceId), Vector[ProvinceId]()),
|
||||
nextRandom
|
||||
)
|
||||
} else {
|
||||
randomProvinceIdSet(
|
||||
sfr.newProvinces.toVector,
|
||||
playerCount,
|
||||
nextRandom
|
||||
).map(_.splitAt(humanPlayerFactionLeaderNameTextIds.size))
|
||||
}
|
||||
}
|
||||
|
||||
val allFactionsResult = splitPids.continue {
|
||||
@@ -570,7 +701,8 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
expandedGameParameters = expandedGameParameters,
|
||||
factionHeadNameTextIds = humanPlayerFactionLeaderNameTextIds,
|
||||
provinceIds = humanProvinceIds,
|
||||
gameId = gameId
|
||||
gameId = gameId,
|
||||
isTutorialMode = isTutorialMode
|
||||
)
|
||||
.withOtherAiFactions(
|
||||
expandedGameParameters = expandedGameParameters,
|
||||
@@ -667,8 +799,63 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
)
|
||||
}
|
||||
|
||||
// In tutorial mode, add an unaffiliated hero with DefeatFactionQuest to player's starting province
|
||||
val withTutorialQuestHeroState =
|
||||
if isTutorialMode then {
|
||||
val currentAR = provincesWithExtraHeroesState.newValue
|
||||
val nextRandom = provincesWithExtraHeroesState.nextRandom
|
||||
val questHeroId = currentAR.nextHeroId
|
||||
|
||||
// Generate a random hero for the quest giver
|
||||
val (heroGenResult, newRandom) = RandomHeroGenerator
|
||||
.createHeroesWithNoProfession(
|
||||
hids = Vector(questHeroId),
|
||||
functionalRandom = nextRandom,
|
||||
remainingImagePathsByProfession = (p, g) => ImagePaths.imagePathsByProfession((p, g)),
|
||||
date = StartDate.startDate
|
||||
) match {
|
||||
case RandomState(result, nr) => (result, nr)
|
||||
}
|
||||
|
||||
val questHero = heroGenResult.head match {
|
||||
case HeroGenerationResponse(hero, nameInfo) =>
|
||||
(
|
||||
hero,
|
||||
NameGenerationRequestCreator.nameGenerationRequest(
|
||||
heroId = hero.id,
|
||||
gameId = gameId,
|
||||
nameInfo = nameInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Create the unaffiliated hero with DefeatFactionQuest
|
||||
val tutorialQuestHero = UnaffiliatedHeroC(
|
||||
heroId = questHeroId,
|
||||
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
|
||||
recruitmentInfo = RecruitmentInfo.HasQuest(
|
||||
DefeatFactionQuest(targetFactionId = tutorialRivalFactionId)
|
||||
)
|
||||
)
|
||||
|
||||
// Add the hero to the player's starting province
|
||||
val playerProvince = currentAR.provinceMap(tutorialPlayerStartProvinceId)
|
||||
val updatedProvince = playerProvince.updateWith(
|
||||
unaffiliatedHeroes = playerProvince.unaffiliatedHeroes :+ tutorialQuestHero
|
||||
)
|
||||
|
||||
val updatedAR = currentAR
|
||||
.withHeroes(Vector(questHero._1))
|
||||
.withGeneratedTextRequests(questHero._2.map(convertNameRequest).toVector)
|
||||
.withProvince(updatedProvince)
|
||||
|
||||
RandomState(updatedAR, newRandom)
|
||||
} else {
|
||||
provincesWithExtraHeroesState
|
||||
}
|
||||
|
||||
val withBattalionTypesResult =
|
||||
provincesWithExtraHeroesState.newValue.copy(
|
||||
withTutorialQuestHeroState.newValue.copy(
|
||||
newBattalionTypes = allBattalionTypes
|
||||
)
|
||||
|
||||
@@ -716,8 +903,20 @@ object FixedNewGameCreation extends NewGameCreation {
|
||||
newHeroes = withBattalionTypesResult.newHeroes.sortBy(_.id)
|
||||
)
|
||||
|
||||
// Set up tutorial battle if configured
|
||||
val withTutorialBattle =
|
||||
if isTutorialMode then
|
||||
gameParameters.tutorialBattle.fold(withPersonalityRequests) { tutorialBattle =>
|
||||
setupTutorialBattle(
|
||||
ar = withPersonalityRequests,
|
||||
attackerFactionHeadName = tutorialBattle.attackerFactionHead,
|
||||
targetProvinceId = tutorialBattle.targetProvinceId
|
||||
)
|
||||
}
|
||||
else withPersonalityRequests
|
||||
|
||||
createEngine(
|
||||
startGameActionResult = withPersonalityRequests,
|
||||
startGameActionResult = withTutorialBattle,
|
||||
heroGenerator = fixedHeroesARwithGenerator.newValue._2,
|
||||
gameId = gameId,
|
||||
persister = persister
|
||||
|
||||
@@ -132,7 +132,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
|
||||
val mockEngineAndResults = mock[EngineAndResults]
|
||||
|
||||
mockGameCreation.createGame
|
||||
.expects(*, *, *, *, *, *)
|
||||
.expects(*, *, *, *, *, *, *)
|
||||
.returns(mockEngineAndResults): Unit
|
||||
(() => mockEngine.updated)
|
||||
.expects()
|
||||
@@ -254,7 +254,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
|
||||
allowGamesE0esReads()
|
||||
val mockEngineAndResults = mock[EngineAndResults]
|
||||
mockGameCreation.createGame
|
||||
.expects(*, *, *, *, *, *)
|
||||
.expects(*, *, *, *, *, *, *)
|
||||
.returns(mockEngineAndResults): Unit
|
||||
|
||||
(() => mockEngine.updated)
|
||||
|
||||
Reference in New Issue
Block a user