Sfoglia il codice sorgente

Add Campaign Mode with database schema, UI, and game integration

djeada 1 mese fa
parent
commit
8d9cde8e48

+ 7 - 0
CMakeLists.txt

@@ -161,6 +161,12 @@ if(QT_VERSION_MAJOR EQUAL 6)
             ui/qml/Main.qml
             ui/qml/MainMenu.qml
             ui/qml/MapSelect.qml
+            ui/qml/MapListPanel.qml
+            ui/qml/PlayerListItem.qml
+            ui/qml/PlayerConfigPanel.qml
+            ui/qml/CampaignMenu.qml
+            ui/qml/StyleGuide.qml
+            ui/qml/StyledButton.qml
             ui/qml/HUD.qml
             ui/qml/HUDTop.qml
             ui/qml/HUDBottom.qml
@@ -171,6 +177,7 @@ if(QT_VERSION_MAJOR EQUAL 6)
             ui/qml/HUDVictory.qml
             ui/qml/BattleSummary.qml
             ui/qml/GameView.qml
+            ui/qml/CursorManager.qml
         RESOURCES
             assets/shaders/archer.frag
             assets/shaders/archer.vert

+ 130 - 20
app/core/game_engine.cpp

@@ -174,16 +174,16 @@ GameEngine::GameEngine(QObject *parent)
   m_mapCatalog = std::make_unique<Game::Map::MapCatalog>(this);
   connect(m_mapCatalog.get(), &Game::Map::MapCatalog::mapLoaded, this,
           [this](const QVariantMap &mapData) {
-            m_availableMaps.append(mapData);
-            emit availableMapsChanged();
+            m_available_maps.append(mapData);
+            emit available_maps_changed();
           });
   connect(m_mapCatalog.get(), &Game::Map::MapCatalog::loadingChanged, this,
           [this](bool loading) {
-            m_mapsLoading = loading;
-            emit mapsLoadingChanged();
+            m_maps_loading = loading;
+            emit maps_loading_changed();
           });
   connect(m_mapCatalog.get(), &Game::Map::MapCatalog::allMapsLoaded, this,
-          [this]() { emit availableMapsChanged(); });
+          [this]() { emit available_maps_changed(); });
 
   if (AudioSystem::getInstance().initialize()) {
     qInfo() << "AudioSystem initialized successfully";
@@ -1032,18 +1032,19 @@ void GameEngine::setRallyAtScreen(qreal sx, qreal sy) {
                                         m_runtime.localOwnerId);
 }
 
