diff --git a/docs/requirements.md b/docs/requirements.md index 576063e..35c44a2 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -154,6 +154,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - Pairing is one-to-one: each Tunnel Entry pairs with at most one Tunnel Exit, and vice versa. A Tunnel Exit is claimed by the nearest Tunnel Entry that can validly reach it; all other entries for which it would otherwise qualify are unpaired. - When one end of a pair is demolished, the pair is dissolved and any items currently in transit are discarded. - REQ-BLD-TUNNEL-TRANSIT: **Tunnel transit.** Items inside a tunnel are not rendered (they travel invisibly). Transit time equals the tile-coordinate distance between entry and exit divided by `world.toml [world].belt_speed_tiles_per_second`, matching the time a chain of belt tiles of equivalent length would take. Multiple items may be in transit simultaneously, spaced as they would be on a belt chain of the same length. Clearing a tunnel entry or exit tile (REQ-UI-BELT-CLEAR) also discards all items currently in transit through that tunnel. +- REQ-BLD-TUNNEL-SELECT-HIGHLIGHT: **Selected-tunnel connection highlight.** While a Tunnel Entry or Tunnel Exit — operational building or construction site — is part of the current selection (single selection or multi-selection, REQ-UI-MULTI-SELECT), its tunnel connection is marked green in the game world, using the same `visuals.toml [overlays].tunnel_preview` green as the placement connection preview (REQ-BLD-TUNNEL-MODE). The matching end is found by applying the pairing scan of REQ-BLD-TUNNEL-PAIR over both built tunnels **and** construction-site tunnels (site-inclusive, matching the placement preview): for a selected entry, the first same-direction tunnel within `tunnel_max_distance` along its facing direction, if it is a Tunnel Exit; for a selected exit, the first same-direction tunnel within `tunnel_max_distance` opposite its facing direction, if it is a Tunnel Entry. When a matching end is found, the entry tile, the exit tile, and every tile strictly between them (along the tunnel's straight run) are marked green. A selected tunnel with no matching end shows no green highlight (it still receives the normal selection outline). In multi-selection each selected tunnel end that has a matching end contributes its connection, and a given connection is shown whenever either of its ends is selected. The highlight is presentation-only and has no effect on the simulation. ## Material Transport & Buffers diff --git a/src/lib/core/TunnelCompletion.cpp b/src/lib/core/TunnelCompletion.cpp index 7598697..d8c2f4d 100644 --- a/src/lib/core/TunnelCompletion.cpp +++ b/src/lib/core/TunnelCompletion.cpp @@ -113,3 +113,43 @@ TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hove } return TunnelCompletion{BuildingType::TunnelEntry, exitPartner}; } + +std::optional findTunnelPartner(const TunnelLookup& lookup, QPoint tile, + BuildingType type, Rotation rotation, + int maxDistance) +{ + if (type == BuildingType::TunnelEntry) + { + // An entry pairs with the first same-direction tunnel ahead, if it is an exit. + const std::optional ahead = + firstTunnelFacing(lookup, tile, rotation, rotation, maxDistance); + if (ahead.has_value()) + { + const std::optional info = lookup(*ahead); + if (info.has_value() && info->type == BuildingType::TunnelExit) + { + return ahead; + } + } + return std::nullopt; + } + + if (type == BuildingType::TunnelExit) + { + // An exit is claimed by the first same-direction tunnel behind it, if it is an + // entry (its forward search reaches this exit as its first same-direction tunnel). + const std::optional behind = + firstTunnelFacing(lookup, tile, oppositeRotation(rotation), rotation, maxDistance); + if (behind.has_value()) + { + const std::optional info = lookup(*behind); + if (info.has_value() && info->type == BuildingType::TunnelEntry) + { + return behind; + } + } + return std::nullopt; + } + + return std::nullopt; +} diff --git a/src/lib/core/TunnelCompletion.h b/src/lib/core/TunnelCompletion.h index 57678fb..4c85c82 100644 --- a/src/lib/core/TunnelCompletion.h +++ b/src/lib/core/TunnelCompletion.h @@ -52,3 +52,15 @@ struct TunnelCompletion TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hoverTile, Rotation rotation, int maxDistance, QVector2D cursorWorldPos); + +// Finds the matching end of an existing tunnel building at `tile` of the given `type` +// (TunnelEntry or TunnelExit) facing `rotation`, applying the pairing scan of +// REQ-BLD-TUNNEL-PAIR over `lookup`: +// - for an entry, the first same-direction tunnel along its facing direction, if it +// is an exit; +// - for an exit, the first same-direction tunnel opposite its facing direction, if it +// is an entry. +// Returns the partner tile, or std::nullopt when the tunnel is unpaired. +std::optional findTunnelPartner(const TunnelLookup& lookup, QPoint tile, + BuildingType type, Rotation rotation, + int maxDistance); diff --git a/src/test/TunnelCompletionTest.cpp b/src/test/TunnelCompletionTest.cpp index 7737366..8c3e924 100644 --- a/src/test/TunnelCompletionTest.cpp +++ b/src/test/TunnelCompletionTest.cpp @@ -169,3 +169,80 @@ TEST_CASE("A candidate beyond the max distance is not matched") REQUIRE(result.resolvedType == BuildingType::TunnelEntry); REQUIRE(!result.partnerTile.has_value()); } + +// --------------------------------------------------------------------------- +// findTunnelPartner: the matching end of an existing selected tunnel +// (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT) +// --------------------------------------------------------------------------- + +TEST_CASE("findTunnelPartner: a selected entry finds its exit ahead") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; + tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; + + const std::optional partner = findTunnelPartner( + makeLookup(tunnels), QPoint(2, 5), BuildingType::TunnelEntry, Rotation::East, 10); + + REQUIRE(partner.has_value()); + REQUIRE(*partner == QPoint(6, 5)); +} + +TEST_CASE("findTunnelPartner: a selected exit finds its entry behind") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; + tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; + + const std::optional partner = findTunnelPartner( + makeLookup(tunnels), QPoint(6, 5), BuildingType::TunnelExit, Rotation::East, 10); + + REQUIRE(partner.has_value()); + REQUIRE(*partner == QPoint(2, 5)); +} + +TEST_CASE("findTunnelPartner: an unpaired tunnel has no partner") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; + + REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5), + BuildingType::TunnelEntry, Rotation::East, 10).has_value()); +} + +TEST_CASE("findTunnelPartner: a same-direction tunnel between blocks the pairing") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // selected + tunnels[{4, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // blocker + tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; + + // The scan from the selected entry stops at the same-direction entry, which is not + // an exit, so the exit beyond it is not paired. + REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5), + BuildingType::TunnelEntry, Rotation::East, 10).has_value()); +} + +TEST_CASE("findTunnelPartner: a differently-facing tunnel is skipped") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; + tunnels[{4, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::North}; // skipped + tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // match + + const std::optional partner = findTunnelPartner( + makeLookup(tunnels), QPoint(2, 5), BuildingType::TunnelEntry, Rotation::East, 10); + + REQUIRE(partner.has_value()); + REQUIRE(*partner == QPoint(6, 5)); +} + +TEST_CASE("findTunnelPartner: a partner beyond the max distance is not matched") +{ + TunnelMap tunnels; + tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; + tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // distance 4 + + REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5), + BuildingType::TunnelEntry, Rotation::East, 3).has_value()); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 47eb67a..b57b967 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -889,20 +889,11 @@ BuildingType GameWorldView::effectiveBuilderType() const return inTunnelMode() ? m_tunnelGhostType : *m_builderType; } -void GameWorldView::updateTunnelGhost() +std::map, TunnelTileInfo> GameWorldView::collectTunnelTiles() const { - 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. + // single-cell tile, so a just-placed tunnel (not yet constructed) is matchable + // (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). std::map, TunnelTileInfo> tunnels; for (const Building& b : m_sim->getBuildings().getAllBuildings()) { @@ -918,7 +909,22 @@ void GameWorldView::updateTunnelGhost() tunnels[{s.anchor.x(), s.anchor.y()}] = TunnelTileInfo{s.type, s.rotation}; } } + return tunnels; +} +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; + } + + const std::map, TunnelTileInfo> tunnels = collectTunnelTiles(); const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional { const std::map, TunnelTileInfo>::const_iterator it = @@ -1819,8 +1825,71 @@ void GameWorldView::drawBeams(QPainter& painter) painter.setRenderHints(savedHints); } +void GameWorldView::drawSelectedTunnelConnections(QPainter& painter) +{ + if (m_selectedBuildingIds.empty()) { return; } + + const std::map, TunnelTileInfo> tunnels = collectTunnelTiles(); + if (tunnels.empty()) { return; } + + 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; + }; + + // Collect the tiles to highlight in a set so a connection selected from both ends + // (or overlapping runs) is filled exactly once — filling a semi-transparent green + // twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). + std::set> highlightTiles; + for (const BuildingId id : m_selectedBuildingIds) + { + std::optional anchor; + std::optional type; + Rotation rotation = Rotation::East; + if (const Building* b = m_sim->getBuildings().findBuilding(id)) + { + anchor = b->anchor; type = b->type; rotation = b->rotation; + } + else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id)) + { + anchor = s->anchor; type = s->type; rotation = s->rotation; + } + if (!type.has_value() + || (*type != BuildingType::TunnelEntry && *type != BuildingType::TunnelExit)) + { + continue; + } + + const std::optional partner = findTunnelPartner( + lookup, *anchor, *type, rotation, m_config->world.tunnelMaxDistance_tiles); + if (!partner.has_value()) { continue; } + + // Add every tile from the selected end to its partner inclusive (colinear run). + const QPoint delta = *partner - *anchor; + const QPoint stepDir((delta.x() > 0) - (delta.x() < 0), + (delta.y() > 0) - (delta.y() < 0)); + for (QPoint t = *anchor; ; t += stepDir) + { + highlightTiles.insert({t.x(), t.y()}); + if (t == *partner) { break; } + } + } + + const QColor green = m_visuals->overlays.tunnelPreview; + for (const std::pair& tile : highlightTiles) + { + painter.fillRect(tileRect(QPoint(tile.first, tile.second)), green); + } +} + void GameWorldView::drawOverlays(QPainter& painter) { + // Green connection highlight for any selected tunnel end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). + drawSelectedTunnelConnections(painter); + // Builder-mode ghost if (m_builderType.has_value()) { diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 43412c4..4a59dd4 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include #include +#include #include #include @@ -42,6 +44,7 @@ #include "Rotation.h" #include "Tick.h" #include "TickDriver.h" +#include "TunnelCompletion.h" #include "VisualsConfig.h" struct Command; @@ -214,6 +217,12 @@ private: // Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost // tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode. void updateTunnelGhost(); + // Indexes every tunnel entry/exit — built or still a construction site — by its + // single-cell tile. Shared by the placement preview and the selection highlight. + std::map, TunnelTileInfo> collectTunnelTiles() const; + // Draws the green connection highlight for every selected tunnel end that has a + // matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). + void drawSelectedTunnelConnections(QPainter& painter); // Belt drag placement (REQ-BLD-BELT-DRAG). // Per-path-tile decision, shared by ghost drawing and release-time placement.