diff --git a/actionneededmanager.cpp b/actionneededmanager.cpp index 6df1eb2..a8e91cf 100644 --- a/actionneededmanager.cpp +++ b/actionneededmanager.cpp @@ -127,6 +127,7 @@ void ActionNeededManager::update(float deltaTime, int activePlayerId, bool isIdle = (u->state == UnitState::Idle) && (u->path.empty() || u->pathIndex >= u->path.size()) + && !u->currentAction.isValid() && !u->hasQueuedAction() && !u->hasRoadRoute() && !u->isBuilding(); diff --git a/config/game.yaml b/config/game.yaml index c225933..78d17d7 100644 --- a/config/game.yaml +++ b/config/game.yaml @@ -2,7 +2,7 @@ movement_tick_duration: 30.0 # Action queue configuration action_queue: - max_size: 5 + max_size: 15 auto_queue_move_when_busy: true allow_action_queueing: true diff --git a/config/strings_en.yaml b/config/strings_en.yaml index 4360840..f37c7e0 100644 --- a/config/strings_en.yaml +++ b/config/strings_en.yaml @@ -49,25 +49,30 @@ ui: queue_cleared: "Queued actions cancelled" queue_full: "Action queue full!" action_queued: "Action queued: %1" + action_queued_count: "%1 added to queue (#%2)" + action_scheduled_after_current: "%1 scheduled after current action" action_cancelled: "%1 cancelled" - farm_queued: "Build Farm queued on arrival" + farm_queued: "Build Farm queued" farm_started: "Constructing Farm..." farm_completed: "Farm completed!" farm_completed_consumed: "Farm completed! (Builder consumed)" farm_completed_charges: "Farm completed! (%1 %2 remaining)" - road_queued: "Build Road queued on arrival" + moving_to_build_farm: "Moving to build Farm at (%1, %2)..." + moving_to_build_city: "Moving to found City at (%1, %2)..." + moving_to_attack: "Moving into attack range..." + road_queued: "Build Road queued" road_started: "Constructing %1... (%2 ticks)" road_completed: "%1 completed!" road_already_here: "Tile already has a road!" cannot_build_road_water: "Roads cannot be built on water!" - city_queued: "Found City queued on arrival" + city_queued: "Found City queued" city_founded: "%1 founded!" already_building: "Already busy building!" busy: "Unit is busy!" cannot_act_busy: "Unit cannot perform action while busy!" tile_occupied: "Tile already has an improvement!" cannot_build_here: "Cannot build here! Allowed on: %1" - cannot_build_destination: "Cannot build on destination terrain! Allowed on: %1" + cannot_build_destination: "Cannot build on target terrain! Allowed on: %1" no_build_charges: "No build charges remaining!" charge_singular: "charge" charge_plural: "charges" @@ -76,6 +81,7 @@ ui: no_path: "No reachable path!" tile_purchased: "Tile purchased! (-%1 Culture)" out_of_range: "Out of range!" + no_enemy_on_tile: "Select a tile with an enemy unit!" defeated: "Defeated!" city_already_here: "A city already exists here!" unsuitable_terrain: "Unsuitable terrain!" @@ -86,35 +92,45 @@ ui: combat_format: "Attack: %1 | Range: %2 hex" building_pct: "Building... %1%" charges_format: "Charges: %1" - status_queued: "Queued: %1" + status_queued: "Queue (%1): %2" btn_move: "Move [M]" btn_attack: "Attack [A]" btn_build_city: "Settle [B]" btn_build_farm: "Farm [F]" - btn_farm_queued: "Farm (Queued)" + btn_farm_queued: "Farm [F]" btn_farm_building: "Building %1%" btn_build_road: "Road [R]" - btn_road_queued: "Road (Queued)" + btn_road_queued: "Road [R]" 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) - Click target tile to build route" - 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!" + tip_move: "Move (M) - Click target tile (Shift+Click to queue)" + tip_attack: "Attack (A) [Range: %1] - Click enemy (Shift+Click to queue)" + tip_build_city: "Found City (B) - Click target tile (Shift+Click to queue)" + tip_build_farm: "Build Farm (F) - Click target tile (Shift+Click to queue)" + tip_farm_building: "Busy building... %1% (Click to plan next target)" + tip_farm_queued: "Build Farm (Queued - Click to plan target)" + tip_build_road: "Build Road (R) - Click target tile (Shift+Click to queue)" + tip_road_building: "Busy building road... %1% (Click to plan next target)" + tip_road_queued: "Build Road (Queued - Click to plan target)" road_route_started: "Building road route to (%1, %2)..." road_route_completed: "Road route completed!" road_route_cancelled: "Road route cancelled" status_road_route: "Road Route: %1/%2" btn_road_route: "Route (%1/%2)" + preview_move: "Move (%1 hexes)" + preview_farm: "Build Farm" + preview_farm_move: "Build Farm (%1 hexes)" + preview_city: "Found City" + preview_city_move: "Found City (%1 hexes)" + preview_road: "Road Route (%1 hexes)" + preview_road_here: "Build Road" + preview_attack: "Attack" + preview_attack_move: "Attack (%1 hexes)" + preview_queue_prefix: "+Queue: %1" + queue_badge_move: "#%1 Move" + queue_badge_farm: "#%1 Farm" + queue_badge_city: "#%1 Settle" + queue_badge_road: "#%1 Road" + queue_badge_attack: "#%1 Attack" improvement: farm: "Farm" diff --git a/gamemanager.cpp b/gamemanager.cpp index f489730..6976222 100644 --- a/gamemanager.cpp +++ b/gamemanager.cpp @@ -17,7 +17,7 @@ GameManager::GameManager() : m_selectedUnitId(-1), m_activeCityViewId(-1), m_isTechTreeOpen(false), m_actionMode(ActionMode::None), m_movementTickDuration(30.0f), m_tickTimer(0.0f), m_simSpeedMultiplier(1.0f), - m_maxActionQueueSize(5), m_autoQueueMoveWhenBusy(true), m_allowActionQueueing(true), + m_maxActionQueueSize(15), m_autoQueueMoveWhenBusy(true), m_allowActionQueueing(true), m_nextUnitId(0), m_nextCityId(0), m_initialized(false) { @@ -41,7 +41,7 @@ void GameManager::init(const QString &baseDir, Matrix2D *mapPoints } if (gameRoot.hasChild("action_queue")) { const YamlNode &aqNode = gameRoot["action_queue"]; - m_maxActionQueueSize = aqNode["max_size"].asInt(5); + m_maxActionQueueSize = aqNode["max_size"].asInt(15); m_autoQueueMoveWhenBusy = aqNode["auto_queue_move_when_busy"].asBool(true); m_allowActionQueueing = aqNode["allow_action_queueing"].asBool(true); } @@ -155,7 +155,10 @@ void GameManager::update(float deltaTime, Matrix2D *mapPoints, int if (!u->isDead() && u->state == UnitState::Idle) { if (u->hasRoadRoute()) { advanceRoadRoute(*u, mapPoints, numPoints05); - } else if (u->hasQueuedAction()) { + } else if (u->currentAction.isValid()) { + continueCurrentUnitAction(*u, mapPoints, numPoints05); + } + if (!u->isDead() && u->state == UnitState::Idle && !u->currentAction.isValid() && u->hasQueuedAction()) { processUnitActionQueue(*u, mapPoints, numPoints05); } } @@ -1026,16 +1029,394 @@ void GameManager::completeCityProject(City &city, Matrix2D *mapPoi } } -void GameManager::commandMoveSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05) +void GameManager::getPlannedOriginTile(const Unit &unit, bool appendWithShift, int &outX, int &outY) const { - Unit *selected = getSelectedUnit(); - if (!selected) return; + if (appendWithShift && !unit.actionQueue.empty()) { + outX = unit.actionQueue.back().targetX; + outY = unit.actionQueue.back().targetY; + return; + } + if (unit.currentAction.isValid() && unit.currentAction.targetX >= 0 && unit.currentAction.targetY >= 0) { + outX = unit.currentAction.targetX; + outY = unit.currentAction.targetY; + return; + } + if (unit.hasRoadRoute()) { + outX = unit.roadRoute.back().first; + outY = unit.roadRoute.back().second; + return; + } + if (unit.state == UnitState::Moving && !unit.path.empty()) { + outX = unit.path.back().first; + outY = unit.path.back().second; + return; + } + outX = unit.tileX; + outY = unit.tileY; +} - if (targetX == selected->tileX && targetY == selected->tileY) { +bool GameManager::scheduleOrStartUnitAction(Unit &unit, const UnitAction &action, bool appendWithShift, + Matrix2D *mapPoints, int numPoints05) +{ + if (!appendWithShift) { + // Without Shift: cancel everything queued after the current action + // (the current action itself is executed completely, and the new action is scheduled right after it) + unit.clearActionQueue(); + } + + if (!unit.hasActiveAction()) { + return startUnitAction(unit, action, mapPoints, numPoints05); + } + + if (!m_allowActionQueueing) { + addFloatingText(Localization::instance().get("ui.unit.busy", "Unit is busy!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); + return false; + } + + if (!unit.queueAction(action)) { + addFloatingText(Localization::instance().get("ui.unit.queue_full", "Action queue full!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); + return false; + } + + QString summary = unit.getActionSummary(action); + QVector2D w = Pathfinder::hexToWorld(action.targetX, action.targetY); + float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); + if (appendWithShift) { + int pos = (int)unit.actionQueue.size(); + QString msg = Localization::instance().get("ui.unit.action_queued_count", "%1 added to queue (#%2)") + .arg(summary).arg(pos); + addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.9f), QColor(100, 220, 255), 2.2f); + } else { + QString msg = Localization::instance().get("ui.unit.action_scheduled_after_current", "%1 scheduled after current action") + .arg(summary); + addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.9f), QColor(255, 220, 90), 2.2f); + } + return true; +} + +bool GameManager::startUnitAction(Unit &unit, const UnitAction &action, + Matrix2D *mapPoints, int numPoints05) +{ + if (unit.isDead() || !action.isValid()) { + return false; + } + + unit.currentAction = action; + + switch (action.type) { + case UnitActionType::Move: { + int tx = action.targetX; + int ty = action.targetY; + if (tx == unit.tileX && ty == unit.tileY) { + unit.currentAction = UnitAction(); + return false; + } + if (!TerrainMovementManager::instance().isHexPassable( + tx, ty, unit.config.movementDomain, mapPoints, numPoints05, &unit.config)) { + if (unit.isNaval()) { + addFloatingText(Localization::instance().get("ui.unit.naval_only_water", "Ships can only sail on water!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + } else { + addFloatingText(Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + } + unit.currentAction = UnitAction(); + return false; + } + + std::vector> path = Pathfinder::findPath( + unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, + unit.config.movementDomain, &unit.config + ); + + if (path.size() > 1) { + unit.setPath(path); + addFloatingText(Localization::instance().get("ui.unit.moving", "Moving"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 230, 80)); + return true; + } else { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + unit.currentAction = UnitAction(); + return false; + } + } + case UnitActionType::BuildImprovement: + case UnitActionType::BuildCity: + case UnitActionType::Attack: { + continueCurrentUnitAction(unit, mapPoints, numPoints05); + return unit.currentAction.isValid() || unit.state != UnitState::Idle || unit.isDead(); + } + case UnitActionType::BuildRoad: { + int tx = action.targetX; + int ty = action.targetY; + if (!TerrainMovementManager::instance().isHexPassable( + tx, ty, MovementDomain::Land, mapPoints, numPoints05, &unit.config)) { + addFloatingText(Localization::instance().get("ui.unit.cannot_build_road_water", "Roads cannot be built on water!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); + unit.currentAction = UnitAction(); + return false; + } + + std::vector> route; + if (unit.tileX == tx && unit.tileY == ty) { + if (hasRoadAt(tx, ty)) { + addFloatingText(Localization::instance().get("ui.unit.road_already_here", "Tile already has a road!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); + unit.currentAction = UnitAction(); + return false; + } + route.push_back({tx, ty}); + } else { + route = Pathfinder::findPath( + unit.tileX, unit.tileY, tx, ty, + mapPoints, numPoints05, MovementDomain::Land, &unit.config + ); + } + + if (route.empty()) { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); + unit.currentAction = UnitAction(); + return false; + } + + unit.startRoadRoute(route); + if (route.size() > 1) { + addFloatingText(Localization::instance().get("ui.unit.road_route_started", "Building road route to (%1, %2)...").arg(tx).arg(ty), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.2f); + } + advanceRoadRoute(unit, mapPoints, numPoints05); + return true; + } + default: + unit.currentAction = UnitAction(); + return false; + } +} + +void GameManager::continueCurrentUnitAction(Unit &unit, Matrix2D *mapPoints, int numPoints05) +{ + if (unit.isDead() || unit.state != UnitState::Idle || !unit.currentAction.isValid()) { return; } - // Check if target tile is passable for this unit's movement domain + UnitAction action = unit.currentAction; + + switch (action.type) { + case UnitActionType::Move: { + // Movement finished + unit.currentAction = UnitAction(); + break; + } + case UnitActionType::BuildImprovement: { + if (!unit.config.canBuildFarm()) { + unit.currentAction = UnitAction(); + return; + } + if (!unit.config.infiniteCharges && unit.buildCharges <= 0) { + addFloatingText(Localization::instance().get("ui.unit.no_build_charges", "No build charges remaining!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); + unit.currentAction = UnitAction(); + return; + } + + int tx = action.targetX; + int ty = action.targetY; + QString impId = action.extraData.isEmpty() ? "farm" : action.extraData; + + if (hasImprovementAt(tx, ty)) { + addFloatingText(Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); + unit.currentAction = UnitAction(); + return; + } + + QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, mapPoints, numPoints05); + const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig(impId); + QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; + + if (!validTerrains.contains(tType)) { + QString validStr = validTerrains.join(", "); + addFloatingText(Localization::instance().get("ui.unit.cannot_build_here", "Cannot build here! Allowed on: %1").arg(validStr), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); + unit.currentAction = UnitAction(); + return; + } + + // First move onto the target tile if not standing on it yet + if (unit.tileX != tx || unit.tileY != ty) { + std::vector> path = Pathfinder::findPath( + unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, + unit.config.movementDomain, &unit.config + ); + if (path.size() > 1) { + unit.setPath(path); + addFloatingText(Localization::instance().get("ui.unit.moving_to_build_farm", "Moving to build Farm at (%1, %2)...").arg(tx).arg(ty), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); + } else { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + unit.currentAction = UnitAction(); + } + return; + } + + // Unit is on the target tile: start constructing the improvement + float duration = unit.config.getActionDuration("boerderij_bouwen", 10.0f); + unit.startBuilding(impId, duration); + addFloatingText(Localization::instance().get("ui.unit.farm_started", "Constructing Farm..."), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); + break; + } + case UnitActionType::BuildCity: { + if (!unit.config.canBuildCity()) { + unit.currentAction = UnitAction(); + return; + } + + int tx = action.targetX; + int ty = action.targetY; + + if (getCityAt(tx, ty) != nullptr) { + addFloatingText(Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); + unit.currentAction = UnitAction(); + return; + } + + // First move onto the target tile if not standing on it yet + if (unit.tileX != tx || unit.tileY != ty) { + std::vector> path = Pathfinder::findPath( + unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, + unit.config.movementDomain, &unit.config + ); + if (path.size() > 1) { + unit.setPath(path); + addFloatingText(Localization::instance().get("ui.unit.moving_to_build_city", "Moving to found City at (%1, %2)...").arg(tx).arg(ty), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); + } else { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + unit.currentAction = UnitAction(); + } + return; + } + + // Unit is on the target tile: found the city! + const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); + int cId = getNextCityId(); + QString cityName = QString("City %1").arg(cId); + QVector2D w = Pathfinder::hexToWorld(tx, ty); + float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); + + City newCity(cId, unit.playerId, cityName, tx, ty, + QVector3D(w.x(), w.y(), gz), + cCfg.initialInhabitants, cCfg.inhabitantsPerPop, cCfg.initialBorderRadius); + + QString defRes = getBestTileResource(tx, ty, mapPoints, numPoints05); + newCity.assignWorker(tx, ty, defRes); + + m_cities.push_back(newCity); + addFloatingText(Localization::instance().get("ui.unit.city_founded", "%1 founded!").arg(cityName), + QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.5f); + + unit.currentAction = UnitAction(); + unit.takeDamage(99999.0f); + if (unit.id == m_selectedUnitId) { + m_selectedUnitId = -1; + m_actionMode = ActionMode::None; + } + break; + } + case UnitActionType::Attack: { + Unit *target = getUnitById(action.targetUnitId); + if (!target || target->isDead() || target->playerId == unit.playerId || !unit.config.canAttack()) { + unit.currentAction = UnitAction(); + return; + } + + int dist = Pathfinder::hexDistance(unit.tileX, unit.tileY, target->tileX, target->tileY); + if (dist <= unit.config.attackRange) { + // Within range: execute attack! + float damage = (float)unit.config.attackDamage; + unit.performAttack(*target); + + QString dmgText = QString("-%1 HP").arg((int)damage); + addFloatingText(dmgText, target->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 50, 50)); + + if (target->isDead()) { + addFloatingText(Localization::instance().get("ui.unit.defeated", "Defeated!"), + target->worldPos + QVector3D(0, 0, 1.2f), QColor(255, 200, 50), 2.2f); + } + unit.currentAction = UnitAction(); + return; + } + + // Out of range: move into attackRange first + if (!mapPoints) { + unit.currentAction = UnitAction(); + return; + } + + std::vector> fullPath = Pathfinder::findPath( + unit.tileX, unit.tileY, target->tileX, target->tileY, + mapPoints, numPoints05, unit.config.movementDomain, &unit.config + ); + std::vector> approachPath; + for (const auto &wp : fullPath) { + approachPath.push_back(wp); + if (Pathfinder::hexDistance(wp.first, wp.second, target->tileX, target->tileY) <= unit.config.attackRange) { + break; + } + } + + if (approachPath.size() > 1) { + unit.currentAction.targetX = approachPath.back().first; + unit.currentAction.targetY = approachPath.back().second; + unit.setPath(approachPath); + addFloatingText(Localization::instance().get("ui.unit.moving_to_attack", "Moving into attack range..."), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 190, 80), 2.0f); + } else { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + unit.currentAction = UnitAction(); + } + break; + } + case UnitActionType::BuildRoad: { + if (!unit.hasRoadRoute()) { + unit.currentAction = UnitAction(); + } + break; + } + default: + unit.currentAction = UnitAction(); + break; + } +} + +bool GameManager::commandMoveSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) +{ + Unit *selected = getSelectedUnit(); + if (!selected) return false; + + int originX = selected->tileX; + int originY = selected->tileY; + getPlannedOriginTile(*selected, appendWithShift, originX, originY); + + if (targetX == originX && targetY == originY) { + if (!appendWithShift && selected->hasQueuedAction()) { + selected->clearActionQueue(); + addFloatingText(Localization::instance().get("ui.unit.queue_cleared", "Queued actions cancelled"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80)); + return true; + } + return false; + } + if (!TerrainMovementManager::instance().isHexPassable( targetX, targetY, selected->config.movementDomain, mapPoints, numPoints05, &selected->config)) { if (selected->isNaval()) { @@ -1045,138 +1426,109 @@ void GameManager::commandMoveSelected(int targetX, int targetY, Matrix2DworldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); } - return; - } - - // If unit is busy building, it CANNOT walk right now. Queue the move action! - if (selected->isBuilding()) { - if (m_allowActionQueueing && m_autoQueueMoveWhenBusy) { - if (selected->queueAction(UnitAction(UnitActionType::Move, targetX, targetY))) { - addFloatingText(Localization::instance().get("ui.unit.move_queued", "Movement queued"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 230, 80)); - } else { - addFloatingText(Localization::instance().get("ui.unit.queue_full", "Action queue full!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); - } - } else { - addFloatingText(Localization::instance().get("ui.unit.busy", "Unit is busy!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100)); - } - return; + return false; } std::vector> path = Pathfinder::findPath( - selected->tileX, selected->tileY, targetX, targetY, mapPoints, numPoints05, + originX, originY, targetX, targetY, mapPoints, numPoints05, selected->config.movementDomain, &selected->config ); - if (path.size() > 1) { - selected->clearActionQueue(); - selected->clearRoadRoute(); - selected->setPath(path); - addFloatingText(Localization::instance().get("ui.unit.moving", "Moving"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 230, 80)); - } else { + if (path.size() <= 1) { addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + return false; } + + return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::Move, targetX, targetY), + appendWithShift, mapPoints, numPoints05); } -void GameManager::commandAttack(Unit *target) +bool GameManager::commandAttack(Unit *target, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); - if (!selected || !target) return; + if (!selected || !target || !selected->config.canAttack()) return false; + if (target->playerId == selected->playerId || target->isDead()) return false; - if (selected->isBuilding()) { - addFloatingText(Localization::instance().get("ui.unit.cannot_act_busy", "Unit cannot perform action while busy!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; + int originX = selected->tileX; + int originY = selected->tileY; + getPlannedOriginTile(*selected, appendWithShift, originX, originY); + + int dist = Pathfinder::hexDistance(originX, originY, target->tileX, target->tileY); + int standX = originX; + int standY = originY; + + if (dist > selected->config.attackRange) { + if (!mapPoints) { + addFloatingText(Localization::instance().get("ui.unit.out_of_range", "Out of range!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); + return false; + } + std::vector> path = Pathfinder::findPath( + originX, originY, target->tileX, target->tileY, + mapPoints, numPoints05, selected->config.movementDomain, &selected->config + ); + if (path.size() <= 1) { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + return false; + } + for (const auto &wp : path) { + if (Pathfinder::hexDistance(wp.first, wp.second, target->tileX, target->tileY) <= selected->config.attackRange) { + standX = wp.first; + standY = wp.second; + break; + } + } } - if (!selected->canAttack(*target)) { - addFloatingText(Localization::instance().get("ui.unit.out_of_range", "Out of range!"), + return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::Attack, standX, standY, "", target->id), + appendWithShift, mapPoints, numPoints05); +} + +bool GameManager::commandBuildCityAtTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) +{ + Unit *selected = getSelectedUnit(); + if (!selected || !selected->config.canBuildCity()) return false; + + if (getCityAt(targetX, targetY) != nullptr) { + addFloatingText(Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; + return false; } - float damage = (float)selected->config.attackDamage; - selected->performAttack(*target); - - QString dmgText = QString("-%1 HP").arg((int)damage); - addFloatingText(dmgText, target->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 50, 50)); - - if (target->isDead()) { - addFloatingText(Localization::instance().get("ui.unit.defeated", "Defeated!"), - target->worldPos + QVector3D(0, 0, 1.2f), QColor(255, 200, 50), 2.2f); + if (!TerrainMovementManager::instance().isHexPassable( + targetX, targetY, selected->config.movementDomain, mapPoints, numPoints05, &selected->config)) { + addFloatingText(Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + return false; } - m_actionMode = ActionMode::None; + int originX = selected->tileX; + int originY = selected->tileY; + getPlannedOriginTile(*selected, appendWithShift, originX, originY); + + if (targetX != originX || targetY != originY) { + std::vector> path = Pathfinder::findPath( + originX, originY, targetX, targetY, mapPoints, numPoints05, + selected->config.movementDomain, &selected->config + ); + if (path.size() <= 1) { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + return false; + } + } + + return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::BuildCity, targetX, targetY), + appendWithShift, mapPoints, numPoints05); } void GameManager::commandBuildCitySelected(Matrix2D *mapPoints, int numPoints05) { Unit *selected = getSelectedUnit(); - if (!selected || !selected->config.canBuildCity()) return; - - if (selected->isBuilding()) { - addFloatingText(Localization::instance().get("ui.unit.cannot_act_busy", "Unit cannot perform action while busy!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; - } - - // If unit is moving, queue city founding upon arrival - if (selected->state == UnitState::Moving) { - if (selected->hasQueuedActionType(UnitActionType::BuildCity)) { - selected->removeQueuedActionsOfType(UnitActionType::BuildCity); - addFloatingText(Localization::instance().get("ui.unit.action_cancelled", "%1 cancelled").arg("Found City"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80)); - return; - } - - if (selected->queueAction(UnitAction(UnitActionType::BuildCity, selected->targetTileX, selected->targetTileY))) { - addFloatingText(Localization::instance().get("ui.unit.city_queued", "Found City 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; - - // Check if tile already has a city - for (const auto &c : m_cities) { - if (c.tileX == tx && c.tileY == ty) { - addFloatingText(Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; - } - } - - const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); - int cId = getNextCityId(); - QString cityName = QString("City %1").arg(cId); - QVector2D w = Pathfinder::hexToWorld(tx, ty); - float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); - - City newCity(cId, selected->playerId, cityName, tx, ty, - QVector3D(w.x(), w.y(), gz), - cCfg.initialInhabitants, cCfg.inhabitantsPerPop, cCfg.initialBorderRadius); - - // Assign initial worker to center tile with best yield resource - QString defRes = getBestTileResource(tx, ty, mapPoints, numPoints05); - newCity.assignWorker(tx, ty, defRes); - - m_cities.push_back(newCity); - addFloatingText(Localization::instance().get("ui.unit.city_founded", "%1 founded!").arg(cityName), - QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.5f); - - // Consuming settler - selected->takeDamage(99999.0f); - m_selectedUnitId = -1; - m_actionMode = ActionMode::None; + if (!selected) return; + commandBuildCityAtTileSelected(selected->tileX, selected->tileY, mapPoints, numPoints05, false); } void GameManager::addFloatingText(const QString &text, const QVector3D &worldPos, const QColor &color, float duration) @@ -1281,76 +1633,44 @@ float GameManager::getRoadSpeedMultiplierBetween(int fromX, int fromY, int toX, return 1.0f; } -void GameManager::commandBuildFarmSelected(Matrix2D *mapPoints, int numPoints05) +bool GameManager::commandBuildFarmAtTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); - if (!selected || !selected->config.canBuildFarm()) return; + if (!selected || !selected->config.canBuildFarm()) return false; - 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; - } - - if (!selected->config.infiniteCharges && selected->buildCharges <= 0) { - addFloatingText(Localization::instance().get("ui.unit.no_build_charges", "No build charges remaining!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; - } - - // If unit is currently moving: toggle or queue build action on arrival! - if (selected->state == UnitState::Moving) { - if (selected->hasQueuedActionType(UnitActionType::BuildImprovement)) { - selected->removeQueuedActionsOfType(UnitActionType::BuildImprovement); - addFloatingText(Localization::instance().get("ui.unit.action_cancelled", "%1 cancelled").arg("Build Farm"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80)); - return; + if (!selected->config.infiniteCharges) { + int plannedFarms = 0; + if (selected->currentAction.type == UnitActionType::BuildImprovement) { + plannedFarms++; } - - int destX = selected->targetTileX; - int destY = selected->targetTileY; - - // Check if destination tile already has an improvement - if (hasImprovementAt(destX, destY)) { - addFloatingText(Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"), + if (appendWithShift) { + for (const auto &act : selected->actionQueue) { + if (act.type == UnitActionType::BuildImprovement) plannedFarms++; + } + } + if (selected->buildCharges - plannedFarms <= 0) { + addFloatingText(Localization::instance().get("ui.unit.no_build_charges", "No build charges remaining!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; + return false; } - - // Check destination terrain type against allowed terrains - QString tType = TerrainYieldManager::instance().getTerrainType(destX, destY, mapPoints, numPoints05); - const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig("farm"); - QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; - - if (!validTerrains.contains(tType)) { - QString validStr = validTerrains.join(", "); - addFloatingText(Localization::instance().get("ui.unit.cannot_build_destination", "Cannot build on destination terrain! Allowed on: %1").arg(validStr), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; - } - - if (selected->queueAction(UnitAction(UnitActionType::BuildImprovement, destX, destY, "farm"))) { - addFloatingText(Localization::instance().get("ui.unit.farm_queued", "Build Farm 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; + bool alreadyPlannedHere = false; + if (selected->currentAction.type == UnitActionType::BuildImprovement && + selected->currentAction.targetX == targetX && selected->currentAction.targetY == targetY) { + alreadyPlannedHere = true; + } + if (appendWithShift && selected->hasQueuedImprovementAt(targetX, targetY)) { + alreadyPlannedHere = true; + } - // Check if tile already has an improvement - if (hasImprovementAt(tx, ty)) { + if (hasImprovementAt(targetX, targetY) || alreadyPlannedHere) { addFloatingText(Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; + return false; } - // Check terrain type against allowed terrains loaded from improvements.yaml - QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, mapPoints, numPoints05); + QString tType = TerrainYieldManager::instance().getTerrainType(targetX, targetY, mapPoints, numPoints05); const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig("farm"); QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; @@ -1358,125 +1678,80 @@ void GameManager::commandBuildFarmSelected(Matrix2D *mapPoints, in QString validStr = validTerrains.join(", "); addFloatingText(Localization::instance().get("ui.unit.cannot_build_here", "Cannot build here! Allowed on: %1").arg(validStr), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; + return false; } - // Start building process (duration comes from the builder unit's action speed) - float duration = selected->config.getActionDuration("boerderij_bouwen", 10.0f); - selected->startBuilding("farm", duration); - addFloatingText(Localization::instance().get("ui.unit.farm_started", "Constructing Farm..."), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); + int originX = selected->tileX; + int originY = selected->tileY; + getPlannedOriginTile(*selected, appendWithShift, originX, originY); + + if (targetX != originX || targetY != originY) { + std::vector> path = Pathfinder::findPath( + originX, originY, targetX, targetY, mapPoints, numPoints05, + selected->config.movementDomain, &selected->config + ); + if (path.size() <= 1) { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); + return false; + } + } + + return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::BuildImprovement, targetX, targetY, "farm"), + appendWithShift, mapPoints, numPoints05); +} + +void GameManager::commandBuildFarmSelected(Matrix2D *mapPoints, int numPoints05) +{ + Unit *selected = getSelectedUnit(); + if (!selected) return; + commandBuildFarmAtTileSelected(selected->tileX, selected->tileY, mapPoints, numPoints05, false); } void GameManager::commandBuildRoadSelected(Matrix2D *mapPoints, int numPoints05) { Unit *selected = getSelectedUnit(); - if (!selected || !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); + if (!selected) return; + commandBuildRoadToTileSelected(selected->tileX, selected->tileY, mapPoints, numPoints05, false); } -void GameManager::commandBuildRoadToTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05) +bool GameManager::commandBuildRoadToTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift) { Unit *selected = getSelectedUnit(); - if (!selected || !selected->config.canBuildRoad()) return; + if (!selected || !selected->config.canBuildRoad()) return false; if (!TerrainMovementManager::instance().isHexPassable( targetX, targetY, MovementDomain::Land, mapPoints, numPoints05, &selected->config)) { addFloatingText(Localization::instance().get("ui.unit.cannot_build_road_water", "Roads cannot be built on water!"), selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; + return false; } - // Pathfind from current tile to target tile - std::vector> route = Pathfinder::findPath( - selected->tileX, selected->tileY, targetX, targetY, - mapPoints, numPoints05, MovementDomain::Land, &selected->config - ); + int originX = selected->tileX; + int originY = selected->tileY; + getPlannedOriginTile(*selected, appendWithShift, originX, originY); - if (route.empty()) { - addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; + if (targetX == originX && targetY == originY) { + if (hasRoadAt(targetX, targetY)) { + addFloatingText(Localization::instance().get("ui.unit.road_already_here", "Tile already has a road!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); + return false; + } + } else { + std::vector> route = Pathfinder::findPath( + originX, originY, targetX, targetY, + mapPoints, numPoints05, MovementDomain::Land, &selected->config + ); + if (route.empty()) { + addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), + selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); + return false; + } } - selected->clearActionQueue(); - selected->startRoadRoute(route); - QString defaultType = m_roadConfigManager.defaultRoadType(); - const RoadTypeConfig *rCfg = m_roadConfigManager.getRoadType(defaultType); - QString roadName = rCfg ? rCfg->name : "Road"; - - addFloatingText(Localization::instance().get("ui.unit.road_route_started", "Building road route to (%1, %2)...").arg(targetX).arg(targetY), - selected->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.2f); - - advanceRoadRoute(*selected, mapPoints, numPoints05); + return scheduleOrStartUnitAction(*selected, UnitAction(UnitActionType::BuildRoad, targetX, targetY, defaultType), + appendWithShift, mapPoints, numPoints05); } void GameManager::advanceRoadRoute(Unit &unit, Matrix2D *mapPoints, int numPoints05) @@ -1490,7 +1765,15 @@ void GameManager::advanceRoadRoute(Unit &unit, Matrix2D *mapPoints // If unit is not on this tile yet, move to it! if (unit.tileX != tx || unit.tileY != ty) { - unit.setPath({ {unit.tileX, unit.tileY}, {tx, ty} }); + std::vector> stepPath = Pathfinder::findPath( + unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, + MovementDomain::Land, &unit.config + ); + if (stepPath.size() > 1) { + unit.setPath(stepPath); + } else { + unit.setPath({ {unit.tileX, unit.tileY}, {tx, ty} }); + } unit.state = UnitState::Moving; return; } @@ -1516,6 +1799,9 @@ void GameManager::advanceRoadRoute(Unit &unit, Matrix2D *mapPoints // Finished entire route! unit.clearRoadRoute(); + if (unit.currentAction.type == UnitActionType::BuildRoad) { + unit.currentAction = UnitAction(); + } QVector2D w = Pathfinder::hexToWorld(unit.tileX, unit.tileY); float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); addFloatingText(Localization::instance().get("ui.unit.road_route_completed", "Road route completed!"), @@ -1548,12 +1834,17 @@ void GameManager::completeUnitBuilding(Unit &unit, Matrix2D *mapPo if (unit.hasRoadRoute()) { unit.advanceRoadRouteStep(); advanceRoadRoute(unit, mapPoints, numPoints05); + } else if (unit.currentAction.type == UnitActionType::BuildRoad) { + unit.currentAction = UnitAction(); } return; } if (hasImprovementAt(tx, ty)) { unit.cancelBuilding(); + if (unit.currentAction.type == UnitActionType::BuildImprovement) { + unit.currentAction = UnitAction(); + } return; } @@ -1567,6 +1858,9 @@ void GameManager::completeUnitBuilding(Unit &unit, Matrix2D *mapPo m_tileImprovements[{tx, ty}] = imp; unit.cancelBuilding(); + if (unit.currentAction.type == UnitActionType::BuildImprovement) { + unit.currentAction = UnitAction(); + } if (!unit.config.infiniteCharges && unit.buildCharges > 0) { unit.buildCharges -= 1; @@ -1597,165 +1891,10 @@ void GameManager::completeUnitBuilding(Unit &unit, Matrix2D *mapPo void GameManager::processUnitActionQueue(Unit &unit, Matrix2D *mapPoints, int numPoints05) { - if (unit.isDead() || unit.state != UnitState::Idle || !unit.hasQueuedAction()) { - return; - } - - UnitAction action = unit.popNextQueuedAction(); - - switch (action.type) { - case UnitActionType::Move: { - int tx = action.targetX; - int ty = action.targetY; - if (tx == unit.tileX && ty == unit.tileY) { - return; - } - if (!TerrainMovementManager::instance().isHexPassable( - tx, ty, unit.config.movementDomain, mapPoints, numPoints05, &unit.config)) { - addFloatingText(Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); - return; - } - - std::vector> path = Pathfinder::findPath( - unit.tileX, unit.tileY, tx, ty, mapPoints, numPoints05, - unit.config.movementDomain, &unit.config - ); - - if (path.size() > 1) { - unit.setPath(path); - addFloatingText(Localization::instance().get("ui.unit.moving", "Moving"), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 230, 80)); - } else { - addFloatingText(Localization::instance().get("ui.unit.no_path", "No reachable path!"), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 100, 100), 2.5f); - } - break; - } - case UnitActionType::BuildImprovement: { - QString impId = action.extraData.isEmpty() ? "farm" : action.extraData; - int tx = unit.tileX; - int ty = unit.tileY; - - if (!unit.config.canBuildFarm()) { - return; - } - - if (!unit.config.infiniteCharges && unit.buildCharges <= 0) { - addFloatingText(Localization::instance().get("ui.unit.no_build_charges", "No build charges remaining!"), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; - } - - if (hasImprovementAt(tx, ty)) { - addFloatingText(Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 90, 90)); - return; - } - - QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, mapPoints, numPoints05); - const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig(impId); - QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; - - if (!validTerrains.contains(tType)) { - QString validStr = validTerrains.join(", "); - addFloatingText(Localization::instance().get("ui.unit.cannot_build_here", "Cannot build here! Allowed on: %1").arg(validStr), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; - } - - float duration = unit.config.getActionDuration("boerderij_bouwen", 10.0f); - unit.startBuilding(impId, duration); - addFloatingText(Localization::instance().get("ui.unit.farm_started", "Constructing Farm..."), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 220, 80), 2.0f); - break; - } - case UnitActionType::BuildCity: { - if (!unit.config.canBuildCity()) { - return; - } - int tx = unit.tileX; - int ty = unit.tileY; - - // Check if tile already has a city - for (const auto &c : m_cities) { - if (c.tileX == tx && c.tileY == ty) { - addFloatingText(Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"), - unit.worldPos + QVector3D(0, 0, 0.8f), QColor(255, 80, 80)); - return; - } - } - - const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig(); - int cId = getNextCityId(); - QString cityName = QString("City %1").arg(cId); - QVector2D w = Pathfinder::hexToWorld(tx, ty); - float gz = Unit::getTerrainHeight(w.x(), w.y(), mapPoints, numPoints05); - - City newCity(cId, unit.playerId, cityName, tx, ty, - QVector3D(w.x(), w.y(), gz), - cCfg.initialInhabitants, cCfg.inhabitantsPerPop, cCfg.initialBorderRadius); - - QString defRes = getBestTileResource(tx, ty, mapPoints, numPoints05); - newCity.assignWorker(tx, ty, defRes); - - m_cities.push_back(newCity); - addFloatingText(Localization::instance().get("ui.unit.city_founded", "%1 founded!").arg(cityName), - QVector3D(w.x(), w.y(), gz + 1.2f), QColor(80, 255, 120), 2.5f); - - unit.takeDamage(99999.0f); - if (unit.id == m_selectedUnitId) { - m_selectedUnitId = -1; - m_actionMode = ActionMode::None; - } - 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) { - if (other->id == action.targetUnitId && !other->isDead()) { - target = other.get(); - break; - } - } - if (target && unit.canAttack(*target)) { - unit.performAttack(*target); - addFloatingText(QString("-%1 HP").arg(unit.config.attackDamage), - target->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 50, 50)); - if (target->isDead()) { - addFloatingText(Localization::instance().get("ui.unit.defeated", "Defeated!"), - target->worldPos + QVector3D(0, 0, 1.2f), QColor(255, 200, 50), 2.2f); - } - } - break; - } - default: - break; + while (!unit.isDead() && unit.state == UnitState::Idle && !unit.currentAction.isValid() && unit.hasQueuedAction()) { + UnitAction nextAction = unit.popNextQueuedAction(); + startUnitAction(unit, nextAction, mapPoints, numPoints05); } } + diff --git a/gamemanager.h b/gamemanager.h index 628d6d6..9665f15 100644 --- a/gamemanager.h +++ b/gamemanager.h @@ -119,12 +119,22 @@ public: void removeQueuedCityProject(int cityId, int queueIndex); void completeCityProject(City &city, Matrix2D *mapPoints, int numPoints05); - void commandMoveSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05); - void commandAttack(Unit *target); + void getPlannedOriginTile(const Unit &unit, bool appendWithShift, int &outX, int &outY) const; + bool scheduleOrStartUnitAction(Unit &unit, const UnitAction &action, bool appendWithShift, + Matrix2D *mapPoints, int numPoints05); + bool startUnitAction(Unit &unit, const UnitAction &action, + Matrix2D *mapPoints, int numPoints05); + void continueCurrentUnitAction(Unit &unit, Matrix2D *mapPoints, int numPoints05); + + bool commandMoveSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift = false); + bool commandAttack(Unit *target, Matrix2D *mapPoints = nullptr, int numPoints05 = 0, bool appendWithShift = false); + bool commandBuildCityAtTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift = false); + bool commandBuildFarmAtTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift = false); + bool commandBuildRoadToTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05, bool appendWithShift = false); + void commandBuildCitySelected(Matrix2D *mapPoints, int numPoints05); void commandBuildFarmSelected(Matrix2D *mapPoints, int numPoints05); void commandBuildRoadSelected(Matrix2D *mapPoints, int numPoints05); - void commandBuildRoadToTileSelected(int targetX, int targetY, Matrix2D *mapPoints, int numPoints05); void advanceRoadRoute(Unit &unit, Matrix2D *mapPoints, int numPoints05); void completeUnitBuilding(Unit &unit, Matrix2D *mapPoints, int numPoints05); void processUnitActionQueue(Unit &unit, Matrix2D *mapPoints, int numPoints05); diff --git a/qscenerenderer.cpp b/qscenerenderer.cpp index 9c8a7ac..3ca99c9 100644 --- a/qscenerenderer.cpp +++ b/qscenerenderer.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include "pathfinder.h" @@ -788,64 +789,71 @@ void QSceneRenderer::drawUnitsOverlay(QPainter &painter, const QMatrix4x4 &vMatr QPointF bCenter(startX + a * spacing, bubbleY); QRect bRect((int)(bCenter.x() - bubbleR), (int)(bCenter.y() - bubbleR), (int)(bubbleR * 2), (int)(bubbleR * 2)); + bool isMode = false; bool isActive = false; QString iconFile = "images/actions/move.png"; - QString tip = "Verplaatsen (M)"; + QString tip = Localization::instance().get("ui.unit.tip_move", "Move (M) - Click target tile (Shift+Click to queue)"); if (act == "verplaatsen") { - isActive = (GameManager::instance().currentActionMode() == ActionMode::Move); + isMode = (GameManager::instance().currentActionMode() == ActionMode::Move); + isActive = isMode; iconFile = "images/actions/move.png"; - tip = "Verplaatsen (M)"; + tip = Localization::instance().get("ui.unit.tip_move", "Move (M) - Click target tile (Shift+Click to queue)"); } else if (act == "aanvallen") { - isActive = (GameManager::instance().currentActionMode() == ActionMode::Attack); + isMode = (GameManager::instance().currentActionMode() == ActionMode::Attack); + bool isQueued = (unit->currentAction.type == UnitActionType::Attack) || unit->hasQueuedActionType(UnitActionType::Attack); + isActive = isMode || isQueued; iconFile = "images/actions/attack.png"; - tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1]").arg(unit->config.attackRange); + tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1] - Click enemy (Shift+Click to queue)").arg(unit->config.attackRange); } else if (act == "steden_bouwen" || act == "build_city") { - bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildCity); - isActive = (GameManager::instance().currentActionMode() == ActionMode::BuildCity) || isQueued; + isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildCity); + bool isQueued = (unit->currentAction.type == UnitActionType::BuildCity) || unit->hasQueuedActionType(UnitActionType::BuildCity); + isActive = isMode || isQueued; iconFile = "images/actions/build_city.png"; tip = isQueued - ? Localization::instance().get("ui.unit.city_queued", "Found City queued on arrival") - : Localization::instance().get("ui.unit.tip_build_city", "Found City (B)"); + ? Localization::instance().get("ui.unit.city_queued", "Found City queued") + : Localization::instance().get("ui.unit.tip_build_city", "Found City (B) - Click target tile (Shift+Click to queue)"); } else if (act == "boerderij_bouwen" || act == "build_farm") { - bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement); - isActive = (unit->isBuilding() && unit->buildingImprovementId != "road") || isQueued; + isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildFarm); + bool isBuildingFarm = unit->isBuilding() && !unit->buildingImprovementId.startsWith("road"); + bool isQueued = (unit->currentAction.type == UnitActionType::BuildImprovement) || unit->hasQueuedActionType(UnitActionType::BuildImprovement); + isActive = isMode || isBuildingFarm || isQueued; iconFile = "images/actions/farm.png"; - if (unit->isBuilding() && unit->buildingImprovementId != "road") { + if (isBuildingFarm) { 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% (Click to plan next target)").arg(pct); } else if (isQueued) { - tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued on arrival - click to cancel)"); + tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued - Click to plan target)"); } 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) - Click target tile (Shift+Click to queue)"); } } else if (act == "build_road" || act == "weg_bouwen") { - bool isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildRoad); + isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildRoad); bool isBuildingRoad = unit->isBuilding() && unit->buildingImprovementId.startsWith("road"); bool hasRoute = unit->hasRoadRoute(); - bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildRoad); + bool isQueued = (unit->currentAction.type == UnitActionType::BuildRoad) || unit->hasQueuedActionType(UnitActionType::BuildRoad); isActive = isMode || isBuildingRoad || hasRoute || isQueued; iconFile = "images/actions/road.png"; if (isBuildingRoad) { 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); + tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1% (Click to plan next target)").arg(pct); } else if (hasRoute) { int cur = (int)unit->roadRouteIndex + 1; int total = (int)unit->roadRoute.size(); tip = Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total); } else if (isQueued) { - tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued on arrival - click to cancel)"); + tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued - Click to plan target)"); } else { - tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R) - Click target tile to build route"); + tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R) - Click target tile (Shift+Click to queue)"); } } // Draw action bubble - painter.setBrush(isActive ? QColor(40, 80, 140, 240) : QColor(20, 26, 38, 230)); - painter.setPen(QPen(isActive ? QColor(255, 220, 50) : secondaryCol, isActive ? 2.0 : 1.2)); + painter.setBrush(isMode ? QColor(55, 105, 175, 250) : (isActive ? QColor(40, 80, 140, 240) : QColor(20, 26, 38, 230))); + painter.setPen(QPen(isMode ? QColor(100, 235, 255) : (isActive ? QColor(255, 220, 50) : secondaryCol), (isMode || isActive) ? 2.0 : 1.2)); painter.drawEllipse(bCenter, bubbleR, bubbleR); // Action icon inside bubble - QImage aIcon = getTintedIcon(iconFile, isActive ? QColor(255, 230, 80) : primaryCol); + QImage aIcon = getTintedIcon(iconFile, (isMode || isActive) ? QColor(255, 230, 80) : primaryCol); painter.drawImage(QRectF(bCenter.x() - 7, bCenter.y() - 7, 14, 14), aIcon); // Register for click detection @@ -853,11 +861,12 @@ void QSceneRenderer::drawUnitsOverlay(QPainter &painter, const QMatrix4x4 &vMatr // Tooltip on hover if (bRect.contains(m_mouseCurrentPos)) { - QRect tipRect((int)bCenter.x() - 55, (int)bCenter.y() - 26, 110, 18); + painter.setFont(QFont("sans-serif", 7, QFont::Bold)); + int tw = std::max(110, painter.fontMetrics().horizontalAdvance(tip) + 14); + QRect tipRect((int)bCenter.x() - tw / 2, (int)bCenter.y() - 28, tw, 18); painter.setBrush(QColor(15, 20, 30, 240)); painter.setPen(QPen(QColor(120, 160, 210), 1.0)); painter.drawRoundedRect(tipRect, 3, 3); - painter.setFont(QFont("sans-serif", 7, QFont::Bold)); painter.setPen(Qt::white); painter.drawText(tipRect, Qt::AlignCenter, tip); } @@ -871,6 +880,17 @@ void QSceneRenderer::drawPathAndRange(QPainter &painter, Unit *unit, const QMatr if (!unit) return; painter.setRenderHint(QPainter::Antialiasing, true); + auto drawFloatingBadge = [&](const QPointF &sPos, const QString &text, const QColor &borderCol, const QColor &textCol, int yOffset = -28) { + painter.setFont(QFont("sans-serif", 7, QFont::Bold)); + int tw = painter.fontMetrics().horizontalAdvance(text) + 14; + QRect badgeRect((int)sPos.x() - tw / 2, (int)sPos.y() + yOffset, tw, 18); + painter.setBrush(QColor(16, 22, 32, 235)); + painter.setPen(QPen(borderCol, 1.2)); + painter.drawRoundedRect(badgeRect, 4, 4); + painter.setPen(textCol); + painter.drawText(badgeRect, Qt::AlignCenter, text); + }; + // 1. If unit is moving or has a path: draw path line and destination reticle if (unit->path.size() > 1) { QPainterPath pathLine; @@ -948,85 +968,292 @@ void QSceneRenderer::drawPathAndRange(QPainter &painter, Unit *unit, const QMatr } } - // 3. Road Build targeting preview when in ActionMode::BuildRoad - if (GameManager::instance().currentActionMode() == ActionMode::BuildRoad && unit->config.canBuildRoad()) { - int hx = -1, hy = -1; - if (screenToHex(m_mouseCurrentPos, vMatrix, hx, hy) && (hx != unit->tileX || hy != unit->tileY)) { - if (TerrainMovementManager::instance().isHexPassable(hx, hy, MovementDomain::Land, m_point, m_numPoints05, &unit->config)) { - auto preview = Pathfinder::findPath(unit->tileX, unit->tileY, hx, hy, m_point, m_numPoints05, MovementDomain::Land, &unit->config); - if (preview.size() > 1) { - QPainterPath previewLine; + // 2b. Queued Action Chain visualization (shows all actions in unit->actionQueue chained from previous target tile) + if (!unit->actionQueue.empty()) { + int prevX = unit->tileX; + int prevY = unit->tileY; + GameManager::instance().getPlannedOriginTile(*unit, false, prevX, prevY); + + for (size_t qi = 0; qi < unit->actionQueue.size(); ++qi) { + const UnitAction &qAct = unit->actionQueue[qi]; + if (qAct.targetX < 0 || qAct.targetY < 0) continue; + + QColor qCol(100, 210, 255, 210); + QString qBadge; + if (qAct.type == UnitActionType::Move) { + qCol = QColor(80, 210, 255, 210); + qBadge = Localization::instance().get("ui.unit.queue_badge_move", "#%1 Move").arg(qi + 1); + } else if (qAct.type == UnitActionType::BuildImprovement) { + qCol = QColor(90, 235, 130, 220); + qBadge = Localization::instance().get("ui.unit.queue_badge_farm", "#%1 Farm").arg(qi + 1); + } else if (qAct.type == UnitActionType::BuildCity) { + qCol = QColor(120, 245, 160, 220); + qBadge = Localization::instance().get("ui.unit.queue_badge_city", "#%1 Settle").arg(qi + 1); + } else if (qAct.type == UnitActionType::BuildRoad) { + qCol = QColor(255, 200, 65, 220); + qBadge = Localization::instance().get("ui.unit.queue_badge_road", "#%1 Road").arg(qi + 1); + } else if (qAct.type == UnitActionType::Attack) { + qCol = QColor(255, 85, 85, 220); + qBadge = Localization::instance().get("ui.unit.queue_badge_attack", "#%1 Attack").arg(qi + 1); + } + + if (qAct.targetX != prevX || qAct.targetY != prevY) { + MovementDomain dom = (qAct.type == UnitActionType::BuildRoad) ? MovementDomain::Land : unit->config.movementDomain; + auto qPath = Pathfinder::findPath(prevX, prevY, qAct.targetX, qAct.targetY, m_point, m_numPoints05, dom, &unit->config); + if (qPath.size() > 1) { + QPainterPath qLine; bool first = true; - for (const auto &p : preview) { - QVector2D w = Pathfinder::hexToWorld(p.first, p.second); + for (const auto &wp : qPath) { + QVector2D w = Pathfinder::hexToWorld(wp.first, wp.second); float z = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05); bool inFront = false; QPointF sPos = UnitRenderer::projectToScreen(QVector3D(w.x(), w.y(), z + 0.06f), Projection, vMatrix, width(), height(), inFront); if (inFront) { if (first) { - previewLine.moveTo(sPos); + qLine.moveTo(sPos); first = false; } else { - previewLine.lineTo(sPos); + qLine.lineTo(sPos); + } + if (qAct.type == UnitActionType::BuildRoad) { + painter.setPen(QPen(QColor(140, 95, 30, 180), 1.0)); + painter.setBrush(qCol); + painter.drawEllipse(sPos, 3.0, 3.0); } - painter.setPen(QPen(QColor(160, 110, 40, 200), 1.0)); - painter.setBrush(QColor(255, 230, 90, 230)); - painter.drawEllipse(sPos, 3.5, 3.5); } } - painter.setPen(QPen(QColor(255, 220, 60, 240), 3.0, Qt::DashLine)); + painter.setPen(QPen(qCol, 2.2, Qt::DotLine)); painter.setBrush(Qt::NoBrush); - painter.drawPath(previewLine); + painter.drawPath(qLine); + } + } - // Destination reticle - QVector2D destW = Pathfinder::hexToWorld(hx, hy); - float destZ = Unit::getTerrainHeight(destW.x(), destW.y(), m_point, m_numPoints05); - bool inFront = false; - QPointF sDest = UnitRenderer::projectToScreen(QVector3D(destW.x(), destW.y(), destZ + 0.06f), Projection, vMatrix, width(), height(), inFront); - if (inFront) { - float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.01f) + 1.0f) * 0.5f; - painter.setPen(QPen(QColor(255, 225, 70, (int)(180 + pulse * 75)), 2.5)); - painter.setBrush(QColor(255, 210, 50, 50)); - painter.drawEllipse(sDest, 16.0f, 16.0f); - painter.drawLine(sDest.x() - 22, sDest.y(), sDest.x() + 22, sDest.y()); - painter.drawLine(sDest.x(), sDest.y() - 22, sDest.x(), sDest.y() + 22); + QVector2D destW = Pathfinder::hexToWorld(qAct.targetX, qAct.targetY); + float destZ = Unit::getTerrainHeight(destW.x(), destW.y(), m_point, m_numPoints05); + bool inFront = false; + QPointF sDest = UnitRenderer::projectToScreen(QVector3D(destW.x(), destW.y(), destZ + 0.06f), Projection, vMatrix, width(), height(), inFront); + if (inFront) { + painter.setPen(QPen(qCol, 1.8)); + painter.setBrush(QColor(qCol.red(), qCol.green(), qCol.blue(), 40)); + painter.drawEllipse(sDest, 11.0, 11.0); + drawFloatingBadge(sDest, qBadge, qCol, qCol.lighter(130), -26); + } - QString targetTip = Localization::instance().get("ui.unit.btn_road_route", "Route (%1 hexes)").arg(preview.size() - 1); - painter.setFont(QFont("sans-serif", 8, QFont::Bold)); - QFontMetrics fm = painter.fontMetrics(); - int tw = fm.horizontalAdvance(targetTip) + 16; - QRect tipRect((int)sDest.x() - tw / 2, (int)sDest.y() - 34, tw, 20); - painter.setBrush(QColor(20, 24, 34, 230)); - painter.setPen(QPen(QColor(255, 215, 60), 1.2)); - painter.drawRoundedRect(tipRect, 4, 4); - painter.setPen(QColor(255, 235, 120)); - painter.drawText(tipRect, Qt::AlignCenter, targetTip); + if (qAct.type == UnitActionType::Attack && qAct.targetUnitId >= 0) { + Unit *enemy = GameManager::instance().getUnitById(qAct.targetUnitId); + if (enemy && !enemy->isDead() && inFront) { + bool eInFront = false; + QPointF sEnemy = UnitRenderer::projectToScreen(enemy->worldPos + QVector3D(0, 0, 0.35f), Projection, vMatrix, width(), height(), eInFront); + if (eInFront) { + painter.setPen(QPen(QColor(255, 70, 70, 210), 2.0, Qt::DashLine)); + painter.drawLine(sDest, sEnemy); } } } + + prevX = qAct.targetX; + prevY = qAct.targetY; } } - // 4. Attack range visualization if Attack mode is active - if (GameManager::instance().currentActionMode() == ActionMode::Attack) { - int range = unit->config.attackRange; - for (const auto &u : GameManager::instance().getUnits()) { - if (u->isDead() || u->playerId == unit->playerId) continue; + // 3. Target Tile Hover Preview when any ActionMode is active + ActionMode mode = GameManager::instance().currentActionMode(); + if (mode != ActionMode::None) { + bool shiftHeld = (QGuiApplication::keyboardModifiers() & Qt::ShiftModifier) != 0; + int originX = unit->tileX; + int originY = unit->tileY; + GameManager::instance().getPlannedOriginTile(*unit, shiftHeld, originX, originY); - int d = Pathfinder::hexDistance(unit->tileX, unit->tileY, u->tileX, u->tileY); - if (d <= range) { - // Target reticle over enemy unit + int hx = -1, hy = -1; + bool hasHoverHex = screenToHex(m_mouseCurrentPos, vMatrix, hx, hy); + + auto wrapQueueText = [&](const QString &baseText) -> QString { + if (shiftHeld && (unit->hasActiveAction() || unit->hasQueuedAction())) { + return Localization::instance().get("ui.unit.preview_queue_prefix", "+Queue: %1").arg(baseText); + } + return baseText; + }; + + auto drawPreviewPathAndReticle = [&](const std::vector> &previewPath, + int destX, int destY, + const QColor &col, const QString &label, + bool validTarget, bool drawWaypoints) { + if (previewPath.size() > 1) { + QPainterPath previewLine; + bool first = true; + for (const auto &p : previewPath) { + QVector2D w = Pathfinder::hexToWorld(p.first, p.second); + float z = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05); + bool inFront = false; + QPointF sPos = UnitRenderer::projectToScreen(QVector3D(w.x(), w.y(), z + 0.06f), Projection, vMatrix, width(), height(), inFront); + if (inFront) { + if (first) { + previewLine.moveTo(sPos); + first = false; + } else { + previewLine.lineTo(sPos); + } + if (drawWaypoints) { + painter.setPen(QPen(QColor(30, 30, 30, 200), 1.0)); + painter.setBrush(col); + painter.drawEllipse(sPos, 3.5, 3.5); + } + } + } + painter.setPen(QPen(col, 2.8, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(previewLine); + } + + QVector2D destW = Pathfinder::hexToWorld(destX, destY); + float destZ = Unit::getTerrainHeight(destW.x(), destW.y(), m_point, m_numPoints05); + bool inFront = false; + QPointF sDest = UnitRenderer::projectToScreen(QVector3D(destW.x(), destW.y(), destZ + 0.06f), Projection, vMatrix, width(), height(), inFront); + if (inFront) { + float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.01f) + 1.0f) * 0.5f; + QColor rCol = validTarget ? col : QColor(255, 75, 75); + painter.setPen(QPen(QColor(rCol.red(), rCol.green(), rCol.blue(), (int)(180 + pulse * 75)), 2.5)); + painter.setBrush(QColor(rCol.red(), rCol.green(), rCol.blue(), 50)); + painter.drawEllipse(sDest, 16.0f, 16.0f); + painter.drawLine(sDest.x() - 22, sDest.y(), sDest.x() + 22, sDest.y()); + painter.drawLine(sDest.x(), sDest.y() - 22, sDest.x(), sDest.y() + 22); + + if (!label.isEmpty()) { + drawFloatingBadge(sDest, label, rCol, rCol.lighter(135), -34); + } + } + }; + + if (mode == ActionMode::Move && hasHoverHex && (hx != originX || hy != originY)) { + bool passable = TerrainMovementManager::instance().isHexPassable(hx, hy, unit->config.movementDomain, m_point, m_numPoints05, &unit->config); + std::vector> preview; + if (passable) { + preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, unit->config.movementDomain, &unit->config); + } + bool valid = preview.size() > 1; + QString label = valid + ? wrapQueueText(Localization::instance().get("ui.unit.preview_move", "Move (%1 hexes)").arg(preview.size() - 1)) + : Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"); + drawPreviewPathAndReticle(preview, hx, hy, QColor(80, 220, 255, 240), label, valid, false); + } + else if (mode == ActionMode::BuildRoad && unit->config.canBuildRoad() && hasHoverHex) { + bool passable = TerrainMovementManager::instance().isHexPassable(hx, hy, MovementDomain::Land, m_point, m_numPoints05, &unit->config); + std::vector> preview; + bool valid = false; + QString label; + if (passable) { + if (hx == originX && hy == originY) { + valid = !GameManager::instance().hasRoadAt(hx, hy); + label = valid + ? wrapQueueText(Localization::instance().get("ui.unit.preview_road_here", "Build Road")) + : Localization::instance().get("ui.unit.road_already_here", "Tile already has a road!"); + } else { + preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, MovementDomain::Land, &unit->config); + valid = preview.size() > 1; + label = valid + ? wrapQueueText(Localization::instance().get("ui.unit.preview_road", "Road Route (%1 hexes)").arg(preview.size() - 1)) + : Localization::instance().get("ui.unit.no_path", "No reachable path!"); + } + } else { + label = Localization::instance().get("ui.unit.cannot_build_road_water", "Roads cannot be built on water!"); + } + drawPreviewPathAndReticle(preview, hx, hy, QColor(255, 220, 60, 240), label, valid, true); + } + else if (mode == ActionMode::BuildFarm && unit->config.canBuildFarm() && hasHoverHex) { + QString tType = TerrainYieldManager::instance().getTerrainType(hx, hy, m_point, m_numPoints05); + const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig("farm"); + QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"}; + bool alreadyOccupied = GameManager::instance().hasImprovementAt(hx, hy) || + (shiftHeld && unit->hasQueuedImprovementAt(hx, hy)); + bool terrainValid = validTerrains.contains(tType) && !alreadyOccupied; + + std::vector> preview; + bool pathValid = true; + if (hx != originX || hy != originY) { + preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, unit->config.movementDomain, &unit->config); + pathValid = preview.size() > 1; + } + bool valid = terrainValid && pathValid; + QString label; + if (!valid) { + if (alreadyOccupied) { + label = Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!"); + } else if (!validTerrains.contains(tType)) { + label = Localization::instance().get("ui.unit.unsuitable_terrain", "Unsuitable terrain!"); + } else { + label = Localization::instance().get("ui.unit.no_path", "No reachable path!"); + } + } else if (preview.size() > 1) { + label = wrapQueueText(Localization::instance().get("ui.unit.preview_farm_move", "Build Farm (%1 hexes)").arg(preview.size() - 1)); + } else { + label = wrapQueueText(Localization::instance().get("ui.unit.preview_farm", "Build Farm")); + } + drawPreviewPathAndReticle(preview, hx, hy, QColor(90, 235, 125, 240), label, valid, false); + } + else if (mode == ActionMode::BuildCity && unit->config.canBuildCity() && hasHoverHex) { + bool passable = TerrainMovementManager::instance().isHexPassable(hx, hy, unit->config.movementDomain, m_point, m_numPoints05, &unit->config); + bool noCity = (GameManager::instance().getCityAt(hx, hy) == nullptr); + std::vector> preview; + bool pathValid = true; + if (hx != originX || hy != originY) { + preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, unit->config.movementDomain, &unit->config); + pathValid = preview.size() > 1; + } + bool valid = passable && noCity && pathValid; + QString label; + if (!valid) { + if (!noCity) { + label = Localization::instance().get("ui.unit.city_already_here", "A city already exists here!"); + } else { + label = Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!"); + } + } else if (preview.size() > 1) { + label = wrapQueueText(Localization::instance().get("ui.unit.preview_city_move", "Found City (%1 hexes)").arg(preview.size() - 1)); + } else { + label = wrapQueueText(Localization::instance().get("ui.unit.preview_city", "Found City")); + } + drawPreviewPathAndReticle(preview, hx, hy, QColor(110, 245, 160, 240), label, valid, false); + } + else if (mode == ActionMode::Attack) { + int range = unit->config.attackRange; + for (const auto &u : GameManager::instance().getUnits()) { + if (u->isDead() || u->playerId == unit->playerId) continue; + + int d = Pathfinder::hexDistance(originX, originY, u->tileX, u->tileY); bool inFront = false; QPointF sTarget = UnitRenderer::projectToScreen(u->worldPos + QVector3D(0, 0, 0.4f), Projection, vMatrix, width(), height(), inFront); if (inFront) { float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.01f) + 1.0f) * 0.5f; - painter.setPen(QPen(QColor(255, 50, 50, (int)(180 + pulse * 75)), 2.2)); + QColor rCol = (d <= range) ? QColor(255, 50, 50, (int)(180 + pulse * 75)) : QColor(255, 140, 60, (int)(150 + pulse * 70)); + painter.setPen(QPen(rCol, 2.2)); painter.setBrush(QColor(255, 40, 40, 40)); painter.drawEllipse(sTarget, 16.0f, 16.0f); painter.drawLine(sTarget.x() - 20, sTarget.y(), sTarget.x() + 20, sTarget.y()); painter.drawLine(sTarget.x(), sTarget.y() - 20, sTarget.x(), sTarget.y() + 20); } } + + if (hasHoverHex) { + Unit *hoveredEnemy = GameManager::instance().getUnitAt(hx, hy); + if (hoveredEnemy && hoveredEnemy->playerId != unit->playerId && !hoveredEnemy->isDead()) { + int d = Pathfinder::hexDistance(originX, originY, hoveredEnemy->tileX, hoveredEnemy->tileY); + std::vector> approachPath; + if (d > range) { + auto fullPath = Pathfinder::findPath(originX, originY, hoveredEnemy->tileX, hoveredEnemy->tileY, + m_point, m_numPoints05, unit->config.movementDomain, &unit->config); + for (const auto &wp : fullPath) { + approachPath.push_back(wp); + if (Pathfinder::hexDistance(wp.first, wp.second, hoveredEnemy->tileX, hoveredEnemy->tileY) <= range) { + break; + } + } + } + QString label = (approachPath.size() > 1) + ? wrapQueueText(Localization::instance().get("ui.unit.preview_attack_move", "Attack (%1 hexes)").arg(approachPath.size() - 1)) + : wrapQueueText(Localization::instance().get("ui.unit.preview_attack", "Attack")); + drawPreviewPathAndReticle(approachPath, hoveredEnemy->tileX, hoveredEnemy->tileY, QColor(255, 65, 65, 240), label, true, false); + } + } } } } @@ -1088,7 +1315,7 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) painter.setFont(QFont("sans-serif", 8)); painter.setPen(QColor(160, 185, 215)); - QString playerInfo = QString("%1 | Hex: (%2, %3)").arg(player ? player->name : "Onbekend").arg(unit->tileX).arg(unit->tileY); + QString playerInfo = QString("%1 | Hex: (%2, %3)").arg(player ? player->name : "Unknown").arg(unit->tileX).arg(unit->tileY); painter.drawText(QRect(badgeRect.right() + 10, cardRect.top() + 28, 200, 14), Qt::AlignLeft | Qt::AlignVCenter, playerInfo); // Category Pill @@ -1132,10 +1359,12 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) QString costInfo = Localization::instance().get("ui.unit.cost_format", "Cost: %1 pts").arg(unit->config.cost); painter.drawText(QRect(cardRect.left() + 160, cardRect.top() + 76, 130, 14), Qt::AlignLeft | Qt::AlignVCenter, costInfo); - if (unit->config.isMilitary()) { - QString combatInfo = Localization::instance().get("ui.unit.combat_format", "Attack: %1 | Range: %2 hex") - .arg(unit->config.attackDamage).arg(unit->config.attackRange); - painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, combatInfo); + if (unit->hasQueuedAction()) { + QString qSummary = unit->getQueuedActionSummary(); + QString queuedInfo = Localization::instance().get("ui.unit.status_queued", "Queue (%1): %2") + .arg(unit->actionQueue.size()).arg(qSummary); + painter.setPen(QColor(100, 220, 140)); + painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, queuedInfo); } else if (unit->isBuilding()) { int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f); QString buildInfo = Localization::instance().get("ui.unit.building_pct", "Building... %1%").arg(pct); @@ -1147,27 +1376,23 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) buildInfo += QString(" | ") + Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges); } painter.setPen(QColor(255, 210, 80)); - painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, buildInfo); + painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, buildInfo); } else if (unit->hasRoadRoute()) { int cur = (int)unit->roadRouteIndex + 1; int total = (int)unit->roadRoute.size(); QString routeInfo = Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total); painter.setPen(QColor(255, 200, 70)); - painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, routeInfo); - } else if (unit->hasQueuedAction()) { - QString qSummary = unit->getQueuedActionSummary(); - QString queuedInfo = Localization::instance().get("ui.unit.status_queued", "Queued: %1").arg(qSummary); - 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); + painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, routeInfo); + } else if (unit->config.isMilitary()) { + QString combatInfo = Localization::instance().get("ui.unit.combat_format", "Attack: %1 | Range: %2 hex") + .arg(unit->config.attackDamage).arg(unit->config.attackRange); + painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, combatInfo); } 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); + painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, builderInfo); } else { - painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, + painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, Localization::instance().get("ui.unit.civilian_unit", "Civilian unit")); } @@ -1185,81 +1410,88 @@ void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit) QString act = actions[a]; QRect bRect(actionArea.left() + 8 + a * (btnW + 8), actionArea.top() + 6, btnW, 40); + bool isMode = false; bool isActive = false; QString iconFile = "images/actions/move.png"; QString label = Localization::instance().get("ui.unit.btn_move", "Move [M]"); - QString tip = Localization::instance().get("ui.unit.tip_move", "Move (M)"); + QString tip = Localization::instance().get("ui.unit.tip_move", "Move (M) - Click target tile (Shift+Click to queue)"); if (act == "verplaatsen") { - isActive = (GameManager::instance().currentActionMode() == ActionMode::Move); + isMode = (GameManager::instance().currentActionMode() == ActionMode::Move); + isActive = isMode; iconFile = "images/actions/move.png"; label = Localization::instance().get("ui.unit.btn_move", "Move [M]"); - tip = Localization::instance().get("ui.unit.tip_move", "Move (M)"); + tip = Localization::instance().get("ui.unit.tip_move", "Move (M) - Click target tile (Shift+Click to queue)"); } else if (act == "aanvallen") { - isActive = (GameManager::instance().currentActionMode() == ActionMode::Attack); + isMode = (GameManager::instance().currentActionMode() == ActionMode::Attack); + bool isQueued = (unit->currentAction.type == UnitActionType::Attack) || unit->hasQueuedActionType(UnitActionType::Attack); + isActive = isMode || isQueued; iconFile = "images/actions/attack.png"; label = Localization::instance().get("ui.unit.btn_attack", "Attack [A]"); - tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1]").arg(unit->config.attackRange); + tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1] - Click enemy (Shift+Click to queue)").arg(unit->config.attackRange); } else if (act == "steden_bouwen" || act == "build_city") { - bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildCity); - isActive = (GameManager::instance().currentActionMode() == ActionMode::BuildCity) || isQueued; + isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildCity); + bool isQueued = (unit->currentAction.type == UnitActionType::BuildCity) || unit->hasQueuedActionType(UnitActionType::BuildCity); + isActive = isMode || isQueued; iconFile = "images/actions/build_city.png"; - label = isQueued ? "Queued" : Localization::instance().get("ui.unit.btn_build_city", "Settle [B]"); + label = Localization::instance().get("ui.unit.btn_build_city", "Settle [B]"); tip = isQueued - ? Localization::instance().get("ui.unit.city_queued", "Found City queued on arrival") - : Localization::instance().get("ui.unit.tip_build_city", "Found City (B)"); + ? Localization::instance().get("ui.unit.city_queued", "Found City queued") + : Localization::instance().get("ui.unit.tip_build_city", "Found City (B) - Click target tile (Shift+Click to queue)"); } else if (act == "boerderij_bouwen" || act == "build_farm") { - bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement); - isActive = (unit->isBuilding() && unit->buildingImprovementId != "road") || isQueued; + isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildFarm); + bool isBuildingFarm = unit->isBuilding() && !unit->buildingImprovementId.startsWith("road"); + bool isQueued = (unit->currentAction.type == UnitActionType::BuildImprovement) || unit->hasQueuedActionType(UnitActionType::BuildImprovement); + isActive = isMode || isBuildingFarm || isQueued; iconFile = "images/actions/farm.png"; - if (unit->isBuilding() && unit->buildingImprovementId != "road") { + if (isBuildingFarm) { 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); + tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1% (Click to plan next target)").arg(pct); } else if (isQueued) { - label = Localization::instance().get("ui.unit.btn_farm_queued", "Farm (Queued)"); - tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued on arrival - click to cancel)"); + label = Localization::instance().get("ui.unit.btn_farm_queued", "Farm [F]"); + tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued - Click to plan target)"); } else { 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) - Click target tile (Shift+Click to queue)"); } } else if (act == "build_road" || act == "weg_bouwen") { - bool isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildRoad); + isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildRoad); bool isBuildingRoad = unit->isBuilding() && unit->buildingImprovementId.startsWith("road"); bool hasRoute = unit->hasRoadRoute(); - bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildRoad); + bool isQueued = (unit->currentAction.type == UnitActionType::BuildRoad) || unit->hasQueuedActionType(UnitActionType::BuildRoad); isActive = isMode || isBuildingRoad || hasRoute || isQueued; iconFile = "images/actions/road.png"; if (isBuildingRoad) { 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); + tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1% (Click to plan next target)").arg(pct); } else if (hasRoute) { int cur = (int)unit->roadRouteIndex + 1; int total = (int)unit->roadRoute.size(); label = Localization::instance().get("ui.unit.btn_road_route", "Route (%1/%2)").arg(cur).arg(total); tip = Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total); } 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)"); + label = Localization::instance().get("ui.unit.btn_road_queued", "Road [R]"); + tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued - Click to plan target)"); } else { label = Localization::instance().get("ui.unit.btn_build_road", "Road [R]"); - tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R) - Click target tile to build route"); + tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R) - Click target tile (Shift+Click to queue)"); } } - // Circular bubble button - painter.setBrush(isActive ? QColor(45, 85, 145, 250) : QColor(24, 32, 46, 220)); - painter.setPen(QPen(isActive ? QColor(255, 220, 50) : QColor(80, 110, 150), isActive ? 1.8 : 1.0)); + // Button styling + painter.setBrush(isMode ? QColor(55, 105, 175, 250) : (isActive ? QColor(45, 85, 145, 240) : QColor(24, 32, 46, 220))); + painter.setPen(QPen(isMode ? QColor(100, 235, 255) : (isActive ? QColor(255, 220, 50) : QColor(80, 110, 150)), (isMode || isActive) ? 1.8 : 1.0)); painter.drawRoundedRect(bRect, 6, 6); // Icon inside button - QImage aIcon = getTintedIcon(iconFile, isActive ? QColor(255, 230, 80) : primaryCol); + QImage aIcon = getTintedIcon(iconFile, (isMode || isActive) ? QColor(255, 230, 80) : primaryCol); painter.drawImage(QRect(bRect.left() + 8, bRect.top() + 10, 20, 20), aIcon); // Label painter.setFont(QFont("sans-serif", 7, QFont::Bold)); - painter.setPen(isActive ? QColor(255, 235, 90) : QColor(210, 225, 245)); + painter.setPen((isMode || isActive) ? QColor(255, 235, 90) : QColor(210, 225, 245)); painter.drawText(QRect(bRect.left() + 32, bRect.top(), bRect.width() - 36, bRect.height()), Qt::AlignLeft | Qt::AlignVCenter, label); m_activeActionBubbles.push_back({bRect, act, tip}); @@ -2541,39 +2773,29 @@ void QSceneRenderer::mousePressEvent(QMouseEvent *event) return; } - // 4. Action Bubbles clicked + // 4. Action Bubbles clicked (all unit actions enter target-tile selection mode) for (const auto &ab : m_activeActionBubbles) { if (ab.rect.contains(event->pos())) { if (ab.actionId == "verplaatsen") { - if (GameManager::instance().currentActionMode() == ActionMode::Move) { - GameManager::instance().setActionMode(ActionMode::None); - } else { - GameManager::instance().setActionMode(ActionMode::Move); - } + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::Move ? ActionMode::None : ActionMode::Move + ); } else if (ab.actionId == "aanvallen") { - if (GameManager::instance().currentActionMode() == ActionMode::Attack) { - GameManager::instance().setActionMode(ActionMode::None); - } else { - GameManager::instance().setActionMode(ActionMode::Attack); - } + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::Attack ? ActionMode::None : ActionMode::Attack + ); } else if (ab.actionId == "steden_bouwen" || ab.actionId == "build_city") { - GameManager::instance().commandBuildCitySelected(m_point, m_numPoints05); + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::BuildCity ? ActionMode::None : ActionMode::BuildCity + ); } else if (ab.actionId == "boerderij_bouwen" || ab.actionId == "build_farm") { - GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05); + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::BuildFarm ? ActionMode::None : ActionMode::BuildFarm + ); } else if (ab.actionId == "build_road" || ab.actionId == "weg_bouwen") { - Unit *sel = GameManager::instance().getSelectedUnit(); - if (sel && sel->hasRoadRoute()) { - sel->clearRoadRoute(); - sel->cancelBuilding(); - GameManager::instance().setActionMode(ActionMode::None); - GameManager::instance().addFloatingText( - Localization::instance().get("ui.unit.road_route_cancelled", "Road route cancelled"), - sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 120, 100)); - } else if (GameManager::instance().currentActionMode() == ActionMode::BuildRoad) { - GameManager::instance().setActionMode(ActionMode::None); - } else { - GameManager::instance().setActionMode(ActionMode::BuildRoad); - } + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::BuildRoad ? ActionMode::None : ActionMode::BuildRoad + ); } update(); return; @@ -2586,63 +2808,88 @@ void QSceneRenderer::mousePressEvent(QMouseEvent *event) QVector3D lookat = getCameraLookAt(); vMatrix.lookAt(cameraPosition, lookat, cameraUpDirection); - // 5. Left Click: Check if directly clicked on any unit's Druppel pin - if (event->button() == Qt::LeftButton) { - for (const auto &u : GameManager::instance().getUnits()) { - if (u->isDead()) continue; + // 5. Check if directly clicked on any unit's Druppel pin + Unit *clickedPinUnit = nullptr; + for (const auto &u : GameManager::instance().getUnits()) { + if (u->isDead()) continue; - bool inFront = false; - QVector3D topPoint = u->worldPos + QVector3D(0, 0, 0.38f); - QPointF tipPos = UnitRenderer::projectToScreen(topPoint, Projection, vMatrix, width(), height(), inFront); - if (!inFront) continue; + bool inFront = false; + QVector3D topPoint = u->worldPos + QVector3D(0, 0, 0.38f); + QPointF tipPos = UnitRenderer::projectToScreen(topPoint, Projection, vMatrix, width(), height(), inFront); + if (!inFront) continue; - QPointF headCenter(tipPos.x(), tipPos.y() - 25.0f); - float distSq = (event->x() - headCenter.x()) * (event->x() - headCenter.x()) + - (event->y() - headCenter.y()) * (event->y() - headCenter.y()); + QPointF headCenter(tipPos.x(), tipPos.y() - 25.0f); + float distSq = (event->x() - headCenter.x()) * (event->x() - headCenter.x()) + + (event->y() - headCenter.y()) * (event->y() - headCenter.y()); - if (distSq <= 18.0f * 18.0f) { - // If in attack mode and clicked enemy: attack! - Unit *sel = GameManager::instance().getSelectedUnit(); - if (sel && GameManager::instance().currentActionMode() == ActionMode::Attack && u->playerId != sel->playerId) { - GameManager::instance().commandAttack(u.get()); - } else { - GameManager::instance().selectUnit(u->id); - } - update(); - return; - } + if (distSq <= 18.0f * 18.0f) { + clickedPinUnit = u.get(); + break; } } // 6. Terrain hex picking via Raycast int hitX = -1, hitY = -1; bool hitTerrain = screenToHex(event->pos(), vMatrix, hitX, hitY); + if (!hitTerrain && clickedPinUnit) { + hitX = clickedPinUnit->tileX; + hitY = clickedPinUnit->tileY; + hitTerrain = true; + } - if (hitTerrain) { - Unit *selected = GameManager::instance().getSelectedUnit(); + Unit *selected = GameManager::instance().getSelectedUnit(); + bool shiftHeld = (event->modifiers() & Qt::ShiftModifier) != 0; - if (event->button() == Qt::LeftButton) { - if (selected) { - if (GameManager::instance().currentActionMode() == ActionMode::Move) { - GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05); - GameManager::instance().setActionMode(ActionMode::None); - update(); - return; - } else if (GameManager::instance().currentActionMode() == ActionMode::BuildRoad) { - GameManager::instance().commandBuildRoadToTileSelected(hitX, hitY, m_point, m_numPoints05); - GameManager::instance().setActionMode(ActionMode::None); - update(); - return; - } else if (GameManager::instance().currentActionMode() == ActionMode::Attack) { - Unit *enemy = GameManager::instance().getUnitAt(hitX, hitY); - if (enemy && enemy->playerId != selected->playerId) { - GameManager::instance().commandAttack(enemy); + if (event->button() == Qt::LeftButton) { + // When an ActionMode is active for the selected unit, clicking selects the target tile + if (selected && GameManager::instance().currentActionMode() != ActionMode::None) { + ActionMode mode = GameManager::instance().currentActionMode(); + if (mode == ActionMode::Attack) { + Unit *enemy = (clickedPinUnit && clickedPinUnit->playerId != selected->playerId) + ? clickedPinUnit + : (hitTerrain ? GameManager::instance().getUnitAt(hitX, hitY) : nullptr); + if (enemy && enemy->playerId != selected->playerId) { + bool ok = GameManager::instance().commandAttack(enemy, m_point, m_numPoints05, shiftHeld); + if (ok && !shiftHeld) { + GameManager::instance().setActionMode(ActionMode::None); } - update(); - return; + } else if (hitTerrain) { + QVector2D w = Pathfinder::hexToWorld(hitX, hitY); + float gz = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05); + GameManager::instance().addFloatingText( + Localization::instance().get("ui.unit.no_enemy_on_tile", "No enemy on target tile!"), + QVector3D(w.x(), w.y(), gz + 0.8f), QColor(255, 80, 80)); } + update(); + return; + } else if (hitTerrain) { + bool ok = false; + if (mode == ActionMode::Move) { + ok = GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld); + } else if (mode == ActionMode::BuildFarm) { + ok = GameManager::instance().commandBuildFarmAtTileSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld); + } else if (mode == ActionMode::BuildCity) { + ok = GameManager::instance().commandBuildCityAtTileSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld); + } else if (mode == ActionMode::BuildRoad) { + ok = GameManager::instance().commandBuildRoadToTileSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld); + } + // Keep ActionMode active if Shift is held so the player can keep chaining from the previous target tile + if (ok && !shiftHeld) { + GameManager::instance().setActionMode(ActionMode::None); + } + update(); + return; } + } + // Standard unit selection when no ActionMode is active + if (clickedPinUnit) { + GameManager::instance().selectUnit(clickedPinUnit->id); + update(); + return; + } + + if (hitTerrain) { // Check if there is a unit on hit tile to select Unit *unitOnTile = GameManager::instance().getUnitAt(hitX, hitY); if (unitOnTile) { @@ -2660,23 +2907,25 @@ void QSceneRenderer::mousePressEvent(QMouseEvent *event) } update(); return; - } else if (event->button() == Qt::RightButton) { - if (GameManager::instance().currentActionMode() == ActionMode::BuildRoad) { - GameManager::instance().setActionMode(ActionMode::None); - update(); - return; - } - // Right click: Attack enemy unit or move selected unit directly to hex! - if (selected) { - Unit *enemy = GameManager::instance().getUnitAt(hitX, hitY); - if (enemy && enemy->playerId != selected->playerId) { - GameManager::instance().commandAttack(enemy); - } else { - GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05); - } - update(); - return; + } + } else if (event->button() == Qt::RightButton) { + if (GameManager::instance().currentActionMode() != ActionMode::None) { + GameManager::instance().setActionMode(ActionMode::None); + update(); + return; + } + // Right click: Attack enemy unit or move selected unit to target hex (supports Shift-queueing) + if (selected && hitTerrain) { + Unit *enemy = (clickedPinUnit && clickedPinUnit->playerId != selected->playerId) + ? clickedPinUnit + : GameManager::instance().getUnitAt(hitX, hitY); + if (enemy && enemy->playerId != selected->playerId) { + GameManager::instance().commandAttack(enemy, m_point, m_numPoints05, shiftHeld); + } else { + GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld); } + update(); + return; } } @@ -2873,7 +3122,7 @@ void QSceneRenderer::keyPressEvent(QKeyEvent *e) if (zRot >= 360.0f) zRot -= 360.0f; } - // Unit action hotkeys + // Unit action hotkeys (all toggle target-tile selection mode) Unit *sel = GameManager::instance().getSelectedUnit(); if (e->key() == Qt::Key_M && sel) { GameManager::instance().setActionMode( @@ -2886,39 +3135,35 @@ void QSceneRenderer::keyPressEvent(QKeyEvent *e) ); } else if (e->key() == Qt::Key_B && sel && sel->config.canBuildCity()) { - GameManager::instance().commandBuildCitySelected(m_point, m_numPoints05); + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::BuildCity ? ActionMode::None : ActionMode::BuildCity + ); } else if (e->key() == Qt::Key_F && sel && sel->config.canBuildFarm()) { - GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05); + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::BuildFarm ? ActionMode::None : ActionMode::BuildFarm + ); } else if (e->key() == Qt::Key_R && sel && sel->config.canBuildRoad()) { - if (sel->hasRoadRoute()) { - sel->clearRoadRoute(); - sel->cancelBuilding(); - GameManager::instance().setActionMode(ActionMode::None); - GameManager::instance().addFloatingText( - Localization::instance().get("ui.unit.road_route_cancelled", "Road route cancelled"), - sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 120, 100)); - } else { - GameManager::instance().setActionMode( - GameManager::instance().currentActionMode() == ActionMode::BuildRoad ? ActionMode::None : ActionMode::BuildRoad - ); - } + GameManager::instance().setActionMode( + GameManager::instance().currentActionMode() == ActionMode::BuildRoad ? ActionMode::None : ActionMode::BuildRoad + ); } else if (e->key() == Qt::Key_Escape) { if (GameManager::instance().currentActionMode() != ActionMode::None) { GameManager::instance().setActionMode(ActionMode::None); - } else if (sel && sel->hasRoadRoute()) { - sel->clearRoadRoute(); - sel->cancelBuilding(); - GameManager::instance().addFloatingText( - Localization::instance().get("ui.unit.road_route_cancelled", "Road route cancelled"), - sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 120, 100)); } else if (sel && sel->hasQueuedAction()) { sel->clearActionQueue(); GameManager::instance().addFloatingText( Localization::instance().get("ui.unit.queue_cleared", "Queued actions cancelled"), sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80)); + } else if (sel && sel->hasRoadRoute()) { + sel->clearRoadRoute(); + sel->cancelBuilding(); + sel->currentAction = UnitAction(); + GameManager::instance().addFloatingText( + Localization::instance().get("ui.unit.road_route_cancelled", "Road route cancelled"), + sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 120, 100)); } else { GameManager::instance().deselectUnit(); } diff --git a/unit.cpp b/unit.cpp index 773595d..94dfe85 100644 --- a/unit.cpp +++ b/unit.cpp @@ -1,8 +1,10 @@ #include "unit.h" #include "pathfinder.h" #include "terrainmovementmanager.h" +#include "localization.h" #include #include +#include Unit::Unit(int uId, int pId, const UnitConfig &cfg, int startX, int startY, float groundZ) : id(uId), playerId(pId), config(cfg), tileX(startX), tileY(startY), @@ -57,6 +59,36 @@ bool Unit::hasQueuedActionType(UnitActionType type) const return false; } +bool Unit::hasQueuedImprovementAt(int tx, int ty) const +{ + if (currentAction.type == UnitActionType::BuildImprovement && + currentAction.targetX == tx && currentAction.targetY == ty) { + return true; + } + for (const auto &act : actionQueue) { + if (act.type == UnitActionType::BuildImprovement && + act.targetX == tx && act.targetY == ty) { + return true; + } + } + return false; +} + +bool Unit::hasQueuedCityAt(int tx, int ty) const +{ + if (currentAction.type == UnitActionType::BuildCity && + currentAction.targetX == tx && currentAction.targetY == ty) { + return true; + } + for (const auto &act : actionQueue) { + if (act.type == UnitActionType::BuildCity && + act.targetX == tx && act.targetY == ty) { + return true; + } + } + return false; +} + void Unit::removeQueuedActionsOfType(UnitActionType type) { for (auto it = actionQueue.begin(); it != actionQueue.end(); ) { @@ -82,23 +114,35 @@ const UnitAction* Unit::peekNextQueuedAction() const return &actionQueue.front(); } +QString Unit::getActionSummary(const UnitAction &act) const +{ + switch (act.type) { + case UnitActionType::Move: + return Localization::instance().get("ui.actions.move", "Move"); + case UnitActionType::BuildImprovement: + return Localization::instance().get("ui.actions.build_farm", "Build Farm"); + case UnitActionType::BuildCity: + return Localization::instance().get("ui.actions.found_city", "Found City"); + case UnitActionType::BuildRoad: + return Localization::instance().get("ui.actions.build_road", "Build Road"); + case UnitActionType::Attack: + return Localization::instance().get("ui.actions.attack", "Attack"); + default: + return ""; + } +} + QString Unit::getQueuedActionSummary() const { if (actionQueue.empty()) return ""; - switch (actionQueue.front().type) { - case UnitActionType::Move: - return "Move"; - case UnitActionType::BuildImprovement: - return "Build Farm"; - case UnitActionType::BuildCity: - return "Found City"; - case UnitActionType::BuildRoad: - return "Build Road"; - case UnitActionType::Attack: - return "Attack"; - default: - return "Action"; + QStringList parts; + for (size_t i = 0; i < actionQueue.size() && i < 3; ++i) { + parts.append(getActionSummary(actionQueue[i])); } + if (actionQueue.size() > 3) { + parts.append(QString("+%1").arg(actionQueue.size() - 3)); + } + return parts.join(" -> "); } void Unit::setPath(const std::vector> &newPath) @@ -122,6 +166,7 @@ void Unit::stopMovement() pathIndex = 0; moveProgress = 0.0f; state = UnitState::Idle; + currentAction = UnitAction(); clearActionQueue(); clearRoadRoute(); } diff --git a/unit.h b/unit.h index 23620a4..9685063 100644 --- a/unit.h +++ b/unit.h @@ -35,8 +35,8 @@ struct UnitAction { UnitAction() = default; UnitAction(UnitActionType t) : type(t) {} - UnitAction(UnitActionType t, int x, int y, const QString &data = "") - : type(t), targetX(x), targetY(y), extraData(data) {} + UnitAction(UnitActionType t, int x, int y, const QString &data = "", int uId = -1) + : type(t), targetX(x), targetY(y), targetUnitId(uId), extraData(data) {} UnitAction(UnitActionType t, int targetId) : type(t), targetUnitId(targetId) {} @@ -65,9 +65,10 @@ public: int targetTileX; int targetTileY; - // Action Queue + // Currently executing action & subsequent Action Queue + UnitAction currentAction; std::deque actionQueue; - int maxQueueSize = 5; + int maxQueueSize = 15; // Road Construction Route (tile-by-tile road paving) std::vector> roadRoute; @@ -88,13 +89,20 @@ public: } } + bool hasActiveAction() const { + return currentAction.isValid() || state != UnitState::Idle || hasRoadRoute(); + } + bool queueAction(const UnitAction &action); void clearActionQueue(); bool hasQueuedAction() const { return !actionQueue.empty(); } bool hasQueuedActionType(UnitActionType type) const; + bool hasQueuedImprovementAt(int tx, int ty) const; + bool hasQueuedCityAt(int tx, int ty) const; void removeQueuedActionsOfType(UnitActionType type); UnitAction popNextQueuedAction(); const UnitAction* peekNextQueuedAction() const; + QString getActionSummary(const UnitAction &act) const; QString getQueuedActionSummary() const; // Attack cooldown / flash