Highlight selected tunnels' connection in green (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT)

While a Tunnel Entry or Exit (building or construction site) is selected,
mark its connection green in the game world — the entry tile, exit tile,
and tiles between — reusing the tunnel_preview color from the placement
preview. Unpaired tunnels show no green.

- Add core findTunnelPartner(): the matching end of an existing tunnel
  tile, reusing firstTunnelFacing so the pairing scan stays single-sourced;
  unit-tested in TunnelCompletionTest.
- GameWorldView: extract the built+site tunnel index into
  collectTunnelTiles() (shared with updateTunnelGhost) and add
  drawSelectedTunnelConnections(), called from drawOverlays. Tiles are
  deduped via a set so a connection selected from both ends fills once.
- Also commits the REQ-BLD-TUNNEL-SELECT-HIGHLIGHT requirement text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
This commit is contained in:
2026-07-21 19:32:51 +02:00
parent 9a21506b5c
commit 3ac3d8a6e6
6 changed files with 220 additions and 12 deletions

View File

@@ -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

View File

@@ -113,3 +113,43 @@ TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hove
}
return TunnelCompletion{BuildingType::TunnelEntry, exitPartner};
}
std::optional<QPoint> 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<QPoint> ahead =
firstTunnelFacing(lookup, tile, rotation, rotation, maxDistance);
if (ahead.has_value())
{
const std::optional<TunnelTileInfo> 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<QPoint> behind =
firstTunnelFacing(lookup, tile, oppositeRotation(rotation), rotation, maxDistance);
if (behind.has_value())
{
const std::optional<TunnelTileInfo> info = lookup(*behind);
if (info.has_value() && info->type == BuildingType::TunnelEntry)
{
return behind;
}
}
return std::nullopt;
}
return std::nullopt;
}

View File

@@ -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<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
BuildingType type, Rotation rotation,
int maxDistance);

View File

@@ -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<QPoint> 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<QPoint> 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<QPoint> 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());
}

View File

@@ -889,20 +889,11 @@ BuildingType GameWorldView::effectiveBuilderType() const
return inTunnelMode() ? m_tunnelGhostType : *m_builderType;
}
void GameWorldView::updateTunnelGhost()
std::map<std::pair<int, int>, 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<std::pair<int, int>, 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<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles();
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const std::map<std::pair<int, int>, 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<std::pair<int, int>, TunnelTileInfo> tunnels = collectTunnelTiles();
if (tunnels.empty()) { return; }
const TunnelLookup lookup = [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const std::map<std::pair<int, int>, 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<std::pair<int, int>> highlightTiles;
for (const BuildingId id : m_selectedBuildingIds)
{
std::optional<QPoint> anchor;
std::optional<BuildingType> 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<QPoint> 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<int, int>& 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())
{

View File

@@ -1,9 +1,11 @@
#pragma once
#include <map>
#include <optional>
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <QElapsedTimer>
@@ -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<std::pair<int, int>, 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.