From dfefb2d7fb34fc100784a1980d3fc216d992c095 Mon Sep 17 00:00:00 2001 From: bart Date: Sun, 8 Nov 2026 21:52:34 +0100 Subject: [PATCH] Implement builder road construction with hex-edge auto-connection, winding rendering, and speed bonus --- config/roads.yaml | 31 ++++++ config/strings_en.yaml | 15 +++ config/units/builder.yaml | 2 + gamemanager.cpp | 208 ++++++++++++++++++++++++++++++++++++- gamemanager.h | 34 +++++- images/actions/road.png | Bin 0 -> 962 bytes qscenerenderer.cpp | 184 ++++++++++++++++++++++++++++++-- qscenerenderer.h | 1 + roadconfig.cpp | 94 +++++++++++++++++ roadconfig.h | 42 ++++++++ terrainmovementmanager.cpp | 14 ++- tiletest.pro | 6 +- unit.cpp | 2 + unit.h | 1 + unitconfig.h | 2 + 15 files changed, 620 insertions(+), 16 deletions(-) create mode 100644 config/roads.yaml create mode 100644 images/actions/road.png create mode 100644 roadconfig.cpp create mode 100644 roadconfig.h diff --git a/config/roads.yaml b/config/roads.yaml new file mode 100644 index 0000000..755e303 --- /dev/null +++ b/config/roads.yaml @@ -0,0 +1,31 @@ +# Road System Configuration +default_road_type: "dirt" +build_duration_ticks: 2 +meander_amplitude: 0.045 +meander_subdivisions: 5 + +types: + dirt: + id: "dirt" + name: "Dirt Road" + speed_multiplier: 1.30 # +30% movement speed + color: "#c49a62" + border_color: "#6b4b23" + width: 3.5 + cost: 5 + stone: + id: "stone" + name: "Stone Road" + speed_multiplier: 1.60 # +60% movement speed + color: "#9aa1a8" + border_color: "#464b50" + width: 4.0 + cost: 15 + rail: + id: "rail" + name: "Railroad" + speed_multiplier: 2.50 # +150% movement speed + color: "#3a3c40" + border_color: "#181a1c" + width: 4.5 + cost: 30 diff --git a/config/strings_en.yaml b/config/strings_en.yaml index 6e86313..5f0f77a 100644 --- a/config/strings_en.yaml +++ b/config/strings_en.yaml @@ -88,12 +88,27 @@ ui: btn_build_farm: "Farm [F]" btn_farm_queued: "Farm (Queued)" btn_farm_building: "Building %1%" + btn_build_road: "Road [R]" + btn_road_queued: "Road (Queued)" + btn_road_building: "Building %1%" tip_move: "Move (M)" tip_attack: "Attack (A) [Range: %1]" tip_build_city: "Found City (B)" tip_build_farm: "Build Farm (F)" tip_farm_building: "Busy building... %1%" tip_farm_queued: "Build Farm (Queued on arrival - click to cancel)" + tip_build_road: "Build Road (R)" + tip_road_building: "Busy building road... %1%" + tip_road_queued: "Build Road (Queued on arrival - click to cancel)" + road_started: "Constructing %1... (%2 ticks)" + road_completed: "%1 completed!" + road_queued: "Build Road queued on arrival" + road_already_here: "Tile already has a road!" + cannot_build_road_water: "Roads cannot be built on water!" + + improvement: + farm: "Farm" + road: "Road" city: center: "City Center" diff --git a/config/units/builder.yaml b/config/units/builder.yaml index d2a8762..d535f82 100644 --- a/config/units/builder.yaml +++ b/config/units/builder.yaml @@ -12,5 +12,7 @@ icon: images/units/builder.png actions: - verplaatsen - boerderij_bouwen + - build_road action_speeds: boerderij_bouwen: 10.0 # Duur in seconden die deze builder unit nodig heeft voor de actie boerderij_bouwen + build_road: 2 # Duur in ticks diff --git a/gamemanager.cpp b/gamemanager.cpp index 7687dab..f611f07 100644 --- a/gamemanager.cpp +++ b/gamemanager.cpp @@ -54,6 +54,10 @@ void GameManager::init(const QString &baseDir, Matrix2D *mapPoints QString buildingsConfigPath = baseDir + "/config/buildings.yaml"; m_buildingConfigManager.loadConfigFile(buildingsConfigPath); + // Load road YAML configurations + QString roadsConfigPath = baseDir + "/config/roads.yaml"; + m_roadConfigManager.loadConfigFile(roadsConfigPath); + const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); // Initialize player national resources from configuration @@ -667,6 +671,89 @@ 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; +} + void GameManager::commandBuildFarmSelected(Matrix2D *mapPoints, int numPoints05) { Unit *selected = getSelectedUnit(); @@ -754,21 +841,111 @@ void GameManager::commandBuildFarmSelected(Matrix2D *mapPoints, in selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); } +void GameManager::commandBuildRoadSelected(Matrix2D *mapPoints, int numPoints05) +{ + Unit *selected = getSelectedUnit(); + if (!selected || !selected->config.canBuildRoad()) return; + + if (selected->isBuilding()) { + addFloatingText(Localization::instance().get("ui.unit.already_building", "Already busy building!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 200, 70)); + return; + } + + QString defaultType = m_roadConfigManager.defaultRoadType(); + const RoadTypeConfig *rCfg = m_roadConfigManager.getRoadType(defaultType); + QString roadName = rCfg ? rCfg->name : "Road"; + + // If unit is currently moving: toggle or queue build road action on arrival! + if (selected->state == UnitState::Moving) { + if (selected->hasQueuedActionType(UnitActionType::BuildRoad)) { + selected->removeQueuedActionsOfType(UnitActionType::BuildRoad); + addFloatingText(Localization::instance().get("ui.unit.action_cancelled", "%1 cancelled").arg("Build Road"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80)); + return; + } + + int destX = selected->targetTileX; + int destY = selected->targetTileY; + + if (hasRoadAt(destX, destY)) { + 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; + } + + if (!TerrainMovementManager::instance().isHexPassable( + destX, destY, 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; + } + + if (selected->queueAction(UnitAction(UnitActionType::BuildRoad, destX, destY, defaultType))) { + addFloatingText(Localization::instance().get("ui.unit.road_queued", "Build Road queued on arrival"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(80, 220, 120), 2.2f); + } else { + addFloatingText(Localization::instance().get("ui.unit.queue_full", "Action queue full!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); + } + return; + } + + int tx = selected->tileX; + int ty = selected->tileY; + + if (hasRoadAt(tx, ty)) { + 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; + } + + if (!TerrainMovementManager::instance().isHexPassable( + tx, ty, 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; + } + + // Duration is 2 ticks (configured via YAML) + int ticks = m_roadConfigManager.buildDurationTicks(); + float duration = (float)ticks * m_movementTickDuration; + selected->startBuilding(QString("road:%1").arg(defaultType), duration); + addFloatingText(Localization::instance().get("ui.unit.road_started", "Constructing %1... (%2 ticks)").arg(roadName).arg(ticks), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.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); + return; + } + if (hasImprovementAt(tx, ty)) { unit.cancelBuilding(); return; } // Place improvement now that it is completed! - QVector2D w = Pathfinder::hexToWorld(tx, ty); - float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); - TileImprovement imp; imp.improvementId = impId; imp.tileX = tx; @@ -923,6 +1100,31 @@ void GameManager::processUnitActionQueue(Unit &unit, Matrix2D *map } break; } + case UnitActionType::BuildRoad: { + int tx = unit.tileX; + int ty = unit.tileY; + 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)); + return; + } + 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)); + return; + } + + QString roadType = action.extraData.isEmpty() ? m_roadConfigManager.defaultRoadType() : action.extraData; + const RoadTypeConfig *rCfg = m_roadConfigManager.getRoadType(roadType); + QString roadName = rCfg ? rCfg->name : "Road"; + int ticks = m_roadConfigManager.buildDurationTicks(); + float duration = (float)ticks * m_movementTickDuration; + unit.startBuilding(QString("road:%1").arg(roadType), duration); + addFloatingText(Localization::instance().get("ui.unit.road_started", "Constructing %1... (%2 ticks)").arg(roadName).arg(ticks), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); + break; + } case UnitActionType::Attack: { Unit *target = nullptr; for (auto &other : m_units) { diff --git a/gamemanager.h b/gamemanager.h index 1be2bef..0302cce 100644 --- a/gamemanager.h +++ b/gamemanager.h @@ -12,13 +12,32 @@ #include "matrix2d.h" #include "terrain.h" #include "terrainyieldmanager.h" +#include "roadconfig.h" enum class ActionMode { None, Move, Attack, BuildCity, - BuildFarm + BuildFarm, + BuildRoad +}; + +struct TileRoad { + int tileX; + int tileY; + int playerId; + QString roadTypeId; // "dirt", "stone", "rail" + unsigned char connectionMask; // 6 bits: bit e (0..5) is 1 if connected to neighbor across edge e + QVector3D worldPos; + + bool hasConnection(int edge) const { + return (connectionMask & (1 << (edge % 6))) != 0; + } + void setConnection(int edge, bool connected) { + if (connected) connectionMask |= (1 << (edge % 6)); + else connectionMask &= ~(1 << (edge % 6)); + } }; struct TileImprovement { @@ -48,6 +67,8 @@ public: UnitConfigManager& unitConfigs() { return m_unitConfigManager; } BuildingConfigManager& buildingConfigs() { return m_buildingConfigManager; } const BuildingConfigManager& buildingConfigs() const { return m_buildingConfigManager; } + RoadConfigManager& roadConfigs() { return m_roadConfigManager; } + const RoadConfigManager& roadConfigs() const { return m_roadConfigManager; } const std::vector>& getUnits() const { return m_units; } const std::vector& getCities() const { return m_cities; } @@ -90,6 +111,7 @@ public: void commandAttack(Unit *target); void commandBuildCitySelected(Matrix2D *mapPoints, int numPoints05); void commandBuildFarmSelected(Matrix2D *mapPoints, int numPoints05); + void commandBuildRoadSelected(Matrix2D *mapPoints, int numPoints05); void completeUnitBuilding(Unit &unit, Matrix2D *mapPoints, int numPoints05); void processUnitActionQueue(Unit &unit, Matrix2D *mapPoints, int numPoints05); @@ -97,6 +119,14 @@ public: const TileImprovement* getImprovementAt(int tx, int ty) const; bool hasImprovementAt(int tx, int ty) const; + const std::map, TileRoad>& getTileRoads() const { return m_tileRoads; } + const TileRoad* getRoadAt(int tx, int ty) const; + bool hasRoadAt(int tx, int ty) const; + void addRoad(int tx, int ty, int playerId, const QString &roadTypeId = "dirt", Matrix2D *mapPoints = nullptr, int numPoints05 = 0); + void removeRoad(int tx, int ty); + void updateRoadConnections(int tx, int ty); + float getRoadSpeedMultiplierBetween(int fromX, int fromY, int toX, int toY) const; + float getTickDuration() const { return m_movementTickDuration; } void setTickDuration(float d) { m_movementTickDuration = std::max(1.0f, d); } float getTickTimer() const { return m_tickTimer; } @@ -123,10 +153,12 @@ private: PlayerManager m_playerManager; UnitConfigManager m_unitConfigManager; BuildingConfigManager m_buildingConfigManager; + RoadConfigManager m_roadConfigManager; std::vector> m_units; std::vector m_cities; std::vector m_floatingTexts; std::map, TileImprovement> m_tileImprovements; + std::map, TileRoad> m_tileRoads; int m_selectedUnitId; int m_activeCityViewId; diff --git a/images/actions/road.png b/images/actions/road.png new file mode 100644 index 0000000000000000000000000000000000000000..514e1d9927b16a331c412ed9fa7b54a3c66eeb68 GIT binary patch literal 962 zcmV;z13mnSP)YqQHho~TGNIU+#dc8~bK1_*%bTgaNRmjwU-000000000`E2R|m z8qUhJ*5B#{)Ob7CZ`nfj{ZTKV#j0Mw87F_Dq66{qg|F*sf4-?Ez!~eh{vG{{Qp(rp zXGHm>UO*>`I&iC?CU*${8rcV+=0+Vc{%f9-;nTqF?f^BOUr>e%+X(g(-~?qCr@sf> zApp3*IslISU;8}wdw_KSFkbcmICQYX8k*S#z062E8HvwF`vIob#*#)46 zOaHp8qeaF5ICrh{+ICq5z;Q(O=C;RaFM;nbV%7_x)dV=RcI0pa$Bmua4|8Fwf1E&2LxOd<)el&Eg z0Nc*Iz;aKn0YH%O0L;?=8~1wv6JTdnFAh!U#jL7U4 zz|Gq}tZKk500pj3J01yZX0R3Mt>dcq`lXD5ct^0rD zcn=sJ0LFj2h_iE<01$BqfMgV|I|R#+*MLI+Ufi|+H*WhI8UXtLy3uE7Hvte!9RPU; z8jzlWIsh-+`f(JeeGUr%{ol{Xv$UE3h^Gty{r{irEQSQ2Wn@M6VmT1?{AH=%@2K@r z0h3b&fbsv6nv6zx^Ap_ZiD8VAlTMsP`Wo z0LK4kp=TtY0MP0d0Qx^p&$E_J0CoSlP@Y3%0O&#^nfc}4_ zI*+IT(Es&9&g^mk82>sw&Ynx)R=z9QnfXOunfd2P!~1|u8vU; k{c)xM00000007$k1FsWS>F=P1_y7O^07*qoM6N<$f?%(~-~a#s literal 0 HcmV?d00001 diff --git a/qscenerenderer.cpp b/qscenerenderer.cpp index 2bf8ab8..7052aa1 100644 --- a/qscenerenderer.cpp +++ b/qscenerenderer.cpp @@ -347,6 +347,31 @@ void QSceneRenderer::drawMinimap(QPainter &painter) painter.setPen(QPen(QColor(120, 150, 180, 160), 1.0)); painter.drawRect(mapRect); + // Draw roads on minimap + for (const auto &pair : GameManager::instance().getTileRoads()) { + const TileRoad &road = pair.second; + QVector2D center = Pathfinder::hexToWorld(road.tileX, road.tileY); + QPointF mapP1 = worldToMinimap(center.x(), center.y(), mapRect); + if (!mapRect.contains(mapP1.toPoint())) continue; + + const RoadTypeConfig *rCfg = GameManager::instance().roadConfigs().getRoadType(road.roadTypeId); + QColor roadCol = rCfg ? rCfg->getColor() : QColor(194, 166, 117); + + painter.setPen(QPen(roadCol, 1.2f)); + if (road.connectionMask == 0) { + painter.setBrush(roadCol); + painter.drawEllipse(mapP1, 1.2, 1.2); + } else { + for (int e = 0; e < 6; ++e) { + if (road.hasConnection(e)) { + QVector2D edgeMid = center + getHexEdgeMidpoint(e); + QPointF mapP2 = worldToMinimap(edgeMid.x(), edgeMid.y(), mapRect); + painter.drawLine(mapP1, mapP2); + } + } + } + } + // Draw unit dots on minimap for (const auto &u : GameManager::instance().getUnits()) { if (u->isDead()) continue; @@ -725,9 +750,9 @@ void QSceneRenderer::drawUnitsOverlay(QPainter &painter, const QMatrix4x4 &vMatr : Localization::instance().get("ui.unit.tip_build_city", "Found City (B)"); } else if (act == "boerderij_bouwen" || act == "build_farm") { bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement); - isActive = unit->isBuilding() || isQueued; + isActive = (unit->isBuilding() && unit->buildActionType != "road") || isQueued; iconFile = "images/actions/farm.png"; - if (unit->isBuilding()) { + if (unit->isBuilding() && unit->buildActionType != "road") { int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f); tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1%").arg(pct); } else if (isQueued) { @@ -735,6 +760,18 @@ void QSceneRenderer::drawUnitsOverlay(QPainter &painter, const QMatrix4x4 &vMatr } else { tip = Localization::instance().get("ui.unit.tip_build_farm", "Build Farm (F)"); } + } else if (act == "build_road" || act == "weg_bouwen") { + bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildRoad); + isActive = (unit->isBuilding() && unit->buildActionType == "road") || isQueued; + iconFile = "images/actions/road.png"; + if (unit->isBuilding() && unit->buildActionType == "road") { + int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f); + tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1%").arg(pct); + } else if (isQueued) { + tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued on arrival - click to cancel)"); + } else { + tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R)"); + } } // Draw action bubble @@ -945,12 +982,12 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) } else if (unit->hasQueuedAction()) { QString qSummary = unit->getQueuedActionSummary(); QString queuedInfo = Localization::instance().get("ui.unit.status_queued", "Queued: %1").arg(qSummary); - if (unit->config.canBuildFarm() && !unit->config.infiniteCharges && unit->buildCharges > 0) { + if (unit->config.isBuilder() && !unit->config.infiniteCharges && unit->buildCharges > 0) { queuedInfo += QString(" | ") + Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges); } painter.setPen(QColor(100, 220, 140)); painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, queuedInfo); - } else if (unit->config.canBuildFarm() && !unit->config.infiniteCharges && unit->buildCharges > 0) { + } else if (unit->config.isBuilder() && !unit->config.infiniteCharges && unit->buildCharges > 0) { QString builderInfo = Localization::instance().get("ui.unit.civilian_unit", "Civilian unit") + QString(" | ") + Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges); painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, builderInfo); @@ -998,9 +1035,9 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) : Localization::instance().get("ui.unit.tip_build_city", "Found City (B)"); } else if (act == "boerderij_bouwen" || act == "build_farm") { bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement); - isActive = unit->isBuilding() || isQueued; + isActive = (unit->isBuilding() && unit->buildActionType != "road") || isQueued; iconFile = "images/actions/farm.png"; - if (unit->isBuilding()) { + if (unit->isBuilding() && unit->buildActionType != "road") { int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f); label = Localization::instance().get("ui.unit.btn_farm_building", "Building %1%").arg(pct); tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1%").arg(pct); @@ -1011,6 +1048,21 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) label = Localization::instance().get("ui.unit.btn_build_farm", "Farm [F]"); tip = Localization::instance().get("ui.unit.tip_build_farm", "Build Farm (F)"); } + } else if (act == "build_road" || act == "weg_bouwen") { + bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildRoad); + isActive = (unit->isBuilding() && unit->buildActionType == "road") || isQueued; + iconFile = "images/actions/road.png"; + if (unit->isBuilding() && unit->buildActionType == "road") { + int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f); + label = Localization::instance().get("ui.unit.btn_road_building", "Building %1%").arg(pct); + tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1%").arg(pct); + } else if (isQueued) { + label = Localization::instance().get("ui.unit.btn_road_queued", "Road (Queued)"); + tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued on arrival - click to cancel)"); + } else { + label = Localization::instance().get("ui.unit.btn_build_road", "Road [R]"); + tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R)"); + } } // Circular bubble button @@ -1082,6 +1134,118 @@ void QSceneRenderer::drawCityBorders(QPainter &painter, const QMatrix4x4 &vMatri } } +void QSceneRenderer::drawRoadsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix) +{ + painter.setRenderHint(QPainter::Antialiasing, true); + + const auto &roads = GameManager::instance().getTileRoads(); + if (roads.empty()) return; + + RoadConfigManager &roadConfigs = GameManager::instance().roadConfigs(); + float meanderAmp = roadConfigs.meanderAmplitude(); + int subdivisions = std::max(2, roadConfigs.meanderSubdivisions()); + + struct RoadBatch { + std::vector polylines; + std::vector centerJunctions; + std::vector isolatedCenters; + }; + std::unordered_map batches; + + for (const auto &pair : roads) { + const TileRoad &road = pair.second; + QVector2D center = Pathfinder::hexToWorld(road.tileX, road.tileY); + float centerZ = Unit::getTerrainHeight(center.x(), center.y(), m_point, m_numPoints05) + 0.028f; + bool cInFront = false; + QPointF cScreen = UnitRenderer::projectToScreen(QVector3D(center.x(), center.y(), centerZ), Projection, vMatrix, width(), height(), cInFront); + + RoadBatch &batch = batches[road.roadTypeId]; + + if (road.connectionMask == 0) { + if (cInFront) { + batch.isolatedCenters.push_back(cScreen); + } + continue; + } + + if (cInFront) { + batch.centerJunctions.push_back(cScreen); + } + + for (int e = 0; e < 6; ++e) { + if (!road.hasConnection(e)) continue; + + QVector2D endP = center + getHexEdgeMidpoint(e); + QVector2D dir = endP - center; + float len = dir.length(); + QVector2D perp = (len > 0.0001f) ? QVector2D(-dir.y() / len, dir.x() / len) : QVector2D(0, 0); + + // Deterministic pseudo-random seed for subtle, natural meandering + float seed = std::sin(float(road.tileX * 41 + road.tileY * 113 + e * 29)) * 43758.5453f; + float meanderSign = (seed - std::floor(seed) > 0.5f) ? 1.0f : -1.0f; + + QPolygonF poly; + bool allVisible = true; + + for (int i = 0; i <= subdivisions; ++i) { + float t = float(i) / float(subdivisions); + // Zero offset at both endpoints (t=0 and t=1) ensures perfect tile-to-tile alignment + float offset = std::sin(2.0f * 3.14159265f * t) * meanderAmp * meanderSign; + QVector2D p2d = center + dir * t + perp * offset; + float wz = Unit::getTerrainHeight(p2d.x(), p2d.y(), m_point, m_numPoints05) + 0.028f; + bool inFront = false; + QPointF sPos = UnitRenderer::projectToScreen(QVector3D(p2d.x(), p2d.y(), wz), Projection, vMatrix, width(), height(), inFront); + if (!inFront) { + allVisible = false; + break; + } + poly.append(sPos); + } + + if (allVisible && poly.size() >= 2) { + batch.polylines.push_back(poly); + } + } + } + + for (const auto &bPair : batches) { + const std::string &typeId = bPair.first; + const RoadBatch &batch = bPair.second; + const RoadTypeConfig *rCfg = roadConfigs.getRoadType(typeId); + + QColor casingCol = rCfg ? rCfg->getBorderColor() : QColor(92, 64, 40); + float casingWidth = rCfg ? rCfg->borderWidth : 4.5f; + QColor surfaceCol = rCfg ? rCfg->getColor() : QColor(194, 166, 117); + float surfaceWidth = rCfg ? rCfg->width : 2.8f; + + // Pass 1: Outer Casing / Under-layer + painter.setPen(QPen(casingCol, casingWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + for (const auto &poly : batch.polylines) { + painter.drawPolyline(poly); + } + + // Pass 2: Road Surface Core + painter.setPen(QPen(surfaceCol, surfaceWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + for (const auto &poly : batch.polylines) { + painter.drawPolyline(poly); + } + + // Pass 3: Center junction smooth cap + painter.setPen(Qt::NoPen); + painter.setBrush(surfaceCol); + for (const auto &cP : batch.centerJunctions) { + painter.drawEllipse(cP, surfaceWidth * 0.5f, surfaceWidth * 0.5f); + } + + // Isolated road markers (tile has road, no connected neighbor yet) + for (const auto &cP : batch.isolatedCenters) { + painter.setBrush(surfaceCol); + painter.setPen(QPen(casingCol, 1.5f)); + painter.drawEllipse(cP, surfaceWidth * 0.9f, surfaceWidth * 0.9f); + } + } +} + void QSceneRenderer::drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix) { painter.setRenderHint(QPainter::Antialiasing, true); @@ -1115,7 +1279,7 @@ void QSceneRenderer::drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 painter.drawRoundedRect(labelRect, 3, 3); painter.setFont(QFont("sans-serif", 6, QFont::Bold)); painter.setPen(QColor(255, 230, 130)); - painter.drawText(labelRect, Qt::AlignCenter, "Boerderij"); + painter.drawText(labelRect, Qt::AlignCenter, Localization::instance().get("ui.improvement.farm", "Farm")); } } @@ -1873,6 +2037,8 @@ void QSceneRenderer::mousePressEvent(QMouseEvent *event) GameManager::instance().commandBuildCitySelected(m_point, m_numPoints05); } else if (ab.actionId == "boerderij_bouwen" || ab.actionId == "build_farm") { GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05); + } else if (ab.actionId == "build_road" || ab.actionId == "weg_bouwen") { + GameManager::instance().commandBuildRoadSelected(m_point, m_numPoints05); } update(); return; @@ -2067,6 +2233,9 @@ void QSceneRenderer::keyPressEvent(QKeyEvent *e) else if (e->key() == Qt::Key_F && sel && sel->config.canBuildFarm()) { GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05); } + else if (e->key() == Qt::Key_R && sel && sel->config.canBuildRoad()) { + GameManager::instance().commandBuildRoadSelected(m_point, m_numPoints05); + } else if (e->key() == Qt::Key_Escape) { if (GameManager::instance().currentActionMode() != ActionMode::None) { GameManager::instance().setActionMode(ActionMode::None); @@ -2247,6 +2416,7 @@ void QSceneRenderer::paintGL() // 3. Render 2D UI & HUD Overlays via QPainter QPainter painter(this); drawCityBorders(painter, vMatrix); + drawRoadsOverlay(painter, vMatrix); drawImprovementsOverlay(painter, vMatrix); if (!cityView) { drawPathAndRange(painter, selected, vMatrix); diff --git a/qscenerenderer.h b/qscenerenderer.h index 4b01bbd..46a6f83 100644 --- a/qscenerenderer.h +++ b/qscenerenderer.h @@ -56,6 +56,7 @@ protected: void drawFloatingTexts(QPainter &painter, const QMatrix4x4 &vMatrix); // City & Territory Overlays void drawCityBorders(QPainter &painter, const QMatrix4x4 &vMatrix); + void drawRoadsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix); void drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix); void drawCityViewOverlay(QPainter &painter, const QMatrix4x4 &vMatrix); bool screenToHex(const QPoint &screenPos, const QMatrix4x4 &vMatrix, int &outX, int &outY); diff --git a/roadconfig.cpp b/roadconfig.cpp new file mode 100644 index 0000000..c2d224a --- /dev/null +++ b/roadconfig.cpp @@ -0,0 +1,94 @@ +#include "roadconfig.h" +#include "yamlparser.h" +#include +#include + +RoadConfigManager::RoadConfigManager() + : m_defaultRoadType("dirt"), + m_buildDurationTicks(2), + m_meanderAmplitude(0.045f), + m_meanderSubdivisions(5) +{ + // Provide built-in defaults + RoadTypeConfig dirt; + dirt.id = "dirt"; + dirt.name = "Dirt Road"; + dirt.speedMultiplier = 1.30f; + dirt.colorStr = "#c49a62"; + dirt.borderColorStr = "#6b4b23"; + dirt.width = 3.5f; + dirt.cost = 5; + m_types["dirt"] = dirt; + + RoadTypeConfig stone; + stone.id = "stone"; + stone.name = "Stone Road"; + stone.speedMultiplier = 1.60f; + stone.colorStr = "#9aa1a8"; + stone.borderColorStr = "#464b50"; + stone.width = 4.0f; + stone.cost = 15; + m_types["stone"] = stone; + + RoadTypeConfig rail; + rail.id = "rail"; + rail.name = "Railroad"; + rail.speedMultiplier = 2.50f; + rail.colorStr = "#3a3c40"; + rail.borderColorStr = "#181a1c"; + rail.width = 4.5f; + rail.cost = 30; + m_types["rail"] = rail; +} + +bool RoadConfigManager::loadConfigFile(const QString &filePath) +{ + YamlNode root = YamlParser::parseFile(filePath); + if (root.children.isEmpty()) { + qWarning() << "Failed to parse roads config YAML, using defaults:" << filePath; + return false; + } + + if (root.hasChild("default_road_type")) { + m_defaultRoadType = root["default_road_type"].asString("dirt"); + } + if (root.hasChild("build_duration_ticks")) { + m_buildDurationTicks = root["build_duration_ticks"].asInt(2); + } + if (root.hasChild("meander_amplitude")) { + m_meanderAmplitude = root["meander_amplitude"].asFloat(0.045f); + } + if (root.hasChild("meander_subdivisions")) { + m_meanderSubdivisions = root["meander_subdivisions"].asInt(5); + } + + if (root.hasChild("types")) { + const YamlNode &typesNode = root["types"]; + for (auto it = typesNode.children.begin(); it != typesNode.children.end(); ++it) { + RoadTypeConfig rCfg; + rCfg.id = it.value().hasChild("id") ? it.value()["id"].asString() : it.key(); + rCfg.name = it.value().hasChild("name") ? it.value()["name"].asString() : rCfg.id; + rCfg.speedMultiplier = it.value().hasChild("speed_multiplier") ? it.value()["speed_multiplier"].asFloat(1.30f) : 1.30f; + rCfg.colorStr = it.value().hasChild("color") ? it.value()["color"].asString("#c49a62") : "#c49a62"; + rCfg.borderColorStr = it.value().hasChild("border_color") ? it.value()["border_color"].asString("#6b4b23") : "#6b4b23"; + rCfg.width = it.value().hasChild("width") ? it.value()["width"].asFloat(3.5f) : 3.5f; + rCfg.cost = it.value().hasChild("cost") ? it.value()["cost"].asInt(5) : 5; + m_types[rCfg.id] = rCfg; + } + } + + return true; +} + +const RoadTypeConfig* RoadConfigManager::getRoadType(const QString &id) const +{ + auto it = m_types.find(id); + if (it != m_types.end()) { + return &it.value(); + } + auto defIt = m_types.find(m_defaultRoadType); + if (defIt != m_types.end()) { + return &defIt.value(); + } + return nullptr; +} diff --git a/roadconfig.h b/roadconfig.h new file mode 100644 index 0000000..2d3b516 --- /dev/null +++ b/roadconfig.h @@ -0,0 +1,42 @@ +#ifndef ROADCONFIG_H +#define ROADCONFIG_H + +#include +#include +#include + +struct RoadTypeConfig { + QString id = "dirt"; + QString name = "Dirt Road"; + float speedMultiplier = 1.30f; + QString colorStr = "#c49a62"; + QString borderColorStr = "#6b4b23"; + float width = 3.5f; + int cost = 5; + + QColor getColor() const { return QColor(colorStr); } + QColor getBorderColor() const { return QColor(borderColorStr); } +}; + +class RoadConfigManager { +public: + RoadConfigManager(); + + bool loadConfigFile(const QString &filePath); + const RoadTypeConfig* getRoadType(const QString &id) const; + const QMap& getAllRoadTypes() const { return m_types; } + + QString defaultRoadType() const { return m_defaultRoadType; } + int buildDurationTicks() const { return m_buildDurationTicks; } + float meanderAmplitude() const { return m_meanderAmplitude; } + int meanderSubdivisions() const { return m_meanderSubdivisions; } + +private: + QString m_defaultRoadType; + int m_buildDurationTicks; + float m_meanderAmplitude; + int m_meanderSubdivisions; + QMap m_types; +}; + +#endif // ROADCONFIG_H diff --git a/terrainmovementmanager.cpp b/terrainmovementmanager.cpp index c71a999..aa686c2 100644 --- a/terrainmovementmanager.cpp +++ b/terrainmovementmanager.cpp @@ -2,6 +2,7 @@ #include "unitconfig.h" #include "pathfinder.h" #include "yamlparser.h" +#include "gamemanager.h" #include #include #include @@ -300,15 +301,22 @@ float TerrainMovementManager::calculateStepCost( return -1.0f; } + float cost = baseCost; if (deltaH > 0.001f) { // Uphill penalty: going uphill increases movement cost float penalty = deltaH * m_config.uphillSlopeMultiplier; - return baseCost + penalty; + cost = baseCost + penalty; } else if (deltaH < -0.001f) { // Downhill bonus: going downhill decreases movement cost (negative penalty) float bonus = (-deltaH) * m_config.downhillSlopeBonusMultiplier; - return std::max(m_config.minStepCost, baseCost - bonus); + cost = std::max(m_config.minStepCost, baseCost - bonus); } - return baseCost; + // Road speed bonus: if traveling between connected road tiles, movement speed increases! + float roadMult = GameManager::instance().getRoadSpeedMultiplierBetween(fromX, fromY, toX, toY); + if (roadMult > 1.0f) { + cost /= roadMult; + } + + return std::max(0.1f, cost); } diff --git a/tiletest.pro b/tiletest.pro index fa60569..a898e55 100644 --- a/tiletest.pro +++ b/tiletest.pro @@ -31,7 +31,8 @@ SOURCES += main.cpp \ gamemanager.cpp \ unitrenderer.cpp \ localization.cpp \ - terrainmovementmanager.cpp + terrainmovementmanager.cpp \ + roadconfig.cpp HEADERS += mainwindow.h \ qscenerenderer.h \ @@ -52,7 +53,8 @@ HEADERS += mainwindow.h \ gamemanager.h \ unitrenderer.h \ localization.h \ - terrainmovementmanager.h + terrainmovementmanager.h \ + roadconfig.h FORMS += mainwindow.ui diff --git a/unit.cpp b/unit.cpp index 934fdc0..a2b9690 100644 --- a/unit.cpp +++ b/unit.cpp @@ -92,6 +92,8 @@ QString Unit::getQueuedActionSummary() const return "Build Farm"; case UnitActionType::BuildCity: return "Found City"; + case UnitActionType::BuildRoad: + return "Build Road"; case UnitActionType::Attack: return "Attack"; default: diff --git a/unit.h b/unit.h index c365d22..5fce932 100644 --- a/unit.h +++ b/unit.h @@ -22,6 +22,7 @@ enum class UnitActionType { Move, BuildImprovement, BuildCity, + BuildRoad, Attack }; diff --git a/unitconfig.h b/unitconfig.h index e8c29bd..4cfdac5 100644 --- a/unitconfig.h +++ b/unitconfig.h @@ -63,6 +63,8 @@ struct UnitConfig { bool canAttack() const { return actions.contains("aanvallen") || isMilitary(); } bool canBuildCity() const { return actions.contains("steden_bouwen") || actions.contains("build_city"); } bool canBuildFarm() const { return actions.contains("boerderij_bouwen") || actions.contains("build_farm"); } + bool canBuildRoad() const { return actions.contains("build_road") || actions.contains("weg_bouwen"); } + bool isBuilder() const { return canBuildFarm() || canBuildRoad(); } float getActionDuration(const QString &actionName, float fallback = 10.0f) const { if (actionSpeeds.contains(actionName)) {