Browse Source

Merge pull request #145 from djeada/copilot/improve-map-data-loading-ux

Implement Progressive Map Loading with Visual Feedback for Map Select Screen
Adam Djellouli 4 months ago
parent
commit
52151663a5
5 changed files with 351 additions and 3 deletions
  1. 22 1
      app/game_engine.cpp
  2. 10 0
      app/game_engine.h
  3. 140 0
      game/map/map_catalog.cpp
  4. 25 1
      game/map/map_catalog.h
  5. 154 1
      ui/qml/MapSelect.qml

+ 22 - 1
app/game_engine.cpp

@@ -109,6 +109,20 @@ GameEngine::GameEngine() {
 
   m_cursorManager = std::make_unique<CursorManager>();
   m_hoverTracker = std::make_unique<HoverTracker>(m_pickingService.get());
+  
+  // Initialize MapCatalog for progressive loading
+  m_mapCatalog = std::make_unique<Game::Map::MapCatalog>();
+  connect(m_mapCatalog.get(), &Game::Map::MapCatalog::mapLoaded, this, [this](QVariantMap mapData) {
+    m_availableMaps.append(mapData);
+    emit availableMapsChanged();
+  });
+  connect(m_mapCatalog.get(), &Game::Map::MapCatalog::loadingChanged, this, [this](bool loading) {
+    m_mapsLoading = loading;
+    emit mapsLoadingChanged();
+  });
+  connect(m_mapCatalog.get(), &Game::Map::MapCatalog::allMapsLoaded, this, [this]() {
+    emit availableMapsChanged();
+  });
 
   connect(m_cursorManager.get(), &CursorManager::modeChanged, this,
           &GameEngine::cursorModeChanged);
@@ -729,8 +743,15 @@ void GameEngine::setRallyAtScreen(qreal sx, qreal sy) {
                                        m_runtime.localOwnerId);
 }
 
+void GameEngine::startLoadingMaps() {
+  m_availableMaps.clear();
+  if (m_mapCatalog) {
+    m_mapCatalog->loadMapsAsync();
+  }
+}
+
 QVariantList GameEngine::availableMaps() const {
-  return Game::Map::MapCatalog::availableMaps();
+  return m_availableMaps;
 }
 
 void GameEngine::startSkirmish(const QString &mapPath,

+ 10 - 0
app/game_engine.h

@@ -49,6 +49,9 @@ class PickingService;
 class VictoryService;
 class CameraService;
 } // namespace Systems
+namespace Map {
+class MapCatalog;
+} // namespace Map
 } // namespace Game
 
 namespace App {
@@ -82,6 +85,7 @@ public:
       int maxTroopsPerPlayer READ maxTroopsPerPlayer NOTIFY troopCountChanged)
   Q_PROPERTY(
       QVariantList availableMaps READ availableMaps NOTIFY availableMapsChanged)
+  Q_PROPERTY(bool mapsLoading READ mapsLoading NOTIFY mapsLoadingChanged)
   Q_PROPERTY(int enemyTroopsDefeated READ enemyTroopsDefeated NOTIFY
                  enemyTroopsDefeatedChanged)
   Q_PROPERTY(QVariantList ownerInfo READ getOwnerInfo NOTIFY ownerInfoChanged)
@@ -110,6 +114,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 setPaused(bool paused) { m_runtime.paused = paused; }
   Q_INVOKABLE void setGameSpeed(float speed) {
@@ -147,6 +152,7 @@ public:
   Q_INVOKABLE QString getSelectedUnitsCommandMode() const;
   Q_INVOKABLE void setRallyAtScreen(qreal sx, qreal sy);
   Q_INVOKABLE QVariantList availableMaps() const;
+  bool mapsLoading() const { return m_mapsLoading; }
   Q_INVOKABLE void
   startSkirmish(const QString &mapPath,
                 const QVariantList &playerConfigs = QVariantList());
@@ -239,6 +245,7 @@ private:
   std::unique_ptr<Game::Systems::CameraService> m_cameraService;
   std::unique_ptr<Game::Systems::SelectionController> m_selectionController;
   std::unique_ptr<App::Controllers::CommandController> m_commandController;
+  std::unique_ptr<Game::Map::MapCatalog> m_mapCatalog;
   QQuickWindow *m_window = nullptr;
   RuntimeState m_runtime;
   ViewportState m_viewport;
@@ -247,6 +254,8 @@ private:
   QObject *m_selectedUnitsModel = nullptr;
   int m_enemyTroopsDefeated = 0;
   int m_selectedPlayerId = 1;
+  QVariantList m_availableMaps;
+  bool m_mapsLoading = false;
   Engine::Core::ScopedEventSubscription<Engine::Core::UnitDiedEvent>
       m_unitDiedSubscription;
   Engine::Core::ScopedEventSubscription<Engine::Core::UnitSpawnedEvent>
@@ -264,4 +273,5 @@ signals:
   void ownerInfoChanged();
   void selectedPlayerIdChanged();
   void lastErrorChanged();
+  void mapsLoadingChanged();
 };

