Implement builder road construction with hex-edge auto-connection, winding rendering, and speed bonus

This commit is contained in:
bart
2026-11-08 21:52:34 +01:00
parent 4b237872eb
commit dfefb2d7fb
15 changed files with 620 additions and 16 deletions
+31
View File
@@ -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
+15
View File
@@ -88,12 +88,27 @@ ui:
btn_build_farm: "Farm [F]" btn_build_farm: "Farm [F]"
btn_farm_queued: "Farm (Queued)" btn_farm_queued: "Farm (Queued)"
btn_farm_building: "Building %1%" 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_move: "Move (M)"
tip_attack: "Attack (A) [Range: %1]" tip_attack: "Attack (A) [Range: %1]"
tip_build_city: "Found City (B)" tip_build_city: "Found City (B)"
tip_build_farm: "Build Farm (F)" tip_build_farm: "Build Farm (F)"
tip_farm_building: "Busy building... %1%" tip_farm_building: "Busy building... %1%"
tip_farm_queued: "Build Farm (Queued on arrival - click to cancel)" 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: city:
center: "City Center" center: "City Center"
+2
View File
@@ -12,5 +12,7 @@ icon: images/units/builder.png
actions: actions:
- verplaatsen - verplaatsen
- boerderij_bouwen - boerderij_bouwen
- build_road
action_speeds: action_speeds:
boerderij_bouwen: 10.0 # Duur in seconden die deze builder unit nodig heeft voor de actie boerderij_bouwen boerderij_bouwen: 10.0 # Duur in seconden die deze builder unit nodig heeft voor de actie boerderij_bouwen
build_road: 2 # Duur in ticks
+205 -3
View File
@@ -54,6 +54,10 @@ void GameManager::init(const QString &baseDir, Matrix2D<TerrainPoint> *mapPoints
QString buildingsConfigPath = baseDir + "/config/buildings.yaml"; QString buildingsConfigPath = baseDir + "/config/buildings.yaml";
m_buildingConfigManager.loadConfigFile(buildingsConfigPath); m_buildingConfigManager.loadConfigFile(buildingsConfigPath);
// Load road YAML configurations
QString roadsConfigPath = baseDir + "/config/roads.yaml";
m_roadConfigManager.loadConfigFile(roadsConfigPath);
const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig();
// Initialize player national resources from configuration // 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(); 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<TerrainPoint> *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<TerrainPoint> *mapPoints, int numPoints05) void GameManager::commandBuildFarmSelected(Matrix2D<TerrainPoint> *mapPoints, int numPoints05)
{ {
Unit *selected = getSelectedUnit(); Unit *selected = getSelectedUnit();
@@ -754,21 +841,111 @@ void GameManager::commandBuildFarmSelected(Matrix2D<TerrainPoint> *mapPoints, in
selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f);
} }
void GameManager::commandBuildRoadSelected(Matrix2D<TerrainPoint> *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<TerrainPoint> *mapPoints, int numPoints05) void GameManager::completeUnitBuilding(Unit &unit, Matrix2D<TerrainPoint> *mapPoints, int numPoints05)
{ {
int tx = unit.tileX; int tx = unit.tileX;
int ty = unit.tileY; int ty = unit.tileY;
QString impId = unit.buildingImprovementId.isEmpty() ? "farm" : unit.buildingImprovementId; 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)) { if (hasImprovementAt(tx, ty)) {
unit.cancelBuilding(); unit.cancelBuilding();
return; return;
} }
// Place improvement now that it is completed! // 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; TileImprovement imp;
imp.improvementId = impId; imp.improvementId = impId;
imp.tileX = tx; imp.tileX = tx;
@@ -923,6 +1100,31 @@ void GameManager::processUnitActionQueue(Unit &unit, Matrix2D<TerrainPoint> *map
} }
break; 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: { case UnitActionType::Attack: {
Unit *target = nullptr; Unit *target = nullptr;
for (auto &other : m_units) { for (auto &other : m_units) {
+33 -1
View File
@@ -12,13 +12,32 @@
#include "matrix2d.h" #include "matrix2d.h"
#include "terrain.h" #include "terrain.h"
#include "terrainyieldmanager.h" #include "terrainyieldmanager.h"
#include "roadconfig.h"
enum class ActionMode { enum class ActionMode {
None, None,
Move, Move,
Attack, Attack,
BuildCity, 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 { struct TileImprovement {
@@ -48,6 +67,8 @@ public:
UnitConfigManager& unitConfigs() { return m_unitConfigManager; } UnitConfigManager& unitConfigs() { return m_unitConfigManager; }
BuildingConfigManager& buildingConfigs() { return m_buildingConfigManager; } BuildingConfigManager& buildingConfigs() { return m_buildingConfigManager; }
const BuildingConfigManager& buildingConfigs() const { 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<std::shared_ptr<Unit>>& getUnits() const { return m_units; } const std::vector<std::shared_ptr<Unit>>& getUnits() const { return m_units; }
const std::vector<City>& getCities() const { return m_cities; } const std::vector<City>& getCities() const { return m_cities; }
@@ -90,6 +111,7 @@ public:
void commandAttack(Unit *target); void commandAttack(Unit *target);
void commandBuildCitySelected(Matrix2D<TerrainPoint> *mapPoints, int numPoints05); void commandBuildCitySelected(Matrix2D<TerrainPoint> *mapPoints, int numPoints05);
void commandBuildFarmSelected(Matrix2D<TerrainPoint> *mapPoints, int numPoints05); void commandBuildFarmSelected(Matrix2D<TerrainPoint> *mapPoints, int numPoints05);
void commandBuildRoadSelected(Matrix2D<TerrainPoint> *mapPoints, int numPoints05);
void completeUnitBuilding(Unit &unit, Matrix2D<TerrainPoint> *mapPoints, int numPoints05); void completeUnitBuilding(Unit &unit, Matrix2D<TerrainPoint> *mapPoints, int numPoints05);
void processUnitActionQueue(Unit &unit, Matrix2D<TerrainPoint> *mapPoints, int numPoints05); void processUnitActionQueue(Unit &unit, Matrix2D<TerrainPoint> *mapPoints, int numPoints05);
@@ -97,6 +119,14 @@ public:
const TileImprovement* getImprovementAt(int tx, int ty) const; const TileImprovement* getImprovementAt(int tx, int ty) const;
bool hasImprovementAt(int tx, int ty) const; bool hasImprovementAt(int tx, int ty) const;
const std::map<std::pair<int, int>, 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<TerrainPoint> *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; } float getTickDuration() const { return m_movementTickDuration; }
void setTickDuration(float d) { m_movementTickDuration = std::max(1.0f, d); } void setTickDuration(float d) { m_movementTickDuration = std::max(1.0f, d); }
float getTickTimer() const { return m_tickTimer; } float getTickTimer() const { return m_tickTimer; }
@@ -123,10 +153,12 @@ private:
PlayerManager m_playerManager; PlayerManager m_playerManager;
UnitConfigManager m_unitConfigManager; UnitConfigManager m_unitConfigManager;
BuildingConfigManager m_buildingConfigManager; BuildingConfigManager m_buildingConfigManager;
RoadConfigManager m_roadConfigManager;
std::vector<std::shared_ptr<Unit>> m_units; std::vector<std::shared_ptr<Unit>> m_units;
std::vector<City> m_cities; std::vector<City> m_cities;
std::vector<FloatingText> m_floatingTexts; std::vector<FloatingText> m_floatingTexts;
std::map<std::pair<int, int>, TileImprovement> m_tileImprovements; std::map<std::pair<int, int>, TileImprovement> m_tileImprovements;
std::map<std::pair<int, int>, TileRoad> m_tileRoads;
int m_selectedUnitId; int m_selectedUnitId;
int m_activeCityViewId; int m_activeCityViewId;
Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

+177 -7
View File
@@ -347,6 +347,31 @@ void QSceneRenderer::drawMinimap(QPainter &painter)
painter.setPen(QPen(QColor(120, 150, 180, 160), 1.0)); painter.setPen(QPen(QColor(120, 150, 180, 160), 1.0));
painter.drawRect(mapRect); 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 // Draw unit dots on minimap
for (const auto &u : GameManager::instance().getUnits()) { for (const auto &u : GameManager::instance().getUnits()) {
if (u->isDead()) continue; 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)"); : Localization::instance().get("ui.unit.tip_build_city", "Found City (B)");
} else if (act == "boerderij_bouwen" || act == "build_farm") { } else if (act == "boerderij_bouwen" || act == "build_farm") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement); bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement);
isActive = unit->isBuilding() || isQueued; isActive = (unit->isBuilding() && unit->buildActionType != "road") || isQueued;
iconFile = "images/actions/farm.png"; 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); 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); tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1%").arg(pct);
} else if (isQueued) { } else if (isQueued) {
@@ -735,6 +760,18 @@ void QSceneRenderer::drawUnitsOverlay(QPainter &painter, const QMatrix4x4 &vMatr
} else { } else {
tip = Localization::instance().get("ui.unit.tip_build_farm", "Build 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);
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 // Draw action bubble
@@ -945,12 +982,12 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit)
} else if (unit->hasQueuedAction()) { } else if (unit->hasQueuedAction()) {
QString qSummary = unit->getQueuedActionSummary(); QString qSummary = unit->getQueuedActionSummary();
QString queuedInfo = Localization::instance().get("ui.unit.status_queued", "Queued: %1").arg(qSummary); 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); queuedInfo += QString(" | ") + Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges);
} }
painter.setPen(QColor(100, 220, 140)); painter.setPen(QColor(100, 220, 140));
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, queuedInfo); 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(" | ") + QString builderInfo = Localization::instance().get("ui.unit.civilian_unit", "Civilian unit") + QString(" | ") +
Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges); 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); 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)"); : Localization::instance().get("ui.unit.tip_build_city", "Found City (B)");
} else if (act == "boerderij_bouwen" || act == "build_farm") { } else if (act == "boerderij_bouwen" || act == "build_farm") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement); bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement);
isActive = unit->isBuilding() || isQueued; isActive = (unit->isBuilding() && unit->buildActionType != "road") || isQueued;
iconFile = "images/actions/farm.png"; 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); int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
label = Localization::instance().get("ui.unit.btn_farm_building", "Building %1%").arg(pct); 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); 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]"); label = Localization::instance().get("ui.unit.btn_build_farm", "Farm [F]");
tip = Localization::instance().get("ui.unit.tip_build_farm", "Build 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 // 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<QPolygonF> polylines;
std::vector<QPointF> centerJunctions;
std::vector<QPointF> isolatedCenters;
};
std::unordered_map<std::string, RoadBatch> 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) void QSceneRenderer::drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix)
{ {
painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::Antialiasing, true);
@@ -1115,7 +1279,7 @@ void QSceneRenderer::drawImprovementsOverlay(QPainter &painter, const QMatrix4x4
painter.drawRoundedRect(labelRect, 3, 3); painter.drawRoundedRect(labelRect, 3, 3);
painter.setFont(QFont("sans-serif", 6, QFont::Bold)); painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(QColor(255, 230, 130)); 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); GameManager::instance().commandBuildCitySelected(m_point, m_numPoints05);
} else if (ab.actionId == "boerderij_bouwen" || ab.actionId == "build_farm") { } else if (ab.actionId == "boerderij_bouwen" || ab.actionId == "build_farm") {
GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05); 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(); update();
return; return;
@@ -2067,6 +2233,9 @@ void QSceneRenderer::keyPressEvent(QKeyEvent *e)
else if (e->key() == Qt::Key_F && sel && sel->config.canBuildFarm()) { else if (e->key() == Qt::Key_F && sel && sel->config.canBuildFarm()) {
GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05); 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) { else if (e->key() == Qt::Key_Escape) {
if (GameManager::instance().currentActionMode() != ActionMode::None) { if (GameManager::instance().currentActionMode() != ActionMode::None) {
GameManager::instance().setActionMode(ActionMode::None); GameManager::instance().setActionMode(ActionMode::None);
@@ -2247,6 +2416,7 @@ void QSceneRenderer::paintGL()
// 3. Render 2D UI & HUD Overlays via QPainter // 3. Render 2D UI & HUD Overlays via QPainter
QPainter painter(this); QPainter painter(this);
drawCityBorders(painter, vMatrix); drawCityBorders(painter, vMatrix);
drawRoadsOverlay(painter, vMatrix);
drawImprovementsOverlay(painter, vMatrix); drawImprovementsOverlay(painter, vMatrix);
if (!cityView) { if (!cityView) {
drawPathAndRange(painter, selected, vMatrix); drawPathAndRange(painter, selected, vMatrix);
+1
View File
@@ -56,6 +56,7 @@ protected:
void drawFloatingTexts(QPainter &painter, const QMatrix4x4 &vMatrix); void drawFloatingTexts(QPainter &painter, const QMatrix4x4 &vMatrix);
// City & Territory Overlays // City & Territory Overlays
void drawCityBorders(QPainter &painter, const QMatrix4x4 &vMatrix); void drawCityBorders(QPainter &painter, const QMatrix4x4 &vMatrix);
void drawRoadsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix);
void drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix); void drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix);
void drawCityViewOverlay(QPainter &painter, const QMatrix4x4 &vMatrix); void drawCityViewOverlay(QPainter &painter, const QMatrix4x4 &vMatrix);
bool screenToHex(const QPoint &screenPos, const QMatrix4x4 &vMatrix, int &outX, int &outY); bool screenToHex(const QPoint &screenPos, const QMatrix4x4 &vMatrix, int &outX, int &outY);
+94
View File
@@ -0,0 +1,94 @@
#include "roadconfig.h"
#include "yamlparser.h"
#include <QFileInfo>
#include <QDebug>
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;
}
+42
View File
@@ -0,0 +1,42 @@
#ifndef ROADCONFIG_H
#define ROADCONFIG_H
#include <QString>
#include <QMap>
#include <QColor>
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<QString, RoadTypeConfig>& 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<QString, RoadTypeConfig> m_types;
};
#endif // ROADCONFIG_H
+11 -3
View File
@@ -2,6 +2,7 @@
#include "unitconfig.h" #include "unitconfig.h"
#include "pathfinder.h" #include "pathfinder.h"
#include "yamlparser.h" #include "yamlparser.h"
#include "gamemanager.h"
#include <QDebug> #include <QDebug>
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
@@ -300,15 +301,22 @@ float TerrainMovementManager::calculateStepCost(
return -1.0f; return -1.0f;
} }
float cost = baseCost;
if (deltaH > 0.001f) { if (deltaH > 0.001f) {
// Uphill penalty: going uphill increases movement cost // Uphill penalty: going uphill increases movement cost
float penalty = deltaH * m_config.uphillSlopeMultiplier; float penalty = deltaH * m_config.uphillSlopeMultiplier;
return baseCost + penalty; cost = baseCost + penalty;
} else if (deltaH < -0.001f) { } else if (deltaH < -0.001f) {
// Downhill bonus: going downhill decreases movement cost (negative penalty) // Downhill bonus: going downhill decreases movement cost (negative penalty)
float bonus = (-deltaH) * m_config.downhillSlopeBonusMultiplier; 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);
} }
+4 -2
View File
@@ -31,7 +31,8 @@ SOURCES += main.cpp \
gamemanager.cpp \ gamemanager.cpp \
unitrenderer.cpp \ unitrenderer.cpp \
localization.cpp \ localization.cpp \
terrainmovementmanager.cpp terrainmovementmanager.cpp \
roadconfig.cpp
HEADERS += mainwindow.h \ HEADERS += mainwindow.h \
qscenerenderer.h \ qscenerenderer.h \
@@ -52,7 +53,8 @@ HEADERS += mainwindow.h \
gamemanager.h \ gamemanager.h \
unitrenderer.h \ unitrenderer.h \
localization.h \ localization.h \
terrainmovementmanager.h terrainmovementmanager.h \
roadconfig.h
FORMS += mainwindow.ui FORMS += mainwindow.ui
+2
View File
@@ -92,6 +92,8 @@ QString Unit::getQueuedActionSummary() const
return "Build Farm"; return "Build Farm";
case UnitActionType::BuildCity: case UnitActionType::BuildCity:
return "Found City"; return "Found City";
case UnitActionType::BuildRoad:
return "Build Road";
case UnitActionType::Attack: case UnitActionType::Attack:
return "Attack"; return "Attack";
default: default:
+1
View File
@@ -22,6 +22,7 @@ enum class UnitActionType {
Move, Move,
BuildImprovement, BuildImprovement,
BuildCity, BuildCity,
BuildRoad,
Attack Attack
}; };
+2
View File
@@ -63,6 +63,8 @@ struct UnitConfig {
bool canAttack() const { return actions.contains("aanvallen") || isMilitary(); } bool canAttack() const { return actions.contains("aanvallen") || isMilitary(); }
bool canBuildCity() const { return actions.contains("steden_bouwen") || actions.contains("build_city"); } 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 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 { float getActionDuration(const QString &actionName, float fallback = 10.0f) const {
if (actionSpeeds.contains(actionName)) { if (actionSpeeds.contains(actionName)) {