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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user