Implement technology tree, research queue by least remaining work, and unit/building tech availability & obsolescence

This commit is contained in:
2026-11-09 20:28:53 +01:00
parent d4bd9f4711
commit d266715418
30 changed files with 1809 additions and 45 deletions
+403 -5
View File
@@ -14,7 +14,7 @@ GameManager& GameManager::instance()
}
GameManager::GameManager()
: m_selectedUnitId(-1), m_activeCityViewId(-1), m_actionMode(ActionMode::None),
: 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(5), m_autoQueueMoveWhenBusy(true), m_allowActionQueueing(true),
@@ -58,6 +58,10 @@ void GameManager::init(const QString &baseDir, Matrix2D<TerrainPoint> *mapPoints
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);
@@ -65,9 +69,10 @@ void GameManager::init(const QString &baseDir, Matrix2D<TerrainPoint> *mapPoints
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 = cCfg.initialNationalResources.value("kennis", 0);
player.science = 0;
player.faith = cCfg.initialNationalResources.value("geloof", 0);
player.gold = cCfg.initialNationalResources.value("goud", 0);
}
@@ -86,6 +91,11 @@ void GameManager::init(const QString &baseDir, Matrix2D<TerrainPoint> *mapPoints
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);
@@ -123,6 +133,11 @@ Unit* GameManager::spawnUnit(const QString &configId, int playerId, int tileX, i
void GameManager::update(float deltaTime, Matrix2D<TerrainPoint> *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) {
@@ -171,8 +186,347 @@ void GameManager::update(float deltaTime, Matrix2D<TerrainPoint> *mapPoints, int
}
}
// 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);
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<TerrainPoint> *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<QString> 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<QString> requiredSet;
std::vector<QString> 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<QString> orderedQueue;
orderedQueue.reserve(requiredSet.size());
std::set<QString> currentSatisfied = satisfied;
std::set<QString> remainingToSchedule = requiredSet;
while (!remainingToSchedule.empty()) {
std::vector<QString> 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<QString> 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()
@@ -251,6 +605,7 @@ const City* GameManager::getActiveCityView() const
void GameManager::enterCityView(int cityId)
{
m_activeCityViewId = cityId;
m_isTechTreeOpen = false;
m_selectedUnitId = -1;
m_actionMode = ActionMode::None;
}
@@ -378,6 +733,7 @@ CityYieldSummary GameManager::calculateCityYields(const City &city, Matrix2D<Ter
void GameManager::processTickYields(Matrix2D<TerrainPoint> *mapPoints, int numPoints05)
{
const CityConfig &cfg = TerrainYieldManager::instance().getCityConfig();
QMap<int, int> playerTickScience;
for (auto &city : m_cities) {
CityYieldSummary yields = calculateCityYields(city, mapPoints, numPoints05);
@@ -396,13 +752,13 @@ void GameManager::processTickYields(Matrix2D<TerrainPoint> *mapPoints, int numPo
city.accumulatedLabor += yields.labor;
}
// 3. National resources go to the owner player:
// 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->science += yields.science;
owner->faith += yields.faith;
owner->gold += yields.gold;
playerTickScience[city.playerId] += yields.science;
}
// Floating growth text over city
@@ -412,6 +768,13 @@ void GameManager::processTickYields(Matrix2D<TerrainPoint> *mapPoints, int numPo
.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
@@ -471,6 +834,7 @@ void GameManager::queueCityProject(int cityId, CityProjectType type, const QStri
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;
@@ -478,6 +842,23 @@ void GameManager::queueCityProject(int cityId, CityProjectType type, const QStri
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")
@@ -505,6 +886,23 @@ void GameManager::queueCityProject(int cityId, CityProjectType type, const QStri
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);