diff --git a/bin/app/data/config/buildings.toml b/bin/app/data/config/buildings.toml index c6956af..874a4b5 100644 --- a/bin/app/data/config/buildings.toml +++ b/bin/app/data/config/buildings.toml @@ -16,7 +16,7 @@ surface_mask = [""] [[building]] id = "tunnel_entry" -tooltip = "Sends items underground to a matching tunnel exit, letting belts cross." +tooltip = "Sends items underground so belts can cross. Places an entry, or an exit when it would connect to a matching entry under the cursor." cost = 5 player_placeable = true construction_time_seconds = 0.5 diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index 43786e9..437ee6f 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -351,6 +351,7 @@ selected_outline = "#ffff00" # outline drawn around currently-selected buildin copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK) locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion) modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM) +tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE) # ----------------------------------------------------------------------------- # Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST) diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index c332289..716e04d 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -12,6 +12,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h + ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h PARENT_SCOPE ) @@ -21,6 +22,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp PARENT_SCOPE ) diff --git a/src/lib/core/TunnelCompletion.cpp b/src/lib/core/TunnelCompletion.cpp new file mode 100644 index 0000000..7598697 --- /dev/null +++ b/src/lib/core/TunnelCompletion.cpp @@ -0,0 +1,115 @@ +#include "TunnelCompletion.h" + +namespace +{ + +QPoint stepTile(QPoint tile, Rotation dir) +{ + switch (dir) + { + case Rotation::North: return {tile.x(), tile.y() - 1}; + case Rotation::East: return {tile.x() + 1, tile.y() }; + case Rotation::South: return {tile.x(), tile.y() + 1}; + case Rotation::West: return {tile.x() - 1, tile.y() }; + } + return tile; +} + +Rotation oppositeRotation(Rotation dir) +{ + switch (dir) + { + case Rotation::North: return Rotation::South; + case Rotation::East: return Rotation::West; + case Rotation::South: return Rotation::North; + case Rotation::West: return Rotation::East; + } + return dir; +} + +float tileCenterDistanceSq(QPoint tile, QVector2D cursorWorldPos) +{ + const QVector2D center(static_cast(tile.x()) + 0.5f, + static_cast(tile.y()) + 0.5f); + return (center - cursorWorldPos).lengthSquared(); +} + +} // namespace + +std::optional firstTunnelFacing(const TunnelLookup& lookup, QPoint start, + Rotation stepDir, Rotation targetFacing, + int maxDistance) +{ + QPoint probe = start; + for (int distance = 1; distance <= maxDistance; ++distance) + { + probe = stepTile(probe, stepDir); + const std::optional info = lookup(probe); + if (info.has_value() && info->rotation == targetFacing) + { + return probe; + } + } + return std::nullopt; +} + +TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hoverTile, + Rotation rotation, int maxDistance, + QVector2D cursorWorldPos) +{ + // Entry-completion: a hypothetical entry at the hovered tile searches ahead (its + // facing direction) for a same-direction exit to pair with. The first + // same-direction tunnel ahead completes the entry only if it is an exit. + std::optional exitPartner; + if (const std::optional ahead = + firstTunnelFacing(lookup, hoverTile, rotation, rotation, maxDistance); + ahead.has_value()) + { + const std::optional info = lookup(*ahead); + if (info.has_value() && info->type == BuildingType::TunnelExit) + { + exitPartner = ahead; + } + } + + // Exit-completion: a hypothetical exit at the hovered tile pairs with an existing + // entry behind it — one whose forward search (its facing direction) reaches the + // hovered tile as its first same-direction tunnel. Scanning backwards, the first + // same-direction tunnel completes the exit only if it is an entry. + std::optional entryPartner; + if (const std::optional behind = + firstTunnelFacing(lookup, hoverTile, oppositeRotation(rotation), rotation, + maxDistance); + behind.has_value()) + { + const std::optional info = lookup(*behind); + if (info.has_value() && info->type == BuildingType::TunnelEntry) + { + entryPartner = behind; + } + } + + // Default: a TunnelEntry with no partner. + if (!entryPartner.has_value() && !exitPartner.has_value()) + { + return TunnelCompletion{BuildingType::TunnelEntry, std::nullopt}; + } + if (entryPartner.has_value() && !exitPartner.has_value()) + { + return TunnelCompletion{BuildingType::TunnelExit, entryPartner}; + } + if (exitPartner.has_value() && !entryPartner.has_value()) + { + return TunnelCompletion{BuildingType::TunnelEntry, exitPartner}; + } + + // Both apply: complete whichever existing partner is closer to the sub-tile + // cursor position (REQ-BLD-TUNNEL-MODE). + const float entryDistanceSq = tileCenterDistanceSq(*entryPartner, cursorWorldPos); + const float exitDistanceSq = tileCenterDistanceSq(*exitPartner, cursorWorldPos); + if (entryDistanceSq <= exitDistanceSq) + { + return TunnelCompletion{BuildingType::TunnelExit, entryPartner}; + } + return TunnelCompletion{BuildingType::TunnelEntry, exitPartner}; +} diff --git a/src/lib/core/TunnelCompletion.h b/src/lib/core/TunnelCompletion.h new file mode 100644 index 0000000..57678fb --- /dev/null +++ b/src/lib/core/TunnelCompletion.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#include +#include + +#include "BuildingType.h" +#include "Rotation.h" + +// A tunnel building occupying a single tile: whether it is an entry or an exit and +// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and +// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE). +struct TunnelTileInfo +{ + BuildingType type; // TunnelEntry or TunnelExit + Rotation rotation; // facing direction +}; + +// Given a tile, returns the tunnel building on it (entry or exit) with its facing +// direction, or std::nullopt when the tile holds no tunnel building. +using TunnelLookup = std::function(QPoint)>; + +// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and +// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing +// any other direction are skipped, mirroring the "stop at the first same-direction +// tunnel" rule of REQ-BLD-TUNNEL-PAIR. Returns std::nullopt if none is found in range. +std::optional firstTunnelFacing(const TunnelLookup& lookup, QPoint start, + Rotation stepDir, Rotation targetFacing, + int maxDistance); + +// Result of resolving which tunnel end the ghost should become at a hovered tile +// (REQ-BLD-TUNNEL-MODE): the type to place and the existing tunnel it would complete. +struct TunnelCompletion +{ + BuildingType resolvedType; // TunnelEntry (default) or TunnelExit + std::optional partnerTile; // matched existing tunnel, if any +}; + +// Resolves the tunnel ghost type for a hover at `hoverTile` with the ghost facing +// `rotation` (REQ-BLD-TUNNEL-MODE): +// - exit-completion: placing an exit here would pair with an existing entry behind +// it (the entry's forward search reaches this tile as its first same-direction +// tunnel) — the ghost becomes a TunnelExit; +// - entry-completion: placing an entry here would pair with an existing exit ahead +// of it — the ghost stays a TunnelEntry. +// When both apply, the end whose existing partner is closer to `cursorWorldPos` (the +// sub-tile cursor position in world tile units) wins, so nudging the cursor within a +// single tile can flip the target. With no match the ghost stays a TunnelEntry with +// no partner. +TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hoverTile, + Rotation rotation, int maxDistance, + QVector2D cursorWorldPos); diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index 3953384..b148c3e 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -4,6 +4,7 @@ #include "StateChecksum.h" #include "Tick.h" +#include "TunnelCompletion.h" #include "tracing.h" // --------------------------------------------------------------------------- @@ -177,55 +178,57 @@ void BeltSystem::reevaluateTunnelPairing() std::vector oldLinks; std::swap(oldLinks, m_tunnelLinks); + // Tunnel index over this system's own (completed) tunnel tiles, shared with the + // scan primitive so the pairing rule lives in one place (REQ-BLD-TUNNEL-PAIR). + const TunnelLookup lookup = [this](QPoint tile) -> std::optional + { + const std::map, TunnelEntryTile>::const_iterator teIt = + m_tunnelEntries.find(key(tile)); + if (teIt != m_tunnelEntries.end()) + { + return TunnelTileInfo{BuildingType::TunnelEntry, teIt->second.direction}; + } + const std::map, TunnelExitTile>::const_iterator txIt = + m_tunnelExits.find(key(tile)); + if (txIt != m_tunnelExits.end()) + { + return TunnelTileInfo{BuildingType::TunnelExit, txIt->second.direction}; + } + return std::nullopt; + }; + for (const std::pair, TunnelEntryTile>& entry : m_tunnelEntries) { const QPoint entryPos(entry.first.first, entry.first.second); const Rotation dir = entry.second.direction; const int maxDist = entry.second.maxDistance; - for (int d = 1; d <= maxDist; ++d) + // The first same-direction tunnel ahead forms a pair only when it is an exit; + // a same-direction entry blocks (firstTunnelFacing stops at it either way). + const std::optional target = + firstTunnelFacing(lookup, entryPos, dir, dir, maxDist); + if (!target.has_value() || m_tunnelExits.find(key(*target)) == m_tunnelExits.end()) { - QPoint probe = entryPos; - for (int step = 0; step < d; ++step) - { - probe = adjacentTile(probe, dir); - } + continue; + } - // Check if a same-direction tunnel entry is here (blocks pairing) - const std::map, TunnelEntryTile>::const_iterator teIt = - m_tunnelEntries.find(key(probe)); - if (teIt != m_tunnelEntries.end() && teIt->second.direction == dir) + TunnelLink link; + link.entryTile = entryPos; + link.exitTile = *target; + // The exit is colinear with the entry along `dir`, so the tile-coordinate + // distance is the Manhattan distance. + link.length = static_cast((*target - entryPos).manhattanLength()); + + for (const TunnelLink& old : oldLinks) + { + if (old.entryTile == entryPos && old.exitTile == *target) { + link.items = old.items; break; } - - // Check if a same-direction tunnel exit is here (forms pair) - const std::map, TunnelExitTile>::const_iterator txIt = - m_tunnelExits.find(key(probe)); - if (txIt != m_tunnelExits.end()) - { - if (txIt->second.direction == dir) - { - TunnelLink link; - link.entryTile = entryPos; - link.exitTile = probe; - link.length = static_cast(d); - - for (const TunnelLink& old : oldLinks) - { - if (old.entryTile == entryPos && old.exitTile == probe) - { - link.items = old.items; - break; - } - } - - m_tunnelLinks.push_back(std::move(link)); - break; - } - // Different direction exit — skip, keep searching - } } + + m_tunnelLinks.push_back(std::move(link)); } } diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 7ad0eb9..2f910e0 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -9,6 +9,7 @@ add_files( BeltSystemTest.cpp SurfaceMaskTest.cpp BeltDragPathTest.cpp + TunnelCompletionTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/test/TunnelCompletionTest.cpp b/src/test/TunnelCompletionTest.cpp new file mode 100644 index 0000000..7737366 --- /dev/null +++ b/src/test/TunnelCompletionTest.cpp @@ -0,0 +1,171 @@ +#include "catch.hpp" + +#include +#include +#include + +#include +#include + +#include "BuildingType.h" +#include "Rotation.h" +#include "TunnelCompletion.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +namespace +{ + +using TunnelMap = std::map, TunnelTileInfo>; + +TunnelLookup makeLookup(const TunnelMap& tunnels) +{ + return [&tunnels](QPoint tile) -> std::optional + { + const TunnelMap::const_iterator it = tunnels.find({tile.x(), tile.y()}); + if (it == tunnels.end()) { return std::nullopt; } + return it->second; + }; +} + +// Cursor at the centre of a tile. +QVector2D tileCentre(QPoint tile) +{ + return QVector2D(static_cast(tile.x()) + 0.5f, + static_cast(tile.y()) + 0.5f); +} + +} // namespace + +// --------------------------------------------------------------------------- +// No match +// --------------------------------------------------------------------------- + +TEST_CASE("No tunnels: ghost defaults to a Tunnel Entry with no partner") +{ + const TunnelMap tunnels; + const TunnelCompletion result = resolveTunnelCompletion( + makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5))); + + REQUIRE(result.resolvedType == BuildingType::TunnelEntry); + REQUIRE(!result.partnerTile.has_value()); +} + +// --------------------------------------------------------------------------- +// Entry-completion: an existing exit ahead keeps the ghost an entry +// --------------------------------------------------------------------------- + +TEST_CASE("Existing exit ahead completes as an entry") +{ + TunnelMap tunnels; + tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; + + const TunnelCompletion result = resolveTunnelCompletion( + makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5))); + + REQUIRE(result.resolvedType == BuildingType::TunnelEntry); + REQUIRE(result.partnerTile.has_value()); + REQUIRE(*result.partnerTile == QPoint(8, 5)); +} + +// --------------------------------------------------------------------------- +// Exit-completion: an existing entry behind flips the ghost to an exit +// --------------------------------------------------------------------------- + +TEST_CASE("Existing entry behind completes as an exit") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; + + const TunnelCompletion result = resolveTunnelCompletion( + makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5))); + + REQUIRE(result.resolvedType == BuildingType::TunnelExit); + REQUIRE(result.partnerTile.has_value()); + REQUIRE(*result.partnerTile == QPoint(2, 5)); +} + +// --------------------------------------------------------------------------- +// Both matches: the sub-tile cursor position breaks the tie +// --------------------------------------------------------------------------- + +TEST_CASE("Both matches resolve to the partner nearer the sub-tile cursor") +{ + TunnelMap tunnels; + tunnels[{3, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // behind + tunnels[{7, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // ahead + + const TunnelLookup lookup = makeLookup(tunnels); + + SECTION("cursor near the left edge completes the entry (ghost becomes exit)") + { + const QVector2D cursor(5.1f, 5.5f); + const TunnelCompletion result = + resolveTunnelCompletion(lookup, QPoint(5, 5), Rotation::East, 10, cursor); + REQUIRE(result.resolvedType == BuildingType::TunnelExit); + REQUIRE(*result.partnerTile == QPoint(3, 5)); + } + + SECTION("cursor near the right edge completes the exit (ghost stays entry)") + { + const QVector2D cursor(5.9f, 5.5f); + const TunnelCompletion result = + resolveTunnelCompletion(lookup, QPoint(5, 5), Rotation::East, 10, cursor); + REQUIRE(result.resolvedType == BuildingType::TunnelEntry); + REQUIRE(*result.partnerTile == QPoint(7, 5)); + } +} + +// --------------------------------------------------------------------------- +// A same-direction tunnel between the ghost and a candidate blocks the pairing +// --------------------------------------------------------------------------- + +TEST_CASE("A same-direction entry between the ghost and an exit blocks completion") +{ + TunnelMap tunnels; + tunnels[{7, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // blocker + tunnels[{9, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; + + const TunnelCompletion result = resolveTunnelCompletion( + makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5))); + + // The forward scan stops at the same-direction entry, which is not an exit, so no + // entry-completion. Nothing behind, so no exit-completion either. + REQUIRE(result.resolvedType == BuildingType::TunnelEntry); + REQUIRE(!result.partnerTile.has_value()); +} + +// --------------------------------------------------------------------------- +// Differently-facing tunnels are skipped by the scan +// --------------------------------------------------------------------------- + +TEST_CASE("A differently-facing tunnel ahead is skipped, not treated as a match") +{ + TunnelMap tunnels; + tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::North}; // skipped + tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // match + + const TunnelCompletion result = resolveTunnelCompletion( + makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5))); + + REQUIRE(result.resolvedType == BuildingType::TunnelEntry); + REQUIRE(*result.partnerTile == QPoint(8, 5)); +} + +// --------------------------------------------------------------------------- +// Distance limit +// --------------------------------------------------------------------------- + +TEST_CASE("A candidate beyond the max distance is not matched") +{ + TunnelMap tunnels; + tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // distance 3 + + const TunnelCompletion result = resolveTunnelCompletion( + makeLookup(tunnels), QPoint(5, 5), Rotation::East, 2, tileCentre(QPoint(5, 5))); + + REQUIRE(result.resolvedType == BuildingType::TunnelEntry); + REQUIRE(!result.partnerTile.has_value()); +} diff --git a/src/ui/BuildButtonGrid.cpp b/src/ui/BuildButtonGrid.cpp index 98c08ae..aafa76c 100644 --- a/src/ui/BuildButtonGrid.cpp +++ b/src/ui/BuildButtonGrid.cpp @@ -35,11 +35,21 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, QWid { continue; } + // Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is + // reached through the unified tunnel build mode, not its own button + // (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-GRID). Both stay player-placeable so + // blueprints and cost totals still account for exits. + if (def.type == BuildingType::TunnelExit) + { + continue; + } m_types.push_back(def.type); m_costs[def.type] = def.cost; - const QString label = QString::fromStdString(toDisplayName(def.id)) - + "\n" + tr("%1 Building Blocks").arg(def.cost); + const QString name = (def.type == BuildingType::TunnelEntry) + ? tr("Tunnel") + : QString::fromStdString(toDisplayName(def.id)); + const QString label = name + "\n" + tr("%1 Building Blocks").arg(def.cost); QPushButton* btn = new QPushButton(label, this); btn->setCheckable(true); btn->setFixedHeight(48); diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 4e3638c..47eb67a 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -58,6 +58,7 @@ #include "ScrapDataComponent.h" #include "SurfaceMask.h" #include "Tick.h" +#include "TunnelCompletion.h" #include "EscapeMenuRequestedEvent.h" #include "TracePrintRequestedEvent.h" #include "BuildHotkeyPressedEvent.h" @@ -878,13 +879,68 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) } } +bool GameWorldView::inTunnelMode() const +{ + return m_builderType.has_value() && *m_builderType == BuildingType::TunnelEntry; +} + +BuildingType GameWorldView::effectiveBuilderType() const +{ + return inTunnelMode() ? m_tunnelGhostType : *m_builderType; +} + +void GameWorldView::updateTunnelGhost() +{ + m_tunnelGhostType = BuildingType::TunnelEntry; + m_tunnelPartnerTile.reset(); + + // The connection preview and entry/exit switch only apply at a valid placement + // (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry. + if (!m_ghostValid) + { + return; + } + + // Index every tunnel entry/exit — built or still a construction site — by its + // single-cell tile, so a just-placed entry (not yet constructed) is matchable. + std::map, TunnelTileInfo> tunnels; + for (const Building& b : m_sim->getBuildings().getAllBuildings()) + { + if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) + { + tunnels[{b.anchor.x(), b.anchor.y()}] = TunnelTileInfo{b.type, b.rotation}; + } + } + for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) + { + if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit) + { + tunnels[{s.anchor.x(), s.anchor.y()}] = TunnelTileInfo{s.type, s.rotation}; + } + } + + const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional + { + const std::map, TunnelTileInfo>::const_iterator it = + tunnels.find({tile.x(), tile.y()}); + if (it == tunnels.end()) { return std::nullopt; } + return it->second; + }; + + const TunnelCompletion completion = + resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation, + m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos); + m_tunnelGhostType = completion.resolvedType; + m_tunnelPartnerTile = completion.partnerTile; +} + void GameWorldView::placeAtTile(QPoint tile) { if (!m_builderType.has_value()) { return; } - const BuildingType type = *m_builderType; + const BuildingType type = effectiveBuilderType(); if (!isValidPlacement(type, tile, m_ghostRotation)) { @@ -903,11 +959,11 @@ void GameWorldView::placeAtTile(QPoint tile) return; } - // For placements whose UI follow-up depends on success (the tunnel entry/exit - // toggle), pre-validate occupancy + affordability so the optimistic UI update - // matches what the deferred command will do — isValidPlacement (above) already - // covered terrain/bounds. Belts are placed via the drag path (applyBeltDragPath), - // not here. + // For the splitter and tunnels, pre-validate occupancy + affordability so the + // optimistic UI update matches what the deferred command will do — isValidPlacement + // (above) already covered terrain/bounds. In tunnel mode the resolved type (entry + // or exit) is placed as-is (REQ-BLD-TUNNEL-MODE); there is no post-placement toggle. + // Belts are placed via the drag path (applyBeltDragPath), not here. if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) @@ -915,14 +971,6 @@ void GameWorldView::placeAtTile(QPoint tile) if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type)) { enqueuePlaceBuilding(type, tile, m_ghostRotation); - if (type == BuildingType::TunnelEntry) - { - m_builderType = BuildingType::TunnelExit; - } - else if (type == BuildingType::TunnelExit) - { - m_builderType = BuildingType::TunnelEntry; - } } } else @@ -1798,7 +1846,27 @@ void GameWorldView::drawOverlays(QPainter& painter) } else { - drawBuildingGhost(painter, *m_builderType, m_ghostTile, + // In tunnel mode the ghost shows the position-resolved type (entry or + // exit) and, when it would complete an existing tunnel, the matched end + // and the tiles between it and the ghost are tinted green + // (REQ-BLD-TUNNEL-MODE). + if (inTunnelMode() && m_ghostValid && m_tunnelPartnerTile.has_value()) + { + const QColor green = m_visuals->overlays.tunnelPreview; + painter.fillRect(tileRect(*m_tunnelPartnerTile), green); + + // Partner and ghost tile are colinear along the tunnel run; tint the + // tiles strictly between them. + const QPoint delta = *m_tunnelPartnerTile - m_ghostTile; + const QPoint step((delta.x() > 0) - (delta.x() < 0), + (delta.y() > 0) - (delta.y() < 0)); + for (QPoint t = m_ghostTile + step; t != *m_tunnelPartnerTile; t += step) + { + painter.fillRect(tileRect(t), green); + } + } + + drawBuildingGhost(painter, effectiveBuilderType(), m_ghostTile, m_ghostRotation, m_ghostValid, /*showPortTargetGlyphs*/ true); } @@ -2099,8 +2167,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event) { case 1: type = BuildingType::Belt; break; case 2: type = BuildingType::Splitter; break; - case 3: type = BuildingType::TunnelEntry; break; - case 4: type = BuildingType::TunnelExit; break; + case 3: type = BuildingType::TunnelEntry; break; // unified tunnel mode (REQ-BLD-TUNNEL-MODE); 4 unused } } else @@ -2412,6 +2479,13 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event) m_ghostTile = tile; m_ghostValid = isValidPlacement(*m_builderType, tile, m_ghostRotation); + if (inTunnelMode()) + { + // Resolve entry vs exit and the completion partner for the new hover + // position and sub-tile cursor (REQ-BLD-TUNNEL-MODE). + updateTunnelGhost(); + } + if (m_dragging) { // Belt drag: update the previewed path; placement happens on release @@ -2587,6 +2661,8 @@ void GameWorldView::rotateGhost(bool clockwise) m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation) : rotateCounterClockwise(m_ghostRotation); m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation); + // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE). + if (inTunnelMode()) { updateTunnelGhost(); } // Rotating during a belt drag re-picks the path's primary axis immediately, // without waiting for the next mouse move (REQ-BLD-BELT-DRAG). if (m_dragging) { recomputeBeltDragPath(m_ghostTile); } @@ -2701,9 +2777,11 @@ void GameWorldView::pasteConfigTo(BuildingId id) void GameWorldView::enterBuilderMode(BuildingType type) { - m_builderType = type; - m_ghostRotation = Rotation::East; - m_ghostValid = false; + m_builderType = type; + m_ghostRotation = Rotation::East; + m_ghostValid = false; + m_tunnelGhostType = BuildingType::TunnelEntry; + m_tunnelPartnerTile.reset(); m_demolishMode = false; m_blueprintMode.reset(); EventManager::getInstance()->sendEventImmediately( diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 3c1d8a8..43412c4 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -205,6 +205,16 @@ private: void stepSpeed(int delta); void placeAtTile(QPoint tile); + // Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type + // is TunnelEntry; the ghost then resolves to an entry or exit by hovered position. + bool inTunnelMode() const; + // The building type the ghost currently represents: the position-resolved tunnel + // type in tunnel mode, otherwise the plain builder type. + BuildingType effectiveBuilderType() const; + // Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost + // tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode. + void updateTunnelGhost(); + // Belt drag placement (REQ-BLD-BELT-DRAG). // Per-path-tile decision, shared by ghost drawing and release-time placement. enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid }; @@ -274,6 +284,12 @@ private: Rotation m_ghostRotation; QPoint m_ghostTile; bool m_ghostValid; + // Unified tunnel build mode (REQ-BLD-TUNNEL-MODE): while m_builderType is + // TunnelEntry the ghost resolves to an entry or an exit based on the hovered + // position; m_tunnelPartnerTile is the existing tunnel it would complete (drawn + // as the green connection preview), or unset when there is no completion match. + BuildingType m_tunnelGhostType = BuildingType::TunnelEntry; + std::optional m_tunnelPartnerTile; // Deferred belt drag placement (REQ-BLD-BELT-DRAG): while dragging, the // rectilinear anchor->cursor path is recomputed on each move and only applied // on release. Empty unless a belt drag is in progress. diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h index cd1efdd..e8974ab 100644 --- a/src/ui/VisualsConfig.h +++ b/src/ui/VisualsConfig.h @@ -51,6 +51,7 @@ struct OverlayVisuals QColor copyConfig; QColor lockedAsteroid; QColor modalDim; + QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE) }; struct ToastVisuals diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index 4229637..194d706 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -227,6 +227,7 @@ VisualsConfig VisualsLoader::load(const std::string& path) cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config"); cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid"); cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim"); + cfg.overlays.tunnelPreview = parseColor(requireString(ov, "tunnel_preview", "overlays"), "overlays.tunnel_preview"); } // Toast