+ 140 - 0
game/map/map_catalog.cpp

@@ -8,11 +8,14 @@
 #include <QSet>
 #include <QStringList>
 #include <QVariantMap>
+#include <QTimer>
 #include <algorithm>
 
 namespace Game {
 namespace Map {
 
+MapCatalog::MapCatalog(QObject *parent) : QObject(parent) {}
+
 QVariantList MapCatalog::availableMaps() {
   QVariantList list;
   QDir mapsDir(QStringLiteral("assets/maps"));
@@ -97,5 +100,142 @@ QVariantList MapCatalog::availableMaps() {
   return list;
 }
 
+void MapCatalog::loadMapsAsync() {
+  if (m_loading) return;
+  
+  m_maps.clear();
+  m_pendingFiles.clear();
+  m_loading = true;
+  emit loadingChanged(true);
+  
+  QDir mapsDir(QStringLiteral("assets/maps"));
+  if (!mapsDir.exists()) {
+    m_loading = false;
+    emit loadingChanged(false);
+    emit allMapsLoaded();
+    return;
+  }
+  
+  m_pendingFiles = mapsDir.entryList(QStringList() << "*.json", QDir::Files, QDir::Name);
+  
+  if (m_pendingFiles.isEmpty()) {
+    m_loading = false;
+    emit loadingChanged(false);
+    emit allMapsLoaded();
+    return;
+  }
+  
+  // Start loading the first map immediately
+  // Subsequent maps are loaded with a small delay to keep UI responsive
+  QTimer::singleShot(0, this, &MapCatalog::loadNextMap);
+}
+
+void MapCatalog::loadNextMap() {
+  if (m_pendingFiles.isEmpty()) {
+    m_loading = false;
+    emit loadingChanged(false);
+    emit allMapsLoaded();
+    return;
+  }
+  
+  QString fileName = m_pendingFiles.takeFirst();
+  QDir mapsDir(QStringLiteral("assets/maps"));
+  QString path = mapsDir.filePath(fileName);
+  
+  QVariantMap entry = loadSingleMap(path);
+  if (!entry.isEmpty()) {
+    m_maps.append(entry);
+    emit mapLoaded(entry);  // Notify that a new map is available
+  }
+  // Note: Failed/invalid maps are silently skipped
+  
+  // Schedule next map load with a small delay to keep UI responsive
+  // This allows the event loop to process UI updates between map loads
+  if (!m_pendingFiles.isEmpty()) {
+    QTimer::singleShot(10, this, &MapCatalog::loadNextMap);
+  } else {
+    m_loading = false;
+    emit loadingChanged(false);
+    emit allMapsLoaded();
+  }
+}
+
+QVariantMap MapCatalog::loadSingleMap(const QString &path) {
+  QFile file(path);
+  QString name = QFileInfo(path).fileName();
+  QString desc;
+  QSet<int> playerIds;
+  
+  if (file.open(QIODevice::ReadOnly)) {
+    QByteArray data = file.readAll();
+    file.close();
+    QJsonParseError err;
+    QJsonDocument doc = QJsonDocument::fromJson(data, &err);
+    if (err.error == QJsonParseError::NoError && doc.isObject()) {
+      QJsonObject obj = doc.object();
+      if (obj.contains("name") && obj["name"].isString())
+        name = obj["name"].toString();
+      if (obj.contains("description") && obj["description"].isString())
+        desc = obj["description"].toString();
+
+      if (obj.contains("spawns") && obj["spawns"].isArray()) {
+        QJsonArray spawns = obj["spawns"].toArray();
+        for (const QJsonValue &spawnVal : spawns) {
+          if (spawnVal.isObject()) {
+            QJsonObject spawn = spawnVal.toObject();
+            if (spawn.contains("playerId")) {
+              int playerId = spawn["playerId"].toInt();
+              if (playerId > 0) {
+                playerIds.insert(playerId);
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+  
+  QVariantMap entry;
+  entry["name"] = name;
+  entry["description"] = desc;
+  entry["path"] = path;
+  entry["playerCount"] = playerIds.size();
+  
+  QVariantList playerIdList;
+  QList<int> sortedIds = playerIds.values();
+  std::sort(sortedIds.begin(), sortedIds.end());
+  for (int id : sortedIds) {
+    playerIdList.append(id);
+  }
+  entry["playerIds"] = playerIdList;
+
+  // Load thumbnail
+  QString thumbnail;
+  if (file.open(QIODevice::ReadOnly)) {
+    QByteArray data = file.readAll();
+    file.close();
+    QJsonParseError err;
+    QJsonDocument doc = QJsonDocument::fromJson(data, &err);
+    if (err.error == QJsonParseError::NoError && doc.isObject()) {
+      QJsonObject obj = doc.object();
+      if (obj.contains("thumbnail") && obj["thumbnail"].isString()) {
+        thumbnail = obj["thumbnail"].toString();
+      }
+    }
+  }
+
+  if (thumbnail.isEmpty()) {
+    QString baseName = QFileInfo(path).baseName();
+    thumbnail = QString("assets/maps/%1_thumb.png").arg(baseName);
+
+    if (!QFileInfo::exists(thumbnail)) {
+      thumbnail = "";
+    }
+  }
+  entry["thumbnail"] = thumbnail;
+
+  return entry;
+}
+
 }
 }

+ 25 - 1
game/map/map_catalog.h

@@ -1,13 +1,37 @@
 #pragma once
 
+#include <QObject>
 #include <QVariantList>
+#include <QVariantMap>
 
 namespace Game {
 namespace Map {
 
-class MapCatalog {
+class MapCatalog : public QObject {
+  Q_OBJECT
 public:
+  explicit MapCatalog(QObject *parent = nullptr);
+  
   static QVariantList availableMaps();
+  
+  // Asynchronous progressive loading
+  Q_INVOKABLE void loadMapsAsync();
+  
+  bool isLoading() const { return m_loading; }
+  const QVariantList& maps() const { return m_maps; }
+  
+signals:
+  void mapLoaded(QVariantMap mapData);
+  void allMapsLoaded();
+  void loadingChanged(bool loading);
+  
+private:
+  void loadNextMap();
+  QVariantMap loadSingleMap(const QString &filePath);
+  
+  QStringList m_pendingFiles;
+  QVariantList m_maps;
+  bool m_loading = false;
 };
 
 }

+ 154 - 1
ui/qml/MapSelect.qml

@@ -16,6 +16,7 @@ Item {
     
     
     property var mapsModel: (typeof game !== "undefined" && game.availableMaps) ? game.availableMaps : []
+    property bool mapsLoading: (typeof game !== "undefined" && game.mapsLoading) ? game.mapsLoading : false
     ListModel { id: playersModel }
 
     property int    selectedMapIndex: -1
@@ -29,6 +30,10 @@ Item {
             selectedMapData = null
             selectedMapPath = ""
             playersModel.clear()
+            // Start loading maps progressively
+            if (typeof game !== "undefined" && game.startLoadingMaps) {
+                game.startLoadingMaps()
+            }
         }
     }
 
@@ -378,7 +383,7 @@ Item {
 
             Item {
                 anchors.fill: parent
-                visible: list.count === 0
+                visible: list.count === 0 && !mapsLoading
                 Text {
                     text: "No maps available"
                     color: Theme.textSub
@@ -386,6 +391,39 @@ Item {
                     anchors.centerIn: parent
                 }
             }
+            
+            // Loading indicator in the list when maps are being loaded
+            Item {
+                anchors.fill: parent
+                visible: mapsLoading
+                
+                Column {
+                    anchors.centerIn: parent
+                    spacing: Theme.spacingSmall
+                    
+                    Text {
+                        text: "⟳"
+                        font.pixelSize: 24
+                        color: Theme.accent
+                        anchors.horizontalCenter: parent.horizontalCenter
+                        
+                        RotationAnimator on rotation {
+                            from: 0
+                            to: 360
+                            duration: 1500
+                            loops: Animation.Infinite
+                            running: mapsLoading
+                        }
+                    }
+                    
+                    Text {
+                        text: "Loading maps..."
+                        color: Theme.textSub
+                        font.pixelSize: 12
+                        anchors.horizontalCenter: parent.horizontalCenter
+                    }
+                }
+            }
         }
 
         Item {
@@ -412,6 +450,119 @@ Item {
                 anchors { top: parent.top; left: parent.left; right: parent.right }
             }
             
+            // Loading indicator for when maps are being loaded
+            Item {
+                id: loadingIndicator
+                visible: mapsLoading && list.count === 0
+                anchors {
+                    top: breadcrumb.bottom
+                    left: parent.left
+                    right: parent.right
+                    topMargin: Theme.spacingXLarge * 2
+                }
+                height: 100
+                
+                Column {
+                    anchors.centerIn: parent
+                    spacing: Theme.spacingMedium
+                    
+                    Text {
+                        text: "⟳"
+                        font.pixelSize: 40
+                        color: Theme.accent
+                        anchors.horizontalCenter: parent.horizontalCenter
+                        
+                        RotationAnimator on rotation {
+                            from: 0
+                            to: 360
+                            duration: 1500
+                            loops: Animation.Infinite
+                            running: loadingIndicator.visible
+                        }
+                    }
+                    
+                    Text {
+                        text: "Loading maps..."
+                        color: Theme.textSub
+                        font.pixelSize: 14
+                        anchors.horizontalCenter: parent.horizontalCenter
+                    }
+                }
+            }
+            
+            // Loading skeleton when a map is selected but data hasn't fully loaded yet
+            Item {
+                id: loadingSkeleton
+                visible: !selectedMapData && !mapsLoading && list.currentIndex >= 0
+                anchors {
+                    top: breadcrumb.bottom
+                    left: parent.left
+                    right: parent.right
+                    topMargin: Theme.spacingMedium
+                }
+                height: 200
+                
+                Column {
+                    anchors.fill: parent
+                    spacing: Theme.spacingMedium
+                    
+                    // Skeleton title
+                    Rectangle {
+                        width: parent.width * 0.6
+                        height: 28
+                        radius: Theme.radiusSmall
+                        color: Theme.cardBase
+                        opacity: 0.3
+                        
+                        SequentialAnimation on opacity {
+                            loops: Animation.Infinite
+                            running: loadingSkeleton.visible
+                            NumberAnimation { to: 0.6; duration: 800 }
+                            NumberAnimation { to: 0.3; duration: 800 }
+                        }
+                    }
+                    
+                    // Skeleton description
+                    Rectangle {
+                        width: parent.width * 0.8
+                        height: 16
+                        radius: Theme.radiusSmall
+                        color: Theme.cardBase
+                        opacity: 0.3
+                        
+                        SequentialAnimation on opacity {
+                            loops: Animation.Infinite
+                            running: loadingSkeleton.visible
+                            NumberAnimation { to: 0.6; duration: 800; easing.type: Easing.InOutQuad }
+                            NumberAnimation { to: 0.3; duration: 800; easing.type: Easing.InOutQuad }
+                        }
+                    }
+                    
+                    Rectangle {
+                        width: parent.width * 0.7
+                        height: 16
+                        radius: Theme.radiusSmall
+                        color: Theme.cardBase
+                        opacity: 0.3
+                        
+                        SequentialAnimation on opacity {
+                            loops: Animation.Infinite
+                            running: loadingSkeleton.visible
+                            NumberAnimation { to: 0.6; duration: 800; easing.type: Easing.InOutQuad }
+                            NumberAnimation { to: 0.3; duration: 800; easing.type: Easing.InOutQuad }
+                        }
+                    }
+                    
+                    Text {
+                        text: "Loading map details..."
+                        color: Theme.textHint
+                        font.pixelSize: 12
+                        font.italic: true
+                        anchors.horizontalCenter: parent.horizontalCenter
+                    }
+                }
+            }
+            
             
             Text {
                 id: title
@@ -420,6 +571,7 @@ Item {
                     var t = field(it,"name")
                     return t || field(it,"path") || "No Map Selected"
                 }
+                visible: selectedMapData !== null
                 color: Theme.textMain
                 font.pixelSize: 24; font.bold: true
                 elide: Text.ElideRight
@@ -435,6 +587,7 @@ Item {
             Text {
                 id: descr
                 text: field(selectedMapData, "description")
+                visible: selectedMapData !== null
                 color: Theme.textSubLite
                 font.pixelSize: 13
                 wrapMode: Text.WordWrap