-void GameEngine::startLoadingMaps() {
-  m_availableMaps.clear();
+void GameEngine::start_loading_maps() {
+  m_available_maps.clear();
   if (m_mapCatalog) {
     m_mapCatalog->loadMapsAsync();
   }
+  load_campaigns();
 }
 
-auto GameEngine::availableMaps() const -> QVariantList {
-  return m_availableMaps;
+auto GameEngine::available_maps() const -> QVariantList {
+  return m_available_maps;
 }
 
-auto GameEngine::availableNations() const -> QVariantList {
+auto GameEngine::available_nations() const -> QVariantList {
   QVariantList nations;
   const auto &registry = Game::Systems::NationRegistry::instance();
   const auto &all = registry.getAllNations();
@@ -1071,7 +1072,111 @@ auto GameEngine::availableNations() const -> QVariantList {
   return nations;
 }
 
-void GameEngine::startSkirmish(const QString &map_path,
+auto GameEngine::available_campaigns() const -> QVariantList {
+  return m_available_campaigns;
+}
+
+void GameEngine::load_campaigns() {
+  if (!m_saveLoadService) {
+    return;
+  }
+
+  QString error;
+  auto campaigns = m_saveLoadService->list_campaigns(&error);
+  if (!error.isEmpty()) {
+    qWarning() << "Failed to load campaigns:" << error;
+    return;
+  }
+
+  m_available_campaigns = campaigns;
+  emit available_campaigns_changed();
+}
+
+void GameEngine::start_campaign_mission(const QString &campaign_id) {
+  clearError();
+
+  if (!m_saveLoadService) {
+    setError("Save/Load service not initialized");
+    return;
+  }
+
+  // Get campaign details
+  QString error;
+  auto campaigns = m_saveLoadService->list_campaigns(&error);
+  if (!error.isEmpty()) {
+    setError("Failed to load campaign: " + error);
+    return;
+  }
+
+  // Find the campaign
+  QVariantMap selectedCampaign;
+  for (const auto &campaign : campaigns) {
+    auto campaignMap = campaign.toMap();
+    if (campaignMap.value("id").toString() == campaign_id) {
+      selectedCampaign = campaignMap;
+      break;
+    }
+  }
+
+  if (selectedCampaign.isEmpty()) {
+    setError("Campaign not found: " + campaign_id);
+    return;
+  }
+
+  m_current_campaign_id = campaign_id;
+
+  // Get map path
+  QString mapPath = selectedCampaign.value("mapPath").toString();
+
+  // For Carthage vs Rome mission, set up predefined players
+  QVariantList playerConfigs;
+
+  // Player 1: Human (Carthage)
+  QVariantMap player1;
+  player1.insert("player_id", 1);
+  player1.insert("playerName", "Carthage");
+  player1.insert("colorIndex", 0); // Blue
+  player1.insert("team_id", 0);
+  player1.insert("nationId", "carthage");
+  player1.insert("isHuman", true);
+  playerConfigs.append(player1);
+
+  // Player 2: AI (Rome)
+  QVariantMap player2;
+  player2.insert("player_id", 2);
+  player2.insert("playerName", "Rome");
+  player2.insert("colorIndex", 1); // Red
+  player2.insert("team_id", 1);
+  player2.insert("nationId", "roman_republic");
+  player2.insert("isHuman", false);
+  playerConfigs.append(player2);
+
+  // Start the mission like a skirmish
+  start_skirmish(mapPath, playerConfigs);
+}
+
+void GameEngine::mark_current_mission_completed() {
+  if (m_current_campaign_id.isEmpty()) {
+    qWarning() << "No active campaign mission to mark as completed";
+    return;
+  }
+
+  if (!m_saveLoadService) {
+    qWarning() << "Save/Load service not initialized";
+    return;
+  }
+
+  QString error;
+  bool success = m_saveLoadService->mark_campaign_completed(m_current_campaign_id, &error);
+  if (!success) {
+    qWarning() << "Failed to mark campaign as completed:" << error;
+  } else {
+    qInfo() << "Campaign mission" << m_current_campaign_id << "marked as completed";
+    load_campaigns(); // Refresh campaign list
+  }
+}
+
+void GameEngine::start_skirmish(const QString &map_path,
                                const QVariantList &playerConfigs) {
 
   clearError();
@@ -1151,6 +1256,11 @@ void GameEngine::startSkirmish(const QString &map_path,
         if (m_runtime.victoryState != state) {
           m_runtime.victoryState = state;
           emit victoryStateChanged();
+          
+          // Mark campaign mission as completed if victory
+          if (state == "victory" && !m_current_campaign_id.isEmpty()) {
+            mark_current_mission_completed();
+          }
         }
       });
     }
@@ -1195,23 +1305,23 @@ void GameEngine::startSkirmish(const QString &map_path,
   }
 }
 
-void GameEngine::openSettings() {
+void GameEngine::open_settings() {
   if (m_saveLoadService) {
     m_saveLoadService->openSettings();
   }
 }
 
-void GameEngine::loadSave() { loadFromSlot("savegame"); }
+void GameEngine::load_save() { loadFromSlot("savegame"); }
 
-void GameEngine::saveGame(const QString &filename) {
+void GameEngine::save_game(const QString &filename) {
   saveToSlot(filename, filename);
 }
 
-void GameEngine::saveGameToSlot(const QString &slotName) {
+void GameEngine::save_game_to_slot(const QString &slotName) {
   saveToSlot(slotName, slotName);
 }
 
-void GameEngine::loadGameFromSlot(const QString &slotName) {
+void GameEngine::load_game_from_slot(const QString &slotName) {
   loadFromSlot(slotName);
 }
 
@@ -1287,7 +1397,7 @@ auto GameEngine::saveToSlot(const QString &slot, const QString &title) -> bool {
   return true;
 }
 
-auto GameEngine::getSaveSlots() const -> QVariantList {
+auto GameEngine::get_save_slots() const -> QVariantList {
   if (!m_saveLoadService) {
     qWarning() << "Cannot get save slots: service not initialized";
     return {};
@@ -1296,9 +1406,9 @@ auto GameEngine::getSaveSlots() const -> QVariantList {
   return m_saveLoadService->getSaveSlots();
 }
 
-void GameEngine::refreshSaveSlots() { emit saveSlotsChanged(); }
+void GameEngine::refresh_save_slots() { emit saveSlotsChanged(); }
 
-auto GameEngine::deleteSaveSlot(const QString &slotName) -> bool {
+auto GameEngine::delete_save_slot(const QString &slotName) -> bool {
   if (!m_saveLoadService) {
     qWarning() << "Cannot delete save slot: service not initialized";
     return false;
@@ -1317,7 +1427,7 @@ auto GameEngine::deleteSaveSlot(const QString &slotName) -> bool {
   return success;
 }
 
-void GameEngine::exitGame() {
+void GameEngine::exit_game() {
   if (m_saveLoadService) {
     m_saveLoadService->exitGame();
   }

+ 30 - 20
app/core/game_engine.h

@@ -98,9 +98,11 @@ public:
   Q_PROPERTY(int max_troops_per_player READ max_troops_per_player NOTIFY
                  troop_countChanged)
   Q_PROPERTY(
-      QVariantList availableMaps READ availableMaps NOTIFY availableMapsChanged)
-  Q_PROPERTY(bool mapsLoading READ mapsLoading NOTIFY mapsLoadingChanged)
-  Q_PROPERTY(QVariantList availableNations READ availableNations CONSTANT)
+      QVariantList available_maps READ available_maps NOTIFY available_maps_changed)
+  Q_PROPERTY(bool maps_loading READ maps_loading NOTIFY maps_loading_changed)
+  Q_PROPERTY(QVariantList available_nations READ available_nations CONSTANT)
+  Q_PROPERTY(QVariantList available_campaigns READ available_campaigns NOTIFY
+                 available_campaigns_changed)
   Q_PROPERTY(int enemyTroopsDefeated READ enemyTroopsDefeated NOTIFY
                  enemyTroopsDefeatedChanged)
   Q_PROPERTY(QVariantList ownerInfo READ getOwnerInfo NOTIFY ownerInfoChanged)
@@ -132,7 +134,7 @@ public:
   Q_INVOKABLE void cameraOrbitDirection(int direction, bool shift);
   Q_INVOKABLE void cameraFollowSelection(bool enable);
   Q_INVOKABLE void cameraSetFollowLerp(float alpha);
-  Q_INVOKABLE void startLoadingMaps();
+  Q_INVOKABLE void start_loading_maps();
 
   Q_INVOKABLE void setPaused(bool paused) { m_runtime.paused = paused; }
   Q_INVOKABLE void setGameSpeed(float speed) {
@@ -175,21 +177,24 @@ public:
   Q_INVOKABLE [[nodiscard]] QVariantMap getSelectedProductionState() const;
   Q_INVOKABLE [[nodiscard]] QString getSelectedUnitsCommandMode() const;
   Q_INVOKABLE void setRallyAtScreen(qreal sx, qreal sy);
-  Q_INVOKABLE [[nodiscard]] QVariantList availableMaps() const;
-  [[nodiscard]] QVariantList availableNations() const;
-  [[nodiscard]] bool mapsLoading() const { return m_mapsLoading; }
+  Q_INVOKABLE [[nodiscard]] QVariantList available_maps() const;
+  [[nodiscard]] QVariantList available_nations() const;
+  [[nodiscard]] QVariantList available_campaigns() const;
+  [[nodiscard]] bool maps_loading() const { return m_maps_loading; }
   Q_INVOKABLE void
-  startSkirmish(const QString &map_path,
+  start_skirmish(const QString &map_path,
                 const QVariantList &playerConfigs = QVariantList());
-  Q_INVOKABLE void openSettings();
-  Q_INVOKABLE void loadSave();
-  Q_INVOKABLE void saveGame(const QString &filename = "savegame.json");
-  Q_INVOKABLE void saveGameToSlot(const QString &slotName);
-  Q_INVOKABLE void loadGameFromSlot(const QString &slotName);
-  Q_INVOKABLE [[nodiscard]] QVariantList getSaveSlots() const;
-  Q_INVOKABLE void refreshSaveSlots();
-  Q_INVOKABLE bool deleteSaveSlot(const QString &slotName);
-  Q_INVOKABLE void exitGame();
+  Q_INVOKABLE void start_campaign_mission(const QString &campaign_id);
+  Q_INVOKABLE void mark_current_mission_completed();
+  Q_INVOKABLE void open_settings();
+  Q_INVOKABLE void load_save();
+  Q_INVOKABLE void save_game(const QString &filename = "savegame.json");
+  Q_INVOKABLE void save_game_to_slot(const QString &slot_name);
+  Q_INVOKABLE void load_game_from_slot(const QString &slot_name);
+  Q_INVOKABLE [[nodiscard]] QVariantList get_save_slots() const;
+  Q_INVOKABLE void refresh_save_slots();
+  Q_INVOKABLE bool delete_save_slot(const QString &slot_name);
+  Q_INVOKABLE void exit_game();
   Q_INVOKABLE [[nodiscard]] QVariantList getOwnerInfo() const;
 
   QObject *audio_system();
@@ -297,8 +302,10 @@ private:
   QObject *m_selectedUnitsModel = nullptr;
   int m_enemyTroopsDefeated = 0;
   int m_selectedPlayerId = 1;
-  QVariantList m_availableMaps;
-  bool m_mapsLoading = false;
+  QVariantList m_available_maps;
+  QVariantList m_available_campaigns;
+  bool m_maps_loading = false;
+  QString m_current_campaign_id;
   Engine::Core::ScopedEventSubscription<Engine::Core::UnitDiedEvent>
       m_unitDiedSubscription;
   Engine::Core::ScopedEventSubscription<Engine::Core::UnitSpawnedEvent>
@@ -311,6 +318,7 @@ private:
   void updateAmbientState(float dt);
   [[nodiscard]] bool isPlayerInCombat() const;
   static void loadAudioResources();
+  void load_campaigns();
 signals:
   void selectedUnitsChanged();
   void selectedUnitsDataChanged();
@@ -319,7 +327,9 @@ signals:
   void cursorModeChanged();
   void globalCursorChanged();
   void troop_countChanged();
-  void availableMapsChanged();
+  void available_maps_changed();
+  void maps_loading_changed();
+  void available_campaigns_changed();
   void ownerInfoChanged();
   void selectedPlayerIdChanged();
   void lastErrorChanged();

+ 32 - 0
game/systems/save_load_service.cpp

@@ -194,6 +194,38 @@ auto SaveLoadService::deleteSaveSlot(const QString &slotName) -> bool {
   return true;
 }
 
+auto SaveLoadService::list_campaigns(QString *out_error) const -> QVariantList {
+  if (!m_storage) {
+    if (out_error != nullptr) {
+      *out_error = "Storage not initialized";
+    }
+    return {};
+  }
+  return m_storage->list_campaigns(out_error);
+}
+
+auto SaveLoadService::get_campaign_progress(const QString &campaign_id,
+                                         QString *out_error) const -> QVariantMap {
+  if (!m_storage) {
+    if (out_error != nullptr) {
+      *out_error = "Storage not initialized";
+    }
+    return {};
+  }
+  return m_storage->get_campaign_progress(campaign_id, out_error);
+}
+
+auto SaveLoadService::mark_campaign_completed(const QString &campaign_id,
+                                           QString *out_error) -> bool {
+  if (!m_storage) {
+    if (out_error != nullptr) {
+      *out_error = "Storage not initialized";
+    }
+    return false;
+  }
+  return m_storage->mark_campaign_completed(campaign_id, out_error);
+}
+
 void SaveLoadService::openSettings() { qInfo() << "Open settings requested"; }
 
 void SaveLoadService::exitGame() {

+ 6 - 0
game/systems/save_load_service.h

@@ -40,6 +40,12 @@ public:
   auto getLastTitle() const -> QString { return m_lastTitle; }
   auto getLastScreenshot() const -> QByteArray { return m_lastScreenshot; }
 
+  auto list_campaigns(QString *out_error = nullptr) const -> QVariantList;
+  auto get_campaign_progress(const QString &campaign_id,
+                          QString *out_error = nullptr) const -> QVariantMap;
+  auto mark_campaign_completed(const QString &campaign_id,
+                            QString *out_error = nullptr) -> bool;
+
   static void openSettings();
 
   static void exitGame();

+ 188 - 1
game/systems/save_storage.cpp

@@ -23,7 +23,7 @@ namespace Game::Systems {
 
 namespace {
 constexpr const char *k_driver_name = "QSQLITE";
-constexpr int k_current_schema_version = 1;
+constexpr int k_current_schema_version = 2;
 
 auto buildConnectionName(const SaveStorage *instance) -> QString {
   return QStringLiteral("SaveStorage_%1")
@@ -276,6 +276,114 @@ auto SaveStorage::listSlots(QString *out_error) const -> QVariantList {
   return result;
 }
 
+auto SaveStorage::list_campaigns(QString *out_error) const -> QVariantList {
+  QVariantList result;
+  if (!const_cast<SaveStorage *>(this)->initialize(out_error)) {
+    return result;
+  }
+
+  QSqlQuery query(m_database);
+  const QString sql = QStringLiteral(
+      "SELECT c.id, c.title, c.description, c.map_path, c.order_index, "
+      "COALESCE(p.completed, 0) as completed, COALESCE(p.unlocked, 0) as unlocked, "
+      "p.completed_at "
+      "FROM campaigns c "
+      "LEFT JOIN campaign_progress p ON c.id = p.campaign_id "
+      "ORDER BY c.order_index ASC");
+
+  if (!query.exec(sql)) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to list campaigns: %1")
+                       .arg(lastErrorString(query.lastError()));
+    }
+    return result;
+  }
+
+  while (query.next()) {
+    QVariantMap campaign;
+    campaign.insert(QStringLiteral("id"), query.value(0).toString());
+    campaign.insert(QStringLiteral("title"), query.value(1).toString());
+    campaign.insert(QStringLiteral("description"), query.value(2).toString());
+    campaign.insert(QStringLiteral("mapPath"), query.value(3).toString());
+    campaign.insert(QStringLiteral("orderIndex"), query.value(4).toInt());
+    campaign.insert(QStringLiteral("completed"), query.value(5).toInt() != 0);
+    campaign.insert(QStringLiteral("unlocked"), query.value(6).toInt() != 0);
+    campaign.insert(QStringLiteral("completedAt"), query.value(7).toString());
+    result.append(campaign);
+  }
+
+  return result;
+}
+
+auto SaveStorage::get_campaign_progress(const QString &campaign_id,
+                                     QString *out_error) const -> QVariantMap {
+  QVariantMap result;
+  if (!const_cast<SaveStorage *>(this)->initialize(out_error)) {
+    return result;
+  }
+
+  QSqlQuery query(m_database);
+  query.prepare(QStringLiteral(
+      "SELECT completed, unlocked, completed_at FROM campaign_progress "
+      "WHERE campaign_id = :campaign_id"));
+  query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
+
+  if (!query.exec()) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to get campaign progress: %1")
+                       .arg(lastErrorString(query.lastError()));
+    }
+    return result;
+  }
+
+  if (query.next()) {
+    result.insert(QStringLiteral("completed"), query.value(0).toInt() != 0);
+    result.insert(QStringLiteral("unlocked"), query.value(1).toInt() != 0);
+    result.insert(QStringLiteral("completedAt"), query.value(2).toString());
+  }
+
+  return result;
+}
+
+auto SaveStorage::mark_campaign_completed(const QString &campaign_id,
+                                       QString *out_error) -> bool {
+  if (!initialize(out_error)) {
+    return false;
+  }
+
+  TransactionGuard transaction(m_database);
+  if (!transaction.begin(out_error)) {
+    return false;
+  }
+
+  const QString now_iso =
+      QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
+
+  QSqlQuery query(m_database);
+  query.prepare(QStringLiteral(
+      "INSERT INTO campaign_progress (campaign_id, completed, unlocked, completed_at) "
+      "VALUES (:campaign_id, 1, 1, :completed_at) "
+      "ON CONFLICT(campaign_id) DO UPDATE SET "
+      "completed = 1, completed_at = excluded.completed_at"));
+  query.bindValue(QStringLiteral(":campaign_id"), campaign_id);
+  query.bindValue(QStringLiteral(":completed_at"), now_iso);
+
+  if (!query.exec()) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to mark campaign as completed: %1")
+                       .arg(lastErrorString(query.lastError()));
+    }
+    transaction.rollback();
+    return false;
+  }
+
+  if (!transaction.commit(out_error)) {
+    return false;
+  }
+
+  return true;
+}
+
 auto SaveStorage::deleteSlot(const QString &slotName,
                              QString *out_error) -> bool {
   if (!initialize(out_error)) {
@@ -468,6 +576,12 @@ auto SaveStorage::migrateSchema(int fromVersion,
       }
       version = 1;
       break;
+    case 1:
+      if (!migrate_to_2(out_error)) {
+        return false;
+      }
+      version = 2;
+      break;
     default:
       if (out_error != nullptr) {
         *out_error =
@@ -480,4 +594,77 @@ auto SaveStorage::migrateSchema(int fromVersion,
   return true;
 }
 
+auto SaveStorage::migrate_to_2(QString *out_error) const -> bool {
+  // Create campaigns table
+  QSqlQuery query(m_database);
+  const QString create_campaigns_sql = QStringLiteral(
+      "CREATE TABLE IF NOT EXISTS campaigns ("
+      "id TEXT PRIMARY KEY NOT NULL, "
+      "title TEXT NOT NULL, "
+      "description TEXT NOT NULL, "
+      "map_path TEXT NOT NULL, "
+      "order_index INTEGER NOT NULL DEFAULT 0"
+      ")");
+
+  if (!query.exec(create_campaigns_sql)) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to create campaigns table: %1")
+                       .arg(lastErrorString(query.lastError()));
+    }
+    return false;
+  }
+
+  // Create campaign_progress table
+  QSqlQuery progress_query(m_database);
+  const QString create_progress_sql = QStringLiteral(
+      "CREATE TABLE IF NOT EXISTS campaign_progress ("
+      "campaign_id TEXT PRIMARY KEY NOT NULL, "
+      "completed INTEGER NOT NULL DEFAULT 0, "
+      "unlocked INTEGER NOT NULL DEFAULT 0, "
+      "completed_at TEXT, "
+      "FOREIGN KEY(campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE"
+      ")");
+
+  if (!progress_query.exec(create_progress_sql)) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to create campaign_progress table: %1")
+                       .arg(lastErrorString(progress_query.lastError()));
+    }
+    return false;
+  }
+
+  // Insert initial mission: Carthage vs Rome
+  QSqlQuery insert_query(m_database);
+  const QString insert_campaign_sql = QStringLiteral(
+      "INSERT INTO campaigns (id, title, description, map_path, order_index) "
+      "VALUES ('carthage_vs_rome', 'Carthage vs Rome', "
+      "'Historic battle between Carthage and the Roman Republic. "
+      "Command Carthaginian forces to defeat the Roman barracks.', "
+      "':/assets/maps/map_rivers.json', 0)");
+
+  if (!insert_query.exec(insert_campaign_sql)) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to insert initial campaign: %1")
+                       .arg(lastErrorString(insert_query.lastError()));
+    }
+    return false;
+  }
+
+  // Initialize progress for the mission (unlocked by default)
+  QSqlQuery progress_insert_query(m_database);
+  const QString insert_progress_sql = QStringLiteral(
+      "INSERT INTO campaign_progress (campaign_id, completed, unlocked) "
+      "VALUES ('carthage_vs_rome', 0, 1)");
+
+  if (!progress_insert_query.exec(insert_progress_sql)) {
+    if (out_error != nullptr) {
+      *out_error = QStringLiteral("Failed to initialize campaign progress: %1")
+                       .arg(lastErrorString(progress_insert_query.lastError()));
+    }
+    return false;
+  }
+
+  return true;
+}
+
 } // namespace Game::Systems

+ 7 - 0
game/systems/save_storage.h

@@ -31,6 +31,12 @@ public:
   auto deleteSlot(const QString &slotName,
                   QString *out_error = nullptr) -> bool;
 
+  auto list_campaigns(QString *out_error = nullptr) const -> QVariantList;
+  auto get_campaign_progress(const QString &campaign_id,
+                          QString *out_error = nullptr) const -> QVariantMap;
+  auto mark_campaign_completed(const QString &campaign_id,
+                            QString *out_error = nullptr) -> bool;
+
 private:
   auto open(QString *out_error = nullptr) const -> bool;
   auto ensureSchema(QString *out_error = nullptr) const -> bool;
@@ -40,6 +46,7 @@ private:
   auto schemaVersion(QString *out_error = nullptr) const -> int;
   auto setSchemaVersion(int version,
                         QString *out_error = nullptr) const -> bool;
+  auto migrate_to_2(QString *out_error = nullptr) const -> bool;
 
   QString m_database_path;
   QString m_connection_name;

+ 1 - 0
qml_resources.qrc

@@ -3,6 +3,7 @@
         <file>ui/qml/Main.qml</file>
         <file>ui/qml/MainMenu.qml</file>
         <file>ui/qml/MapSelect.qml</file>
+        <file>ui/qml/CampaignMenu.qml</file>
         <file>ui/qml/MapListPanel.qml</file>
         <file>ui/qml/PlayerListItem.qml</file>
         <file>ui/qml/PlayerConfigPanel.qml</file>

+ 304 - 0
ui/qml/CampaignMenu.qml

@@ -0,0 +1,304 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import StandardOfIron.UI 1.0
+
+Item {
+    id: root
+
+    signal missionSelected(string campaignId)
+    signal cancelled()
+
+    property var campaigns: (typeof game !== "undefined" && game.available_campaigns) ? game.available_campaigns : []
+
+    anchors.fill: parent
+    focus: true
+
+    Keys.onPressed: function(event) {
+        if (event.key === Qt.Key_Escape) {
+            root.cancelled();
+            event.accepted = true;
+        }
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Theme.dim
+    }
+
+    Rectangle {
+        id: container
+
+        width: Math.min(parent.width * 0.85, 1200)
+        height: Math.min(parent.height * 0.85, 800)
+        anchors.centerIn: parent
+        radius: Theme.radiusPanel
+        color: Theme.panelBase
+        border.color: Theme.panelBr
+        border.width: 1
+        opacity: 0.98
+
+        ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: Theme.spacingXLarge
+            spacing: Theme.spacingLarge
+
+            // Header
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: Theme.spacingMedium
+
+                Label {
+                    text: qsTr("Campaign Missions")
+                    color: Theme.textMain
+                    font.pointSize: Theme.fontSizeHero
+                    font.bold: true
+                    Layout.fillWidth: true
+                }
+
+                StyledButton {
+                    text: qsTr("← Back")
+                    onClicked: root.cancelled()
+                }
+            }
+
+            Rectangle {
+                Layout.fillWidth: true
+                Layout.preferredHeight: 1
+                color: Theme.border
+            }
+
+            // Mission list
+            ScrollView {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                clip: true
+
+                ListView {
+                    id: listView
+
+                    model: root.campaigns
+                    spacing: Theme.spacingMedium
+
+                    delegate: Rectangle {
+                        width: listView.width
+                        height: 120
+                        radius: Theme.radiusLarge
+                        color: mouseArea.containsMouse ? Theme.hoverBg : Theme.cardBase
+                        border.color: mouseArea.containsMouse ? Theme.selectedBr : Theme.cardBorder
+                        border.width: 1
+
+                        MouseArea {
+                            id: mouseArea
+
+                            anchors.fill: parent
+                            hoverEnabled: true
+                            cursorShape: Qt.PointingHandCursor
+                            onClicked: {
+                                missionDetailPanel.visible = true;
+                                missionDetailPanel.campaignData = modelData;
+                            }
+                        }
+
+                        RowLayout {
+                            anchors.fill: parent
+                            anchors.margins: Theme.spacingMedium
+                            spacing: Theme.spacingMedium
+
+                            ColumnLayout {
+                                Layout.fillWidth: true
+                                spacing: Theme.spacingSmall
+
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    spacing: Theme.spacingSmall
+
+                                    Label {
+                                        text: modelData.title || ""
+                                        color: Theme.textMain
+                                        font.pointSize: Theme.fontSizeTitle
+                                        font.bold: true
+                                        Layout.fillWidth: true
+                                    }
+
+                                    Rectangle {
+                                        visible: modelData.completed || false
+                                        Layout.preferredWidth: 100
+                                        Layout.preferredHeight: 24
+                                        radius: Theme.radiusSmall
+                                        color: Theme.successBg
+                                        border.color: Theme.successBr
+                                        border.width: 1
+
+                                        Label {
+                                            anchors.centerIn: parent
+                                            text: qsTr("✓ Completed")
+                                            color: Theme.successText
+                                            font.pointSize: Theme.fontSizeSmall
+                                            font.bold: true
+                                        }
+                                    }
+
+                                    Rectangle {
+                                        visible: !(modelData.unlocked || false)
+                                        Layout.preferredWidth: 80
+                                        Layout.preferredHeight: 24
+                                        radius: Theme.radiusSmall
+                                        color: Theme.disabledBg
+                                        border.color: Theme.border
+                                        border.width: 1
+
+                                        Label {
+                                            anchors.centerIn: parent
+                                            text: qsTr("🔒 Locked")
+                                            color: Theme.textDim
+                                            font.pointSize: Theme.fontSizeSmall
+                                        }
+                                    }
+                                }
+
+                                Label {
+                                    text: modelData.description || ""
+                                    color: Theme.textSubLite
+                                    wrapMode: Text.WordWrap
+                                    maximumLineCount: 2
+                                    elide: Text.ElideRight
+                                    Layout.fillWidth: true
+                                    font.pointSize: Theme.fontSizeMedium
+                                }
+                            }
+
+                            Text {
+                                text: "›"
+                                font.pointSize: Theme.fontSizeHero
+                                color: Theme.textHint
+                            }
+                        }
+
+                        Behavior on color {
+                            ColorAnimation {
+                                duration: Theme.animNormal
+                            }
+                        }
+
+                        Behavior on border.color {
+                            ColorAnimation {
+                                duration: Theme.animNormal
+                            }
+                        }
+                    }
+                }
+            }
+
+            // Empty state
+            Label {
+                visible: root.campaigns.length === 0
+                text: qsTr("No campaign missions available")
+                color: Theme.textDim
+                font.pointSize: Theme.fontSizeMedium
+                horizontalAlignment: Text.AlignHCenter
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+            }
+        }
+    }
+
+    // Mission detail panel
+    Rectangle {
+        id: missionDetailPanel
+
+        property var campaignData: null
+
+        visible: false
+        anchors.fill: parent
+        color: Theme.dim
+        z: 100
+
+        MouseArea {
+            anchors.fill: parent
+            onClicked: missionDetailPanel.visible = false
+        }
+
+        Rectangle {
+            width: Math.min(parent.width * 0.7, 900)
+            height: Math.min(parent.height * 0.8, 700)
+            anchors.centerIn: parent
+            radius: Theme.radiusPanel
+            color: Theme.panelBase
+            border.color: Theme.panelBr
+            border.width: 2
+
+            MouseArea {
+                anchors.fill: parent
+                onClicked: {} // Prevent click-through
+            }
+
+            ColumnLayout {
+                anchors.fill: parent
+                anchors.margins: Theme.spacingXLarge
+                spacing: Theme.spacingLarge
+
+                // Title
+                Label {
+                    text: missionDetailPanel.campaignData ? (missionDetailPanel.campaignData.title || "") : ""
+                    color: Theme.textMain
+                    font.pointSize: Theme.fontSizeHero
+                    font.bold: true
+                    Layout.fillWidth: true
+                }
+
+                // Description
+                Label {
+                    text: missionDetailPanel.campaignData ? (missionDetailPanel.campaignData.description || "") : ""
+                    color: Theme.textSubLite
+                    wrapMode: Text.WordWrap
+                    Layout.fillWidth: true
+                    font.pointSize: Theme.fontSizeMedium
+                }
+
+                // Black placeholder scene
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.fillHeight: true
+                    color: "#000000"
+                    border.color: Theme.border
+                    border.width: 1
+                    radius: Theme.radiusMedium
+
+                    Label {
+                        anchors.centerIn: parent
+                        text: qsTr("Mission Preview\n(Coming Soon)")
+                        color: Theme.textDim
+                        font.pointSize: Theme.fontSizeLarge
+                        horizontalAlignment: Text.AlignHCenter
+                    }
+                }
+
+                // Buttons
+                RowLayout {
+                    Layout.fillWidth: true
+                    spacing: Theme.spacingMedium
+
+                    Item {
+                        Layout.fillWidth: true
+                    }
+
+                    StyledButton {
+                        text: qsTr("Cancel")
+                        onClicked: missionDetailPanel.visible = false
+                    }
+
+                    StyledButton {
+                        text: qsTr("Start Mission")
+                        enabled: missionDetailPanel.campaignData ? (missionDetailPanel.campaignData.unlocked || false) : false
+                        onClicked: {
+                            if (missionDetailPanel.campaignData) {
+                                root.missionSelected(missionDetailPanel.campaignData.id);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 4 - 4
ui/qml/LoadGamePanel.qml

@@ -159,7 +159,7 @@ Item {
 
                             function loadFromGame() {
                                 clear();
-                                if (typeof game === 'undefined' || !game.getSaveSlots) {
+                                if (typeof game === 'undefined' || !game.get_save_slots) {
                                     append({
                                         "slotName": qsTr("No saves found"),
                                         "title": "",
@@ -171,7 +171,7 @@ Item {
                                     });
                                     return ;
                                 }
-                                var slots = game.getSaveSlots();
+                                var slots = game.get_save_slots();
                                 for (var i = 0; i < slots.length; i++) {
                                     append({
                                         "slotName": slots[i].slotName || slots[i].name,
@@ -397,8 +397,8 @@ Item {
         modal: true
         standardButtons: Dialog.Yes | Dialog.No
         onAccepted: {
-            if (typeof game !== 'undefined' && game.deleteSaveSlot) {
-                if (game.deleteSaveSlot(slotName)) {
+            if (typeof game !== 'undefined' && game.delete_save_slot) {
+                if (game.delete_save_slot(slotName)) {
                     loadListModel.remove(slotIndex);
                     if (loadListModel.count === 0)
                         loadListModel.append({

+ 41 - 8
ui/qml/Main.qml

@@ -132,6 +132,10 @@ ApplicationWindow {
             mapSelect.visible = true;
             mainWindow.menuVisible = false;
         }
+        onOpenCampaign: function() {
+            campaignMenu.visible = true;
+            mainWindow.menuVisible = false;
+        }
         onSaveGame: function() {
             if (mainWindow.gameStarted) {
                 saveGamePanel.visible = true;
@@ -147,8 +151,8 @@ ApplicationWindow {
             mainWindow.menuVisible = false;
         }
         onExitRequested: function() {
-            if (typeof game !== 'undefined' && game.exitGame)
-                game.exitGame();
+            if (typeof game !== 'undefined' && game.exit_game)
+                game.exit_game();
 
         }
     }
@@ -167,8 +171,8 @@ ApplicationWindow {
         }
         onMapChosen: function(map_path, playerConfigs) {
             console.log("Main: onMapChosen received", map_path, "with", playerConfigs.length, "player configs");
-            if (typeof game !== 'undefined' && game.startSkirmish)
-                game.startSkirmish(map_path, playerConfigs);
+            if (typeof game !== 'undefined' && game.start_skirmish)
+                game.start_skirmish(map_path, playerConfigs);
 
             mapSelect.visible = false;
             mainWindow.menuVisible = false;
@@ -182,6 +186,35 @@ ApplicationWindow {
         }
     }
 
+    CampaignMenu {
+        id: campaignMenu
+
+        anchors.fill: parent
+        z: 21
+        visible: false
+        onVisibleChanged: {
+            if (visible) {
+                campaignMenu.forceActiveFocus();
+                gameViewItem.focus = false;
+            }
+        }
+        onMissionSelected: function(campaignId) {
+            console.log("Main: Mission selected:", campaignId);
+            if (typeof game !== 'undefined' && game.start_campaign_mission) {
+                game.start_campaign_mission(campaignId);
+                campaignMenu.visible = false;
+                mainWindow.menuVisible = false;
+                mainWindow.gameStarted = true;
+                mainWindow.gamePaused = false;
+                gameViewItem.forceActiveFocus();
+            }
+        }
+        onCancelled: function() {
+            campaignMenu.visible = false;
+            mainWindow.menuVisible = true;
+        }
+    }
+
     SaveGamePanel {
         id: saveGamePanel
 
@@ -196,8 +229,8 @@ ApplicationWindow {
         }
         onSaveRequested: function(slotName) {
             console.log("Main: Save requested for slot:", slotName);
-            if (typeof game !== 'undefined' && game.saveGameToSlot)
-                game.saveGameToSlot(slotName);
+            if (typeof game !== 'undefined' && game.save_gameToSlot)
+                game.save_gameToSlot(slotName);
 
             saveGamePanel.visible = false;
             mainWindow.menuVisible = true;
@@ -222,8 +255,8 @@ ApplicationWindow {
         }
         onLoadRequested: function(slotName) {
             console.log("Main: Load requested for slot:", slotName);
-            if (typeof game !== 'undefined' && game.loadGameFromSlot) {
-                game.loadGameFromSlot(slotName);
+            if (typeof game !== 'undefined' && game.load_game_from_slot) {
+                game.load_game_from_slot(slotName);
                 loadGamePanel.visible = false;
                 mainWindow.menuVisible = false;
                 mainWindow.gameStarted = true;

+ 11 - 0
ui/qml/MainMenu.qml

@@ -8,6 +8,7 @@ Item {
     id: root
 
     signal openSkirmish()
+    signal openCampaign()
     signal openSettings()
     signal loadSave()
     signal saveGame()
@@ -27,6 +28,8 @@ Item {
             var m = menuModel.get(container.selectedIndex);
             if (m.idStr === "skirmish")
                 root.openSkirmish();
+            else if (m.idStr === "campaign")
+                root.openCampaign();
             else if (m.idStr === "save")
                 root.saveGame();
             else if (m.idStr === "load")
@@ -110,6 +113,12 @@ Item {
                         subtitle: QT_TR_NOOP("Select a map and start")
                     }
 
+                    ListElement {
+                        idStr: "campaign"
+                        title: QT_TR_NOOP("Play — Campaign")
+                        subtitle: QT_TR_NOOP("Story missions and battles")
+                    }
+
                     ListElement {
                         idStr: "save"
                         title: QT_TR_NOOP("Save Game")
@@ -223,6 +232,8 @@ Item {
                             onClicked: {
                                 if (model.idStr === "skirmish")
                                     root.openSkirmish();
+                                else if (model.idStr === "campaign")
+                                    root.openCampaign();
                                 else if (model.idStr === "save")
                                     root.saveGame();
                                 else if (model.idStr === "load")

+ 6 - 6
ui/qml/MapSelect.qml

@@ -6,8 +6,8 @@ import StandardOfIron.UI 1.0
 Item {
     id: root
 
-    property var mapsModel: (typeof game !== "undefined" && game.availableMaps) ? game.availableMaps : []
-    property bool mapsLoading: (typeof game !== "undefined" && game.mapsLoading) ? game.mapsLoading : false
+    property var mapsModel: (typeof game !== "undefined" && game.available_maps) ? game.available_maps : []
+    property bool mapsLoading: (typeof game !== "undefined" && game.maps_loading) ? game.maps_loading : false
     property int selectedMapIndex: -1
     property var selectedMapData: null
     property string selectedMapPath: ""
@@ -18,8 +18,8 @@ Item {
     signal cancelled()
 
     function refreshAvailableNations() {
-        if (typeof game !== "undefined" && game.availableNations)
-            availableNations = game.availableNations;
+        if (typeof game !== "undefined" && game.available_nations)
+            availableNations = game.available_nations;
         else
             availableNations = [];
     }
@@ -288,8 +288,8 @@ Item {
             selectedMapPath = "";
             playersModel.clear();
             refreshAvailableNations();
-            if (typeof game !== "undefined" && game.startLoadingMaps)
-                game.startLoadingMaps();
+            if (typeof game !== "undefined" && game.start_loading_maps)
+                game.start_loading_maps();
 
         }
     }

+ 2 - 2
ui/qml/SaveGamePanel.qml

@@ -171,10 +171,10 @@ Item {
 
                             function loadFromGame() {
                                 clear();
-                                if (typeof game === 'undefined' || !game.getSaveSlots)
+                                if (typeof game === 'undefined' || !game.get_save_slots)
                                     return ;
 
-                                var slots = game.getSaveSlots();
+                                var slots = game.get_save_slots();
                                 for (var i = 0; i < slots.length; i++) {
                                     append({
                                         "slotName": slots[i].slotName || slots[i].name,

+ 10 - 0
ui/theme.h

@@ -45,6 +45,11 @@ class Theme : public QObject {
   Q_PROPERTY(QColor addColor READ addColor CONSTANT)
   Q_PROPERTY(QColor removeColor READ removeColor CONSTANT)
 
+  Q_PROPERTY(QColor successBg READ successBg CONSTANT)
+  Q_PROPERTY(QColor successBr READ successBr CONSTANT)
+  Q_PROPERTY(QColor successText READ successText CONSTANT)
+  Q_PROPERTY(QColor disabledBg READ disabledBg CONSTANT)
+
   Q_PROPERTY(int spacingTiny READ spacingTiny CONSTANT)
   Q_PROPERTY(int spacingSmall READ spacingSmall CONSTANT)
   Q_PROPERTY(int spacingMedium READ spacingMedium CONSTANT)
@@ -112,6 +117,11 @@ public:
   [[nodiscard]] static auto addColor() -> QColor { return {"#3A9CA8"}; }
   [[nodiscard]] static auto removeColor() -> QColor { return {"#D04040"}; }
 
+  [[nodiscard]] static auto successBg() -> QColor { return {"#1e4a2c"}; }
+  [[nodiscard]] static auto successBr() -> QColor { return {"#2d6b3f"}; }
+  [[nodiscard]] static auto successText() -> QColor { return {"#8fdc9f"}; }
+  [[nodiscard]] static auto disabledBg() -> QColor { return {"#1a2a32"}; }
+
   [[nodiscard]] static auto spacingTiny() -> int { return 4; }
   [[nodiscard]] static auto spacingSmall() -> int { return 8; }
   [[nodiscard]] static auto spacingMedium() -> int { return 12; }