#include "gamemanager.h" #include "pathfinder.h" #include "yamlparser.h" #include "terrainyieldmanager.h" #include "terrainmovementmanager.h" #include "localization.h" #include #include GameManager& GameManager::instance() { static GameManager s_instance; return s_instance; } GameManager::GameManager() : m_selectedUnitId(-1), m_activeCityViewId(-1), m_isTechTreeOpen(false), m_actionMode(ActionMode::None), m_movementTickDuration(30.0f), m_tickTimer(0.0f), m_simSpeedMultiplier(1.0f), m_maxActionQueueSize(15), m_autoQueueMoveWhenBusy(true), m_allowActionQueueing(true), m_nextUnitId(0), m_nextCityId(0), m_initialized(false) { } void GameManager::init(const QString &baseDir, Matrix2D *mapPoints, int numPoints05) { if (m_initialized) return; // Load localization Localization::instance().loadFile(baseDir + "/config/strings_en.yaml"); // Load terrain yields & city configuration TerrainYieldManager::instance().loadConfigs(baseDir); // Load game settings QString gameConfigPath = baseDir + "/config/game.yaml"; YamlNode gameRoot = YamlParser::parseFile(gameConfigPath); if (gameRoot.hasChild("movement_tick_duration")) { m_movementTickDuration = gameRoot["movement_tick_duration"].asFloat(30.0f); } if (gameRoot.hasChild("action_queue")) { const YamlNode &aqNode = gameRoot["action_queue"]; m_maxActionQueueSize = aqNode["max_size"].asInt(15); m_autoQueueMoveWhenBusy = aqNode["auto_queue_move_when_busy"].asBool(true); m_allowActionQueueing = aqNode["allow_action_queueing"].asBool(true); } // Load unit YAML configurations QString unitsConfigDir = baseDir + "/config/units"; m_unitConfigManager.loadFromDirectory(unitsConfigDir); // Load building & district YAML configurations QString buildingsConfigPath = baseDir + "/config/buildings.yaml"; m_buildingConfigManager.loadConfigFile(buildingsConfigPath); // Load road YAML configurations QString roadsConfigPath = baseDir + "/config/roads.yaml"; m_roadConfigManager.loadConfigFile(roadsConfigPath); // Load technology tree YAML configuration QString techConfigPath = baseDir + "/config/technologies.yaml"; m_techTreeManager.loadConfigFile(techConfigPath); // Load action needed queue configuration QString actionNeededConfigPath = baseDir + "/config/action_needed.yaml"; m_actionNeededManager.loadConfigFile(actionNeededConfigPath); const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); // Initialize player national resources from configuration // Note: science is a per-tick generation rate and is not stockpiled for (auto &player : m_playerManager.getPlayersMutable()) { player.culture = cCfg.initialNationalResources.value("cultuur", 10000); player.science = 0; player.faith = cCfg.initialNationalResources.value("geloof", 0); player.gold = cCfg.initialNationalResources.value("goud", 0); } // Spawn an initial established city for Player 1 at (35, 17) int cId = getNextCityId(); QVector2D w = Pathfinder::hexToWorld(35, 17); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); City initialCity(cId, 1, "Roma", 35, 17, QVector3D(w.x(), w.y(), gz), cCfg.initialInhabitants, cCfg.inhabitantsPerPop, cCfg.initialBorderRadius); // Assign initial 1 pop to work the city center tile with best yield resource QString defRes = getBestTileResource(35, 17, mapPoints, numPoints05); initialCity.assignWorker(35, 17, defRes); m_cities.push_back(initialCity); // Update initial per-tick science generation rate for all players for (auto &player : m_playerManager.getPlayersMutable()) { player.science = calculatePlayerSciencePerTick(player.id, mapPoints, numPoints05); } // Spawn initial demo units near starting camera position // Player 1 (Blue & Gold) spawnUnit("settler", 1, 36, 18, mapPoints, numPoints05); spawnUnit("builder", 1, 35, 18, mapPoints, numPoints05); spawnUnit("warrior", 1, 37, 18, mapPoints, numPoints05); spawnUnit("slinger", 1, 37, 19, mapPoints, numPoints05); spawnUnit("galley", 1, 41, 92, mapPoints, numPoints05); // Player 2 (Red & White) - Opponent units spawnUnit("warrior", 2, 40, 18, mapPoints, numPoints05); spawnUnit("slinger", 2, 41, 19, mapPoints, numPoints05); m_initialized = true; qDebug() << "GameManager initialized with" << m_cities.size() << "cities and" << m_units.size() << "units."; } Unit* GameManager::spawnUnit(const QString &configId, int playerId, int tileX, int tileY, Matrix2D *mapPoints, int numPoints05) { const UnitConfig *cfg = m_unitConfigManager.getConfig(configId); if (!cfg) { qWarning() << "Unit config not found:" << configId; return nullptr; } QVector2D w = Pathfinder::hexToWorld(tileX, tileY); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); int id = getNextUnitId(); auto unit = std::make_shared(id, playerId, *cfg, tileX, tileY, gz); unit->maxQueueSize = m_maxActionQueueSize; m_units.push_back(unit); return unit.get(); } void GameManager::update(float deltaTime, Matrix2D *mapPoints, int numPoints05) { // Keep player science rate synchronized with current city yields (science is generated per tick, not saved) for (auto &player : m_playerManager.getPlayersMutable()) { player.science = calculatePlayerSciencePerTick(player.id, mapPoints, numPoints05); } // Update movement tick timer m_tickTimer += deltaTime * m_simSpeedMultiplier; if (m_tickTimer >= m_movementTickDuration) { m_tickTimer = std::fmod(m_tickTimer, m_movementTickDuration); processTickYields(mapPoints, numPoints05); } // Update units for (auto &u : m_units) { if (!u->isDead()) { u->update(deltaTime, m_movementTickDuration, m_simSpeedMultiplier, mapPoints, numPoints05); if (u->isBuilding() && u->buildProgress >= 1.0f) { completeUnitBuilding(*u, mapPoints, numPoints05); } if (!u->isDead() && u->state == UnitState::Idle) { if (u->hasRoadRoute()) { advanceRoadRoute(*u, mapPoints, numPoints05); } else if (u->currentAction.isValid()) { continueCurrentUnitAction(*u, mapPoints, numPoints05); } if (!u->isDead() && u->state == UnitState::Idle && !u->currentAction.isValid() && u->hasQueuedAction()) { processUnitActionQueue(*u, mapPoints, numPoints05); } } } } // Clean up dead units for (auto it = m_units.begin(); it != m_units.end(); ) { if ((*it)->isDead()) { if ((*it)->id == m_selectedUnitId) { m_selectedUnitId = -1; m_actionMode = ActionMode::None; } it = m_units.erase(it); } else { ++it; } } // Update floating texts for (auto it = m_floatingTexts.begin(); it != m_floatingTexts.end(); ) { it->life -= deltaTime; it->worldPos.setZ(it->worldPos.z() + deltaTime * 0.4f); if (it->life <= 0.0f) { it = m_floatingTexts.erase(it); } else { ++it; } } // Check if Player 1 needs to select a technology to research const Player *p1 = m_playerManager.getPlayer(1); bool p1HasCities = false; for (const auto &c : m_cities) { if (c.playerId == 1) { p1HasCities = true; break; } } bool hasUnresearchedTechs = p1 && ((int)p1->researchedTechs.size() < m_techTreeManager.getAllTechs().size()); bool needsResearch = p1 && p1HasCities && !p1->hasActiveResearch() && hasUnresearchedTechs; // Update Action Needed Queue for active human player 1 m_actionNeededManager.update(deltaTime, 1, m_units, m_cities, needsResearch); } void GameManager::openTechTree() { m_isTechTreeOpen = true; m_actionMode = ActionMode::None; } void GameManager::closeTechTree() { m_isTechTreeOpen = false; } void GameManager::toggleTechTree() { if (m_isTechTreeOpen) { closeTechTree(); } else { openTechTree(); } } int GameManager::calculatePlayerSciencePerTick(int playerId, Matrix2D *mapPoints, int numPoints05) const { int totalScience = 0; for (const auto &city : m_cities) { if (city.playerId == playerId) { CityYieldSummary yields = calculateCityYields(city, mapPoints, numPoints05); totalScience += yields.science; } } return totalScience; } int GameManager::getTechRemainingScience(const Player &player, const QString &techId) const { const TechConfig *cfg = m_techTreeManager.getTech(techId); if (!cfg) return 0; if (player.hasResearched(techId)) return 0; int progress = player.getTechProgress(techId); return std::max(0, cfg->scienceCost - progress); } bool GameManager::selectTechForPlayer(int playerId, const QString &targetTechId, bool appendToQueue) { Player *player = m_playerManager.getPlayer(playerId); if (!player) return false; const TechConfig *targetCfg = m_techTreeManager.getTech(targetTechId); if (!targetCfg) return false; if (player->hasResearched(targetTechId)) { return false; } // Determine which techs are already satisfied (researched, or already queued if appending) std::set satisfied = player->researchedTechs; if (appendToQueue) { for (const QString &qId : player->researchQueue) { satisfied.insert(qId); } if (satisfied.find(targetTechId) != satisfied.end()) { return false; } } // Collect all unresearched (and unqueued if appending) prerequisites of targetTechId + targetTechId itself std::set requiredSet; std::vector stack; stack.push_back(targetTechId); while (!stack.empty()) { QString cur = stack.back(); stack.pop_back(); if (satisfied.find(cur) != satisfied.end() || requiredSet.find(cur) != requiredSet.end()) { continue; } const TechConfig *cCfg = m_techTreeManager.getTech(cur); if (!cCfg) continue; requiredSet.insert(cur); for (const QString &pre : cCfg->prerequisites) { if (satisfied.find(pre) == satisfied.end() && requiredSet.find(pre) == requiredSet.end()) { stack.push_back(pre); } } } // Build ordered queue: // At each step, among all technologies in requiredSet whose prerequisites are all satisfied, // choose the technology with the smallest remaining science work (scienceCost - currentProgress). // Note: targetTechId itself only becomes available once all its prerequisites are placed in the queue. std::vector orderedQueue; orderedQueue.reserve(requiredSet.size()); std::set currentSatisfied = satisfied; std::set remainingToSchedule = requiredSet; while (!remainingToSchedule.empty()) { std::vector readyTechs; for (const QString &candId : remainingToSchedule) { const TechConfig *candCfg = m_techTreeManager.getTech(candId); if (!candCfg) continue; bool allPrereqsMet = true; for (const QString &pre : candCfg->prerequisites) { if (currentSatisfied.find(pre) == currentSatisfied.end()) { allPrereqsMet = false; break; } } if (allPrereqsMet) { readyTechs.push_back(candId); } } if (readyTechs.empty()) { // Fallback in case of malformed cyclic config in YAML for (const QString &candId : remainingToSchedule) { readyTechs.push_back(candId); } } // Sort ready techs by smallest remaining science work (already partially researched techs have less work left) std::sort(readyTechs.begin(), readyTechs.end(), [&](const QString &a, const QString &b) { // Ensure targetTechId comes after any other ready tech if appending or tie-breaking if (a == targetTechId && b != targetTechId) return false; if (b == targetTechId && a != targetTechId) return true; int remA = getTechRemainingScience(*player, a); int remB = getTechRemainingScience(*player, b); if (remA != remB) return remA < remB; const TechConfig *ta = m_techTreeManager.getTech(a); const TechConfig *tb = m_techTreeManager.getTech(b); if (ta && tb) { if (ta->column != tb->column) return ta->column < tb->column; if (ta->row != tb->row) return ta->row < tb->row; } return a < b; }); QString chosen = readyTechs.front(); orderedQueue.push_back(chosen); currentSatisfied.insert(chosen); remainingToSchedule.erase(chosen); } if (appendToQueue) { player->researchQueue.insert(player->researchQueue.end(), orderedQueue.begin(), orderedQueue.end()); } else { player->researchQueue = orderedQueue; } return true; } void GameManager::removeTechFromPlayerQueue(int playerId, const QString &techId) { Player *player = m_playerManager.getPlayer(playerId); if (!player) return; // Remove techId and any queued tech that transitively depends on techId std::set removed; removed.insert(techId); bool changed = true; while (changed) { changed = false; for (const QString &qId : player->researchQueue) { if (removed.find(qId) != removed.end()) continue; const TechConfig *cfg = m_techTreeManager.getTech(qId); if (!cfg) continue; for (const QString &pre : cfg->prerequisites) { if (removed.find(pre) != removed.end()) { removed.insert(qId); changed = true; break; } } } } player->researchQueue.erase( std::remove_if(player->researchQueue.begin(), player->researchQueue.end(), [&](const QString &id) { return removed.find(id) != removed.end(); }), player->researchQueue.end() ); } void GameManager::clearPlayerResearchQueue(int playerId) { Player *player = m_playerManager.getPlayer(playerId); if (!player) return; player->researchQueue.clear(); } void GameManager::advancePlayerResearch(Player &player, int scienceGenerated) { if (scienceGenerated <= 0 || player.researchQueue.empty()) { // Science cannot be saved when no technology is being researched return; } int remainingScienceToApply = scienceGenerated; while (remainingScienceToApply > 0 && !player.researchQueue.empty()) { QString activeId = player.researchQueue.front(); const TechConfig *cfg = m_techTreeManager.getTech(activeId); if (!cfg) { player.researchQueue.erase(player.researchQueue.begin()); continue; } // Verify prerequisites are met before applying science if (!player.arePrerequisitesMet(cfg->prerequisites)) { // Re-run prerequisite ordering for the target tech QString targetId = player.researchQueue.back(); selectTechForPlayer(player.id, targetId, false); if (player.researchQueue.empty()) break; activeId = player.researchQueue.front(); cfg = m_techTreeManager.getTech(activeId); if (!cfg) break; } int curProg = player.getTechProgress(activeId); int needed = std::max(0, cfg->scienceCost - curProg); if (remainingScienceToApply >= needed) { // Technology completed! remainingScienceToApply -= needed; player.techProgress[activeId] = cfg->scienceCost; player.researchedTechs.insert(activeId); player.researchQueue.erase(player.researchQueue.begin()); // Remove any newly obsolete unbuilt projects from this player's city queues // (Already built buildings and existing units on the map do NOT disappear!) for (auto &city : m_cities) { if (city.playerId != player.id) continue; // Remove obsolete items from waiting projectQueue city.projectQueue.erase( std::remove_if(city.projectQueue.begin(), city.projectQueue.end(), [&](const CityProject &proj) { if (proj.type == CityProjectType::Unit) { const UnitConfig *uCfg = m_unitConfigManager.getConfig(proj.id); return uCfg && player.isItemObsolete(uCfg->obsoleteTech); } else if (proj.type == CityProjectType::Building) { const BuildingConfig *bCfg = m_buildingConfigManager.getBuilding(proj.id); return bCfg && player.isItemObsolete(bCfg->obsoleteTech); } return false; }), city.projectQueue.end() ); // Check if currentProject became obsolete if (city.hasActiveProject()) { bool activeObsolete = false; if (city.currentProject.type == CityProjectType::Unit) { const UnitConfig *uCfg = m_unitConfigManager.getConfig(city.currentProject.id); activeObsolete = (uCfg && player.isItemObsolete(uCfg->obsoleteTech)); } else if (city.currentProject.type == CityProjectType::Building) { const BuildingConfig *bCfg = m_buildingConfigManager.getBuilding(city.currentProject.id); activeObsolete = (bCfg && player.isItemObsolete(bCfg->obsoleteTech)); } if (activeObsolete) { city.cancelActiveProject(); } } } // Show floating notification over player's capital / first city for (const auto &city : m_cities) { if (city.playerId == player.id) { QString msg = Localization::instance().get("ui.tech_tree.tech_completed_float", "Technology Researched: %1!") .arg(cfg->name); addFloatingText(msg, city.worldPos + QVector3D(0.0f, 0.0f, 1.5f), QColor(60, 220, 255), 3.5f); break; } } } else { player.techProgress[activeId] = curProg + remainingScienceToApply; remainingScienceToApply = 0; } } } QStringList GameManager::getTechUnlocks(const QString &techId) const { QStringList unlocks; if (techId.isEmpty()) return unlocks; const auto &buildings = m_buildingConfigManager.getAllBuildings(); for (auto it = buildings.begin(); it != buildings.end(); ++it) { if (it.value().requiredTech == techId) { unlocks.append(it.value().name); } } const auto &units = m_unitConfigManager.getAllConfigs(); for (auto it = units.begin(); it != units.end(); ++it) { if (it.value().requiredTech == techId) { unlocks.append(it.value().name); } } return unlocks; } QStringList GameManager::getTechObsoletes(const QString &techId) const { QStringList obsoletes; if (techId.isEmpty()) return obsoletes; const auto &buildings = m_buildingConfigManager.getAllBuildings(); for (auto it = buildings.begin(); it != buildings.end(); ++it) { if (it.value().obsoleteTech == techId) { obsoletes.append(it.value().name); } } const auto &units = m_unitConfigManager.getAllConfigs(); for (auto it = units.begin(); it != units.end(); ++it) { if (it.value().obsoleteTech == techId) { obsoletes.append(it.value().name); } } return obsoletes; } Unit* GameManager::getSelectedUnit() { return getUnitById(m_selectedUnitId); } const Unit* GameManager::getSelectedUnit() const { for (const auto &u : m_units) { if (u->id == m_selectedUnitId) return u.get(); } return nullptr; } void GameManager::selectUnit(int id) { m_selectedUnitId = id; m_activeCityViewId = -1; // Close city view when unit is selected m_actionMode = ActionMode::None; } void GameManager::deselectUnit() { m_selectedUnitId = -1; m_actionMode = ActionMode::None; } Unit* GameManager::getUnitAt(int tx, int ty) { for (auto &u : m_units) { if (!u->isDead() && u->tileX == tx && u->tileY == ty) { return u.get(); } } return nullptr; } Unit* GameManager::getUnitById(int id) { for (auto &u : m_units) { if (u->id == id && !u->isDead()) return u.get(); } return nullptr; } City* GameManager::getCityAt(int tx, int ty) { for (auto &c : m_cities) { if (c.tileX == tx && c.tileY == ty) return &c; } return nullptr; } City* GameManager::getCityById(int id) { for (auto &c : m_cities) { if (c.id == id) return &c; } return nullptr; } City* GameManager::getActiveCityView() { return getCityById(m_activeCityViewId); } const City* GameManager::getActiveCityView() const { for (const auto &c : m_cities) { if (c.id == m_activeCityViewId) return &c; } return nullptr; } void GameManager::enterCityView(int cityId) { m_activeCityViewId = cityId; m_isTechTreeOpen = false; m_selectedUnitId = -1; m_actionMode = ActionMode::None; } void GameManager::exitCityView() { m_activeCityViewId = -1; } QMap GameManager::getTileYields(int tx, int ty, Matrix2D *mapPoints, int numPoints05) const { QMap yields; const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); QStringList resources = cCfg.resourcePriority; if (resources.isEmpty()) { resources = QStringList{"voedsel", "arbeid", "goud", "kennis", "cultuur", "geloof"}; } QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, mapPoints, numPoints05); const TerrainYieldConfig *yCfg = TerrainYieldManager::instance().getYieldConfig(tType); const TileImprovement *imp = getImprovementAt(tx, ty); const ImprovementConfig *iCfg = imp ? TerrainYieldManager::instance().getImprovementConfig(imp->improvementId) : nullptr; for (const QString &res : resources) { int amount = yCfg ? yCfg->getYield(res) : 0; if (iCfg && iCfg->yieldBonus.contains(res)) { amount += iCfg->yieldBonus.value(res, 0); } yields[res] = amount; } return yields; } int GameManager::getTileYield(int tx, int ty, const QString &res, Matrix2D *mapPoints, int numPoints05) const { QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, mapPoints, numPoints05); const TerrainYieldConfig *yCfg = TerrainYieldManager::instance().getYieldConfig(tType); int amount = yCfg ? yCfg->getYield(res) : 0; const TileImprovement *imp = getImprovementAt(tx, ty); if (imp) { const ImprovementConfig *iCfg = TerrainYieldManager::instance().getImprovementConfig(imp->improvementId); if (iCfg && iCfg->yieldBonus.contains(res)) { amount += iCfg->yieldBonus.value(res, 0); } } return amount; } QString GameManager::getBestTileResource(int tx, int ty, Matrix2D *mapPoints, int numPoints05) const { const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); QStringList priority = cCfg.resourcePriority; if (priority.isEmpty()) { priority = QStringList{"voedsel", "arbeid", "goud", "kennis", "cultuur", "geloof"}; } QMap yields = getTileYields(tx, ty, mapPoints, numPoints05); QString bestRes = priority.first(); int maxYield = -1; for (const QString &res : priority) { int y = yields.value(res, 0); if (y > maxYield) { maxYield = y; bestRes = res; } } return bestRes; } CityYieldSummary GameManager::calculateCityYields(const City &city, Matrix2D *mapPoints, int numPoints05) const { CityYieldSummary summary; const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); // Base yields for every city per tick summary.food += cCfg.getBaseYield("voedsel"); summary.labor += cCfg.getBaseYield("arbeid"); summary.science += cCfg.getBaseYield("kennis"); summary.culture += cCfg.getBaseYield("cultuur"); summary.faith += cCfg.getBaseYield("geloof"); summary.gold += cCfg.getBaseYield("goud"); // Additional yields from worked tiles for (const auto &pair : city.workedTiles) { int tx = pair.first.first; int ty = pair.first.second; QString assignedRes = pair.second; int amount = getTileYield(tx, ty, assignedRes, mapPoints, numPoints05); if (assignedRes == "voedsel") summary.food += amount; else if (assignedRes == "arbeid") summary.labor += amount; else if (assignedRes == "kennis") summary.science += amount; else if (assignedRes == "cultuur") summary.culture += amount; else if (assignedRes == "geloof") summary.faith += amount; else if (assignedRes == "goud") summary.gold += amount; } // Additional yields and multipliers from completed city buildings float totalFoodMultiplier = 1.0f; for (const QString &bId : city.completedBuildings) { const BuildingConfig *bCfg = m_buildingConfigManager.getBuilding(bId); if (!bCfg) continue; summary.food += bCfg->foodBonus; summary.labor += bCfg->laborBonus; summary.science += bCfg->scienceBonus; summary.culture += bCfg->cultureBonus; summary.faith += bCfg->faithBonus; summary.gold += bCfg->goldBonus; if (bCfg->foodModifier > 0.0f) { totalFoodMultiplier += bCfg->foodModifier; } } if (totalFoodMultiplier != 1.0f) { summary.food = (int)std::round(summary.food * totalFoodMultiplier); } return summary; } void GameManager::processTickYields(Matrix2D *mapPoints, int numPoints05) { const CityConfig &cfg = TerrainYieldManager::instance().getCityConfig(); QMap playerTickScience; for (auto &city : m_cities) { CityYieldSummary yields = calculateCityYields(city, mapPoints, numPoints05); // 1. Food is converted into inhabitants: int foodGrown = (int)std::round(yields.food * cfg.foodToInhabitantsRatio); city.inhabitants += foodGrown; // 2. Labor contributes to active project or accumulates locally: if (city.hasActiveProject()) { city.currentProject.currentLabor += yields.labor; if (city.currentProject.currentLabor >= city.currentProject.laborCost) { completeCityProject(city, mapPoints, numPoints05); } } else { city.accumulatedLabor += yields.labor; } // 3. National resources go to the owner player (science is applied to research, not stockpiled): Player *owner = m_playerManager.getPlayer(city.playerId); if (owner) { owner->culture += yields.culture; owner->faith += yields.faith; owner->gold += yields.gold; playerTickScience[city.playerId] += yields.science; } // Floating growth text over city QVector3D ftPos = city.worldPos + QVector3D(0.0f, 0.0f, 1.1f); QString growthMsg = Localization::instance().get( "ui.city.growth_float", "+%1 Inhabitants | +%2 Culture") .arg(foodGrown).arg(yields.culture); addFloatingText(growthMsg, ftPos, QColor(100, 255, 140), 2.5f); } // 4. Apply generated science per tick directly to each player's active research queue for (auto &player : m_playerManager.getPlayersMutable()) { int generatedSci = playerTickScience.value(player.id, 0); player.science = generatedSci; advancePlayerResearch(player, generatedSci); } } bool GameManager::isTileOwned(int tx, int ty, int *outOwnerCityId) const { for (const auto &c : m_cities) { if (c.ownsTile(tx, ty)) { if (outOwnerCityId) *outOwnerCityId = c.id; return true; } } return false; } bool GameManager::buyTileForCity(int cityId, int tx, int ty) { City *city = getCityById(cityId); if (!city) return false; // Check if tile is already owned by any city int existingOwnerId = -1; if (isTileOwned(tx, ty, &existingOwnerId)) { return false; } const CityConfig &cfg = TerrainYieldManager::instance().getCityConfig(); // Check distance (must be within max workable radius) int dist = Pathfinder::hexDistance(city->tileX, city->tileY, tx, ty); if (dist > cfg.maxWorkableRadius) { return false; } // Check culture cost Player *player = m_playerManager.getPlayer(city->playerId); if (!player) return false; int cost = cfg.tileCultureCost; if (player->culture < cost) { QVector2D w = Pathfinder::hexToWorld(tx, ty); QString notEnough = Localization::instance().get("ui.city.not_enough_culture", "Not enough Culture! (%1 needed)").arg(cost); addFloatingText(notEnough, QVector3D(w.x(), w.y(), 0.8f), QColor(255, 90, 90), 2.0f); return false; } // Deduct culture and claim tile player->culture -= cost; city->addTerritory(tx, ty); QVector2D w = Pathfinder::hexToWorld(tx, ty); QString boughtMsg = Localization::instance().get("ui.unit.tile_purchased", "Tile purchased! (-%1 Culture)").arg(cost); addFloatingText(boughtMsg, QVector3D(w.x(), w.y(), 0.8f), QColor(210, 120, 255), 2.5f); return true; } void GameManager::queueCityProject(int cityId, CityProjectType type, const QString &id) { City *city = getCityById(cityId); if (!city) return; const Player *player = m_playerManager.getPlayer(city->playerId); const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); int maxQueue = cCfg.maxProductionQueueSize; if (type == CityProjectType::Unit) { const UnitConfig *uCfg = m_unitConfigManager.getConfig(id); if (!uCfg) return; if (player) { if (!player->isItemUnlocked(uCfg->requiredTech)) { const TechConfig *tCfg = m_techTreeManager.getTech(uCfg->requiredTech); QString tName = tCfg ? tCfg->name : uCfg->requiredTech; QString msg = Localization::instance().get("ui.city.requires_tech", "%1 requires technology: %2!") .arg(uCfg->name).arg(tName); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.2f); return; } if (player->isItemObsolete(uCfg->obsoleteTech)) { QString msg = Localization::instance().get("ui.city.obsolete_item", "%1 is obsolete and can no longer be built!") .arg(uCfg->name); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.2f); return; } } if (!city->hasActiveProject()) { city->startProject(CityProjectType::Unit, id, uCfg->name, uCfg->laborCost); QString msg = Localization::instance().get("ui.city.project_started", "%1: Started %2") .arg(city->name).arg(uCfg->name); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 230, 80), 2.5f); } else { if ((int)city->projectQueue.size() >= maxQueue) { QString msg = Localization::instance().get("ui.city.queue_full_float", "Production queue is full! (Max %1)").arg(maxQueue); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.0f); return; } city->queueProject(CityProjectType::Unit, id, uCfg->name, uCfg->laborCost, "center", maxQueue); int pos = (int)city->projectQueue.size(); QString msg = Localization::instance().get("ui.city.project_queued", "%1: Added %2 to queue (#%3)") .arg(city->name).arg(uCfg->name).arg(pos); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(100, 200, 255), 2.2f); } } else if (type == CityProjectType::Building) { const BuildingConfig *bCfg = m_buildingConfigManager.getBuilding(id); if (!bCfg) return; if (city->hasBuilding(id)) { QString msg = Localization::instance().get("ui.city.already_built", "%1 already built in this city!").arg(bCfg->name); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.2f); return; } if (player) { if (!player->isItemUnlocked(bCfg->requiredTech)) { const TechConfig *tCfg = m_techTreeManager.getTech(bCfg->requiredTech); QString tName = tCfg ? tCfg->name : bCfg->requiredTech; QString msg = Localization::instance().get("ui.city.requires_tech", "%1 requires technology: %2!") .arg(bCfg->name).arg(tName); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.2f); return; } if (player->isItemObsolete(bCfg->obsoleteTech)) { QString msg = Localization::instance().get("ui.city.obsolete_item", "%1 is obsolete and can no longer be built!") .arg(bCfg->name); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.2f); return; } } if (city->isBuildingQueuedOrBuilt(id)) { QString msg = Localization::instance().get("ui.city.already_queued", "%1 is already in the production queue!").arg(bCfg->name); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 160, 60), 2.2f); return; } if (!city->hasActiveProject()) { city->startProject(CityProjectType::Building, id, bCfg->name, bCfg->laborCost, bCfg->district); QString msg = Localization::instance().get("ui.city.project_started", "%1: Started %2") .arg(city->name).arg(bCfg->name); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 230, 80), 2.5f); } else { if ((int)city->projectQueue.size() >= maxQueue) { QString msg = Localization::instance().get("ui.city.queue_full_float", "Production queue is full! (Max %1)").arg(maxQueue); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 100, 100), 2.0f); return; } city->queueProject(CityProjectType::Building, id, bCfg->name, bCfg->laborCost, bCfg->district, maxQueue); int pos = (int)city->projectQueue.size(); QString msg = Localization::instance().get("ui.city.project_queued", "%1: Added %2 to queue (#%3)") .arg(city->name).arg(bCfg->name).arg(pos); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(100, 200, 255), 2.2f); } } } void GameManager::setCityProject(int cityId, CityProjectType type, const QString &id) { queueCityProject(cityId, type, id); } void GameManager::cancelCityProject(int cityId) { City *city = getCityById(cityId); if (!city || !city->hasActiveProject()) return; QString cancelledName = city->currentProject.name; city->cancelActiveProject(); QString msg = Localization::instance().get("ui.city.project_cancelled", "%1: Cancelled %2").arg(city->name).arg(cancelledName); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 180, 80), 2.0f); if (city->hasActiveProject()) { QString nextMsg = Localization::instance().get("ui.city.project_started", "%1: Started %2") .arg(city->name).arg(city->currentProject.name); addFloatingText(nextMsg, city->worldPos + QVector3D(0.0f, 0.0f, 1.4f), QColor(255, 230, 80), 2.5f); } } void GameManager::removeQueuedCityProject(int cityId, int queueIndex) { City *city = getCityById(cityId); if (!city) return; if (queueIndex >= 0 && queueIndex < (int)city->projectQueue.size()) { QString itemName = city->projectQueue[queueIndex].name; city->removeQueuedProject(queueIndex); QString msg = Localization::instance().get("ui.city.queued_item_removed", "%1: Removed %2 from queue") .arg(city->name).arg(itemName); addFloatingText(msg, city->worldPos + QVector3D(0.0f, 0.0f, 1.2f), QColor(255, 170, 80), 2.0f); } } void GameManager::completeCityProject(City &city, Matrix2D *mapPoints, int numPoints05) { if (!city.hasActiveProject()) return; if (city.currentProject.type == CityProjectType::Unit) { QString unitId = city.currentProject.id; const UnitConfig *uCfg = m_unitConfigManager.getConfig(unitId); // Find spawn location: city center or adjacent empty hex int spawnX = city.tileX; int spawnY = city.tileY; if (getUnitAt(spawnX, spawnY) != nullptr) { for (int e = 0; e < 6; ++e) { int nx, ny; getHexNeighbor(city.tileX, city.tileY, e, nx, ny); if (nx >= 0 && nx < MAP_WIDTH && ny >= 0 && ny < MAP_HEIGHT) { if (getUnitAt(nx, ny) == nullptr && TerrainMovementManager::instance().isHexPassable(nx, ny, uCfg ? uCfg->movementDomain : MovementDomain::Land, mapPoints, numPoints05, uCfg)) { spawnX = nx; spawnY = ny; break; } } } } Unit *spawned = spawnUnit(unitId, city.playerId, spawnX, spawnY, mapPoints, numPoints05); QString unitName = spawned ? spawned->config.name : (uCfg ? uCfg->name : unitId); QString msg = Localization::instance().get("ui.city.unit_trained", "%1: %2 trained!") .arg(city.name).arg(unitName); addFloatingText(msg, city.worldPos + QVector3D(0.0f, 0.0f, 1.3f), QColor(80, 255, 140), 3.0f); } else if (city.currentProject.type == CityProjectType::Building) { QString bId = city.currentProject.id; city.addBuilding(bId); const BuildingConfig *bCfg = m_buildingConfigManager.getBuilding(bId); QString bName = bCfg ? bCfg->name : bId; const DistrictConfig *dCfg = m_buildingConfigManager.getDistrict(bCfg ? bCfg->district : "center"); QString dName = dCfg ? dCfg->name : "City Center"; QString msg = Localization::instance().get("ui.city.building_completed", "%1: %2 completed in %3!") .arg(city.name).arg(bName).arg(dName); addFloatingText(msg, city.worldPos + QVector3D(0.0f, 0.0f, 1.3f), QColor(80, 240, 255), 3.5f); } int excess = city.currentProject.currentLabor - city.currentProject.laborCost; city.advanceQueue(); if (city.hasActiveProject() && excess > 0) { city.currentProject.currentLabor += excess; if (city.currentProject.currentLabor >= city.currentProject.laborCost) { completeCityProject(city, mapPoints, numPoints05); } } else { city.accumulatedLabor += std::max(0, excess); } } void GameManager::getPlannedOriginTile(const Unit &unit, bool appendWithShift, int &outX, int &outY) const { if (appendWithShift && !unit.actionQueue.empty()) { outX = unit.actionQueue.back().targetX; outY = unit.actionQueue.back().targetY; return; } if (unit.currentAction.isValid() && unit.currentAction.targetX >= 0 && unit.currentAction.targetY >= 0) { outX = unit.currentAction.targetX; outY = unit.currentAction.targetY; return; } if (unit.hasRoadRoute()) { outX = unit.roadRoute.back().first; outY = unit.roadRoute.back().second; return; } if (unit.state == UnitState::Moving && !unit.path.empty()) { outX = unit.path.back().first; outY = unit.path.back().second; return; } outX = unit.tileX; outY = unit.tileY; } bool GameManager::scheduleOrStartUnitAction(Unit &unit, const UnitAction &action, bool appendWithShift, Matrix2D *mapPoints, int numPoints05) { if (!appendWithShift) { // Without Shift: cancel everything queued after the current action // (the current action itself is executed completely, and the new action is scheduled right after it) unit.clearActionQueue(); } if (!unit.hasActiveAction()) { return startUnitAction(unit, action, mapPoints, numPoints05); } if (!m_allowActionQueueing) { addFloatingText(Localization::instance().get("ui.unit.busy", "Unit is busy!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); return false; } if (!unit.queueAction(action)) { addFloatingText(Localization::instance().get("ui.unit.queue_full", "Action queue full!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); return false; } QString summary = unit.getActionSummary(action); QVector2D w = Pathfinder::hexToWorld(action.targetX, action.targetY); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); if (appendWithShift) { int pos = (int)unit.actionQueue.size(); QString msg = Localization::instance().get("ui.unit.action_queued_count", "%1 added to queue (#%2)") .arg(summary).arg(pos); addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.9f), QColor(100, 220, 255), 2.2f); } else { QString msg = Localization::instance().get("ui.unit.action_scheduled_after_current", "%1 scheduled after current action") .arg(summary); addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.9f), QColor(255, 220, 90), 2.2f); } return true; } bool GameManager::startUnitAction(Unit &unit, const UnitAction &action, Matrix2D *mapPoints, int numPoints05) { if (unit.isDead() || !action.isValid()) { return false; } unit.currentAction = action; switch (action.type) { case UnitActionType::Move: { int tx = action.targetX; int ty = action.targetY; if (tx == unit.tileX && ty == unit.tileY) { unit.currentAction = UnitAction(); return false; } if (!TerrainMovementManager::instance().isHexPassable( tx, ty, unit.config.movementDomain, mapPoints, numPoints05, &unit.config)) { if (unit.isNaval()) { addFloatingText(Localization::instance().get("ui.unit.naval_only_water", "Ships can only sail on water!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); } else { addFloatingText(Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); } unit.currentAction = UnitAction(); return false; } std::vector> path = Pathfinder::findPath( unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, unit.config.movementDomain, &unit.config ); if (path.size() > 1) { unit.setPath(path); addFloatingText(Localization::instance().get("ui.unit.moving", "Moving"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 230, 80)); return true; } else { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); unit.currentAction = UnitAction(); return false; } } case UnitActionType::BuildImprovement: case UnitActionType::BuildCity: case UnitActionType::Attack: { continueCurrentUnitAction(unit, mapPoints, numPoints05); return unit.currentAction.isValid() || unit.state != UnitState::Idle || unit.isDead(); } case UnitActionType::BuildRoad: { int tx = action.targetX; int ty = action.targetY; if (!TerrainMovementManager::instance().isHexPassable( tx, ty, MovementDomain::Land, mapPoints, numPoints05, &unit.config)) { addFloatingText(Localization::instance().get("ui.unit.cannot_build_road_water", "Roads cannot be built on water!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); unit.currentAction = UnitAction(); return false; } std::vector> route; if (unit.tileX == tx && unit.tileY == ty) { if (hasRoadAt(tx, ty)) { addFloatingText(Localization::instance().get("ui.unit.road_already_here", "Tile already has a road!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); unit.currentAction = UnitAction(); return false; } route.push_back({tx, ty}); } else { route = Pathfinder::findPath( unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, MovementDomain::Land, &unit.config ); } if (route.empty()) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); unit.currentAction = UnitAction(); return false; } unit.startRoadRoute(route); if (route.size() > 1) { addFloatingText(Localization::instance().get("ui.unit.road_route_started", "Building road route to (%1, %2)...").arg(tx).arg(ty), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.2f); } advanceRoadRoute(unit, mapPoints, numPoints05); return true; } default: unit.currentAction = UnitAction(); return false; } } void GameManager::continueCurrentUnitAction(Unit &unit, Matrix2D *mapPoints, int numPoints05) { if (unit.isDead() || unit.state != UnitState::Idle || !unit.currentAction.isValid()) { return; } UnitAction action = unit.currentAction; switch (action.type) { case UnitActionType::Move: { // Movement finished unit.currentAction = UnitAction(); break; } case UnitActionType::BuildImprovement: { if (!unit.config.canBuildFarm()) { unit.currentAction = UnitAction(); return; } if (!unit.config.infiniteCharges && unit.buildCharges <= 0) { addFloatingText(Localization::instance().get("ui.unit.no_build_charges", "No build charges remaining!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); unit.currentAction = UnitAction(); return; } int tx = action.targetX; int ty = action.targetY; QString impId = action.extraData.isEmpty() ? "farm" : action.extraData; if (hasImprovementAt(tx, ty)) { addFloatingText(Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); unit.currentAction = UnitAction(); return; } QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, mapPoints, numPoints05); const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig(impId); QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; if (!validTerrains.contains(tType)) { QString validStr = validTerrains.join(", "); addFloatingText(Localization::instance().get("ui.unit.cannot_build_here", "Cannot build here! Allowed on: %1").arg(validStr), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); unit.currentAction = UnitAction(); return; } // First move onto the target tile if not standing on it yet if (unit.tileX != tx || unit.tileY != ty) { std::vector> path = Pathfinder::findPath( unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, unit.config.movementDomain, &unit.config ); if (path.size() > 1) { unit.setPath(path); addFloatingText(Localization::instance().get("ui.unit.moving_to_build_farm", "Moving to build Farm at (%1, %2)...").arg(tx).arg(ty), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); } else { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); unit.currentAction = UnitAction(); } return; } // Unit is on the target tile: start constructing the improvement float ticks = unit.config.getActionDuration("boerderij_bouwen", 2.0f); float duration = ticks * m_movementTickDuration; unit.startBuilding(impId, duration); addFloatingText(Localization::instance().get("ui.unit.farm_started", "Constructing Farm... (%1 ticks)").arg(ticks, 0, 'g', 3), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); break; } case UnitActionType::BuildCity: { if (!unit.config.canBuildCity()) { unit.currentAction = UnitAction(); return; } int tx = action.targetX; int ty = action.targetY; if (getCityAt(tx, ty) != nullptr) { addFloatingText(Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); unit.currentAction = UnitAction(); return; } // First move onto the target tile if not standing on it yet if (unit.tileX != tx || unit.tileY != ty) { std::vector> path = Pathfinder::findPath( unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, unit.config.movementDomain, &unit.config ); if (path.size() > 1) { unit.setPath(path); addFloatingText(Localization::instance().get("ui.unit.moving_to_build_city", "Moving to found City at (%1, %2)...").arg(tx).arg(ty), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); } else { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); unit.currentAction = UnitAction(); } return; } // Unit is on the target tile: found the city! const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); int cId = getNextCityId(); QString cityName = QString("City %1").arg(cId); QVector2D w = Pathfinder::hexToWorld(tx, ty); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); City newCity(cId, unit.playerId, cityName, tx, ty, QVector3D(w.x(), w.y(), gz), cCfg.initialInhabitants, cCfg.inhabitantsPerPop, cCfg.initialBorderRadius); QString defRes = getBestTileResource(tx, ty, mapPoints, numPoints05); newCity.assignWorker(tx, ty, defRes); m_cities.push_back(newCity); addFloatingText(Localization::instance().get("ui.unit.city_founded", "%1 founded!").arg(cityName), QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.5f); unit.currentAction = UnitAction(); unit.takeDamage(99999.0f); if (unit.id == m_selectedUnitId) { m_selectedUnitId = -1; m_actionMode = ActionMode::None; } break; } case UnitActionType::Attack: { Unit *target = getUnitById(action.targetUnitId); if (!target || target->isDead() || target->playerId == unit.playerId || !unit.config.canAttack()) { unit.currentAction = UnitAction(); return; } int dist = Pathfinder::hexDistance(unit.tileX, unit.tileY, target->tileX, target->tileY); if (dist <= unit.config.attackRange) { // Within range: execute attack! float damage = (float)unit.config.attackDamage; unit.performAttack(*target); QString dmgText = QString("-%1 HP").arg((int)damage); addFloatingText(dmgText, target->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 50, 50)); if (target->isDead()) { addFloatingText(Localization::instance().get("ui.unit.defeated", "Defeated!"), target->worldPos + QVector3D(0, 0, 1.2f), QColor(255, 200, 50), 2.2f); } unit.currentAction = UnitAction(); return; } // Out of range: move into attackRange first if (!mapPoints) { unit.currentAction = UnitAction(); return; } std::vector> fullPath = Pathfinder::findPath( unit.tileX, unit.tileY, target->tileX, target->tileY, mapPoints, numPoints05, unit.config.movementDomain, &unit.config ); std::vector> approachPath; for (const auto &wp : fullPath) { approachPath.push_back(wp); if (Pathfinder::hexDistance(wp.first, wp.second, target->tileX, target->tileY) <= unit.config.attackRange) { break; } } if (approachPath.size() > 1) { unit.currentAction.targetX = approachPath.back().first; unit.currentAction.targetY = approachPath.back().second; unit.setPath(approachPath); addFloatingText(Localization::instance().get("ui.unit.moving_to_attack", "Moving into attack range..."), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 190, 80), 2.0f); } else { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); unit.currentAction = UnitAction(); } break; } case UnitActionType::BuildRoad: { if (!unit.hasRoadRoute()) { unit.currentAction = UnitAction(); } break; } default: unit.currentAction = UnitAction(); break; } } bool GameManager::commandMoveSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); if (!selected) return false; int originX = selected->tileX; int originY = selected->tileY; getPlannedOriginTile(*selected, appendWithShift, originX, originY); if (targetX == originX && targetY == originY) { if (!appendWithShift && selected->hasQueuedAction()) { selected->clearActionQueue(); addFloatingText(Localization::instance().get("ui.unit.queue_cleared", "Queued actions cancelled"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80)); return true; } return false; } if (!TerrainMovementManager::instance().isHexPassable( targetX, targetY, selected->config.movementDomain, mapPoints, numPoints05, &selected->config)) { if (selected->isNaval()) { addFloatingText(Localization::instance().get("ui.unit.naval_only_water", "Ships can only sail on water!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); } else { addFloatingText(Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); } return false; } std::vector> path = Pathfinder::findPath( originX, originY, targetX, targetY, mapPoints, numPoints05, selected->config.movementDomain, &selected->config ); if (path.size() <= 1) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); return false; } return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::Move, targetX, targetY), appendWithShift, mapPoints, numPoints05); } bool GameManager::commandAttack(Unit *target, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); if (!selected || !target || !selected->config.canAttack()) return false; if (target->playerId == selected->playerId || target->isDead()) return false; int originX = selected->tileX; int originY = selected->tileY; getPlannedOriginTile(*selected, appendWithShift, originX, originY); int dist = Pathfinder::hexDistance(originX, originY, target->tileX, target->tileY); int standX = originX; int standY = originY; if (dist > selected->config.attackRange) { if (!mapPoints) { addFloatingText(Localization::instance().get("ui.unit.out_of_range", "Out of range!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); return false; } std::vector> path = Pathfinder::findPath( originX, originY, target->tileX, target->tileY, mapPoints, numPoints05, selected->config.movementDomain, &selected->config ); if (path.size() <= 1) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); return false; } for (const auto &wp : path) { if (Pathfinder::hexDistance(wp.first, wp.second, target->tileX, target->tileY) <= selected->config.attackRange) { standX = wp.first; standY = wp.second; break; } } } return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::Attack, standX, standY, "", target->id), appendWithShift, mapPoints, numPoints05); } bool GameManager::commandBuildCityAtTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); if (!selected || !selected->config.canBuildCity()) return false; if (getCityAt(targetX, targetY) != nullptr) { addFloatingText(Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); return false; } if (!TerrainMovementManager::instance().isHexPassable( targetX, targetY, selected->config.movementDomain, mapPoints, numPoints05, &selected->config)) { addFloatingText(Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); return false; } int originX = selected->tileX; int originY = selected->tileY; getPlannedOriginTile(*selected, appendWithShift, originX, originY); if (targetX != originX || targetY != originY) { std::vector> path = Pathfinder::findPath( originX, originY, targetX, targetY, mapPoints, numPoints05, selected->config.movementDomain, &selected->config ); if (path.size() <= 1) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); return false; } } return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::BuildCity, targetX, targetY), appendWithShift, mapPoints, numPoints05); } void GameManager::commandBuildCitySelected(Matrix2D *mapPoints, int numPoints05) { Unit *selected = getSelectedUnit(); if (!selected) return; commandBuildCityAtTileSelected(selected->tileX, selected->tileY, mapPoints, numPoints05, false); } void GameManager::addFloatingText(const QString &text, const QVector3D &worldPos, const QColor &color, float duration) { m_floatingTexts.push_back({text, worldPos, duration, duration, color}); } const TileImprovement* GameManager::getImprovementAt(int tx, int ty) const { auto it = m_tileImprovements.find({tx, ty}); if (it != m_tileImprovements.end()) { return &it->second; } return nullptr; } bool GameManager::hasImprovementAt(int tx, int ty) const { return m_tileImprovements.find({tx, ty}) != m_tileImprovements.end(); } const TileRoad* GameManager::getRoadAt(int tx, int ty) const { auto it = m_tileRoads.find({tx, ty}); if (it != m_tileRoads.end()) { return &it->second; } return nullptr; } bool GameManager::hasRoadAt(int tx, int ty) const { return m_tileRoads.find({tx, ty}) != m_tileRoads.end(); } void GameManager::updateRoadConnections(int tx, int ty) { auto it = m_tileRoads.find({tx, ty}); if (it == m_tileRoads.end()) return; unsigned char mask = 0; for (int e = 0; e < 6; ++e) { int nx, ny; getHexNeighbor(tx, ty, e, nx, ny); if (nx >= 0 && nx < MAP_WIDTH && ny >= 0 && ny < MAP_HEIGHT) { auto nIt = m_tileRoads.find({nx, ny}); if (nIt != m_tileRoads.end()) { mask |= (1 << e); // Also update the neighbor's opposite edge (e + 3) % 6 nIt->second.connectionMask |= (1 << ((e + 3) % 6)); } } } it->second.connectionMask = mask; } void GameManager::addRoad(int tx, int ty, int playerId, const QString &roadTypeId, Matrix2D *mapPoints, int numPoints05) { QVector2D w = Pathfinder::hexToWorld(tx, ty); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); TileRoad road; road.roadTypeId = roadTypeId.isEmpty() ? m_roadConfigManager.defaultRoadType() : roadTypeId; road.tileX = tx; road.tileY = ty; road.playerId = playerId; road.worldPos = QVector3D(w.x(), w.y(), gz); road.connectionMask = 0; m_tileRoads[{tx, ty}] = road; updateRoadConnections(tx, ty); } void GameManager::removeRoad(int tx, int ty) { auto it = m_tileRoads.find({tx, ty}); if (it == m_tileRoads.end()) return; for (int e = 0; e < 6; ++e) { int nx, ny; getHexNeighbor(tx, ty, e, nx, ny); auto nIt = m_tileRoads.find({nx, ny}); if (nIt != m_tileRoads.end()) { nIt->second.connectionMask &= ~(1 << ((e + 3) % 6)); } } m_tileRoads.erase(it); } float GameManager::getRoadSpeedMultiplierBetween(int fromX, int fromY, int toX, int toY) const { auto itFrom = m_tileRoads.find({fromX, fromY}); auto itTo = m_tileRoads.find({toX, toY}); if (itFrom != m_tileRoads.end() && itTo != m_tileRoads.end()) { const RoadTypeConfig *cfgFrom = m_roadConfigManager.getRoadType(itFrom->second.roadTypeId); const RoadTypeConfig *cfgTo = m_roadConfigManager.getRoadType(itTo->second.roadTypeId); float multFrom = cfgFrom ? cfgFrom->speedMultiplier : 1.30f; float multTo = cfgTo ? cfgTo->speedMultiplier : 1.30f; return std::min(multFrom, multTo); } return 1.0f; } bool GameManager::commandBuildFarmAtTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); if (!selected || !selected->config.canBuildFarm()) return false; if (!selected->config.infiniteCharges) { int plannedFarms = 0; if (selected->currentAction.type == UnitActionType::BuildImprovement) { plannedFarms++; } if (appendWithShift) { for (const auto &act : selected->actionQueue) { if (act.type == UnitActionType::BuildImprovement) plannedFarms++; } } if (selected->buildCharges - plannedFarms <= 0) { addFloatingText(Localization::instance().get("ui.unit.no_build_charges", "No build charges remaining!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); return false; } } bool alreadyPlannedHere = false; if (selected->currentAction.type == UnitActionType::BuildImprovement && selected->currentAction.targetX == targetX && selected->currentAction.targetY == targetY) { alreadyPlannedHere = true; } if (appendWithShift && selected->hasQueuedImprovementAt(targetX, targetY)) { alreadyPlannedHere = true; } if (hasImprovementAt(targetX, targetY) || alreadyPlannedHere) { addFloatingText(Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); return false; } QString tType = TerrainYieldManager::instance().getTerrainType(targetX, targetY, mapPoints, numPoints05); const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig("farm"); QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; if (!validTerrains.contains(tType)) { QString validStr = validTerrains.join(", "); addFloatingText(Localization::instance().get("ui.unit.cannot_build_here", "Cannot build here! Allowed on: %1").arg(validStr), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); return false; } int originX = selected->tileX; int originY = selected->tileY; getPlannedOriginTile(*selected, appendWithShift, originX, originY); if (targetX != originX || targetY != originY) { std::vector> path = Pathfinder::findPath( originX, originY, targetX, targetY, mapPoints, numPoints05, selected->config.movementDomain, &selected->config ); if (path.size() <= 1) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); return false; } } return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::BuildImprovement, targetX, targetY, "farm"), appendWithShift, mapPoints, numPoints05); } void GameManager::commandBuildFarmSelected(Matrix2D *mapPoints, int numPoints05) { Unit *selected = getSelectedUnit(); if (!selected) return; commandBuildFarmAtTileSelected(selected->tileX, selected->tileY, mapPoints, numPoints05, false); } void GameManager::commandBuildRoadSelected(Matrix2D *mapPoints, int numPoints05) { Unit *selected = getSelectedUnit(); if (!selected) return; commandBuildRoadToTileSelected(selected->tileX, selected->tileY, mapPoints, numPoints05, false); } bool GameManager::commandBuildRoadToTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); if (!selected || !selected->config.canBuildRoad()) return false; if (!TerrainMovementManager::instance().isHexPassable( targetX, targetY, MovementDomain::Land, mapPoints, numPoints05, &selected->config)) { addFloatingText(Localization::instance().get("ui.unit.cannot_build_road_water", "Roads cannot be built on water!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); return false; } int originX = selected->tileX; int originY = selected->tileY; getPlannedOriginTile(*selected, appendWithShift, originX, originY); if (targetX == originX && targetY == originY) { if (hasRoadAt(targetX, targetY)) { addFloatingText(Localization::instance().get("ui.unit.road_already_here", "Tile already has a road!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); return false; } } else { std::vector> route = Pathfinder::findPath( originX, originY, targetX, targetY, mapPoints, numPoints05, MovementDomain::Land, &selected->config ); if (route.empty()) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); return false; } } QString defaultType = m_roadConfigManager.defaultRoadType(); return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::BuildRoad, targetX, targetY, defaultType), appendWithShift, mapPoints, numPoints05); } void GameManager::advanceRoadRoute(Unit &unit, Matrix2D *mapPoints, int numPoints05) { if (!unit.hasRoadRoute()) return; while (unit.roadRouteIndex < unit.roadRoute.size()) { auto nextHex = unit.roadRoute[unit.roadRouteIndex]; int tx = nextHex.first; int ty = nextHex.second; // If unit is not on this tile yet, move to it! if (unit.tileX != tx || unit.tileY != ty) { std::vector> stepPath = Pathfinder::findPath( unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, MovementDomain::Land, &unit.config ); if (stepPath.size() > 1) { unit.setPath(stepPath); } else { unit.setPath({ {unit.tileX, unit.tileY}, {tx, ty} }); } unit.state = UnitState::Moving; return; } // Unit is on this tile! Does it already have a road? if (hasRoadAt(tx, ty)) { // Already has a road, skip to next tile along route unit.advanceRoadRouteStep(); continue; } // Start building road on this tile QString defaultType = m_roadConfigManager.defaultRoadType(); const RoadTypeConfig *rCfg = m_roadConfigManager.getRoadType(defaultType); QString roadName = rCfg ? rCfg->name : "Road"; float ticks = unit.config.getActionDuration("build_road", (float)m_roadConfigManager.buildDurationTicks()); float duration = ticks * m_movementTickDuration; unit.startBuilding(QString("road:%1").arg(defaultType), duration); addFloatingText(Localization::instance().get("ui.unit.road_started", "Constructing %1... (%2 ticks)").arg(roadName).arg(ticks, 0, 'g', 3), unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); return; } // Finished entire route! unit.clearRoadRoute(); if (unit.currentAction.type == UnitActionType::BuildRoad) { unit.currentAction = UnitAction(); } QVector2D w = Pathfinder::hexToWorld(unit.tileX, unit.tileY); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); addFloatingText(Localization::instance().get("ui.unit.road_route_completed", "Road route completed!"), QVector3D(w.x(), w.y(), gz + 1.2f), QColor(100, 255, 140), 3.0f); } void GameManager::completeUnitBuilding(Unit &unit, Matrix2D *mapPoints, int numPoints05) { int tx = unit.tileX; int ty = unit.tileY; QString impId = unit.buildingImprovementId.isEmpty() ? "farm" : unit.buildingImprovementId; QVector2D w = Pathfinder::hexToWorld(tx, ty); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); // Check if completing a road if (impId.startsWith("road")) { QString roadType = "dirt"; if (impId.contains(":")) { roadType = impId.section(":", 1); } addRoad(tx, ty, unit.playerId, roadType, mapPoints, numPoints05); unit.cancelBuilding(); const RoadTypeConfig *rCfg = m_roadConfigManager.getRoadType(roadType); QString roadName = rCfg ? rCfg->name : "Road"; addFloatingText(Localization::instance().get("ui.unit.road_completed", "%1 completed!").arg(roadName), QVector3D(w.x(), w.y(), gz + 1.2f), QColor(240, 210, 100), 2.8f); if (unit.hasRoadRoute()) { unit.advanceRoadRouteStep(); advanceRoadRoute(unit, mapPoints, numPoints05); } else if (unit.currentAction.type == UnitActionType::BuildRoad) { unit.currentAction = UnitAction(); } return; } if (hasImprovementAt(tx, ty)) { unit.cancelBuilding(); if (unit.currentAction.type == UnitActionType::BuildImprovement) { unit.currentAction = UnitAction(); } return; } // Place improvement now that it is completed! TileImprovement imp; imp.improvementId = impId; imp.tileX = tx; imp.tileY = ty; imp.playerId = unit.playerId; imp.worldPos = QVector3D(w.x(), w.y(), gz); m_tileImprovements[{tx, ty}] = imp; unit.cancelBuilding(); if (unit.currentAction.type == UnitActionType::BuildImprovement) { unit.currentAction = UnitAction(); } if (!unit.config.infiniteCharges && unit.buildCharges > 0) { unit.buildCharges -= 1; if (unit.buildCharges <= 0) { addFloatingText(Localization::instance().get("ui.unit.farm_completed_consumed", "Farm completed! (Builder consumed)"), QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.8f); unit.takeDamage(99999.0f); if (unit.id == m_selectedUnitId) { m_selectedUnitId = -1; m_actionMode = ActionMode::None; } return; } else { QString chargeWord = unit.buildCharges == 1 ? Localization::instance().get("ui.unit.charge_singular", "charge") : Localization::instance().get("ui.unit.charge_plural", "charges"); QString msg = Localization::instance().get("ui.unit.farm_completed_charges", "Farm completed! (%1 %2 remaining)") .arg(unit.buildCharges) .arg(chargeWord); addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.8f); return; } } addFloatingText(Localization::instance().get("ui.unit.farm_completed", "Farm completed!"), QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.8f); } void GameManager::processUnitActionQueue(Unit &unit, Matrix2D *mapPoints, int numPoints05) { while (!unit.isDead() && unit.state == UnitState::Idle && !unit.currentAction.isValid() && unit.hasQueuedAction()) { UnitAction nextAction = unit.popNextQueuedAction(); startUnitAction(unit, nextAction, mapPoints, numPoints05); } }