Unify tunnel build mode into a single Tunnel button

This commit is contained in:
2026-07-21 21:12:06 +02:00
parent 4ca5b332cd
commit 9b63af6ccb
14 changed files with 522 additions and 63 deletions

View File

@@ -16,7 +16,7 @@ surface_mask = ["<A>"]
[[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

View File

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

View File

@@ -112,7 +112,13 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
- REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type.
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button grid contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-GRID), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
- **Resolving the type.** If neither match exists, the ghost is a Tunnel Entry (the default). If only one kind of match exists, the ghost is the kind that produces it (Tunnel Exit for an exit-completion match, Tunnel Entry for an entry-completion match). If **both** an exit-completion match (an existing Entry) and an entry-completion match (an existing Exit) exist, the mode resolves to the completion whose **existing partner building is closer to the mouse cursor position** — the actual sub-tile cursor position, not the hovered tile's center — and the ghost becomes the corresponding type (a Tunnel Exit to complete the nearer Entry, or a Tunnel Entry to complete the nearer Exit). Because the comparison uses the sub-tile cursor position, when the two partners are at the same tile distance the player can move the cursor within the hovered tile to switch which end is placed. When more than one candidate qualifies on a side, the nearest qualifying partner on that side is used.
- **Connection preview (green).** Whenever a completion match is resolved, the matched existing partner building is highlighted green, and every tile strictly between that partner and the hovered ghost tile (along the tunnel's straight run) is marked green, previewing the connection that placing the ghost would create.
- **Invalid positions.** The completion tests, type switch, and green preview apply only while the hovered position is a valid placement (REQ-BLD-PLACE-VALID). At an invalid position the ordinary invalid-colored ghost is shown (REQ-BLD-GHOST) with no green preview and no switch away from the default Tunnel Entry.
- REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished.
- REQ-BLD-DEMOLISH-CLICK: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), left-clicking a placed factory building or construction site in the game world demolishes it, following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Clicking a building that cannot be demolished (the HQ or a player defence station, per REQ-BLD-DEMOLISH), or clicking empty world space, has no effect. Demolish mode stays active after a demolition so the player can demolish further buildings without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Demolish button (REQ-UI-DEMOLISH-BUTTON).
- REQ-BLD-DEMOLISH-BOX: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, every placed factory building and construction site covered by the box is demolished, each following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Buildings that cannot be demolished (the HQ and player defence stations, per REQ-BLD-DEMOLISH) are excluded from the box demolition; ships and defence stations are never affected.
@@ -474,7 +480,7 @@ The screen is divided into two columns: a main column (75% width) containing the
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
- **1** — Belt, **2** — Splitter, **3** — Tunnel Entry, **4** — Tunnel Exit.
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
### Debug Draw
@@ -528,7 +534,7 @@ The screen is divided into two columns: a main column (75% width) containing the
### Build Button Grid
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping.
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks".
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Demolish button (REQ-UI-DEMOLISH-BUTTON) is not a building type and has no config-defined tooltip.
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled.

View File

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

View File

@@ -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<float>(tile.x()) + 0.5f,
static_cast<float>(tile.y()) + 0.5f);
return (center - cursorWorldPos).lengthSquared();
}
} // namespace
std::optional<QPoint> 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<TunnelTileInfo> 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<QPoint> exitPartner;
if (const std::optional<QPoint> ahead =
firstTunnelFacing(lookup, hoverTile, rotation, rotation, maxDistance);
ahead.has_value())
{
const std::optional<TunnelTileInfo> 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<QPoint> entryPartner;
if (const std::optional<QPoint> behind =
firstTunnelFacing(lookup, hoverTile, oppositeRotation(rotation), rotation,
maxDistance);
behind.has_value())
{
const std::optional<TunnelTileInfo> 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};
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include <functional>
#include <optional>
#include <QPoint>
#include <QVector2D>
#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<std::optional<TunnelTileInfo>(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<QPoint> 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<QPoint> 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);

View File

@@ -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<TunnelLink> 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<TunnelTileInfo>
{
const std::map<std::pair<int, int>, TunnelEntryTile>::const_iterator teIt =
m_tunnelEntries.find(key(tile));
if (teIt != m_tunnelEntries.end())
{
return TunnelTileInfo{BuildingType::TunnelEntry, teIt->second.direction};
}
const std::map<std::pair<int, int>, 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<const std::pair<int, int>, 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<QPoint> 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<std::pair<int, int>, 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<double>((*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<std::pair<int, int>, 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<double>(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));
}
}

View File

@@ -9,6 +9,7 @@ add_files(
BeltSystemTest.cpp
SurfaceMaskTest.cpp
BeltDragPathTest.cpp
TunnelCompletionTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -0,0 +1,171 @@
#include "catch.hpp"
#include <map>
#include <optional>
#include <utility>
#include <QPoint>
#include <QVector2D>
#include "BuildingType.h"
#include "Rotation.h"
#include "TunnelCompletion.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
namespace
{
using TunnelMap = std::map<std::pair<int, int>, TunnelTileInfo>;
TunnelLookup makeLookup(const TunnelMap& tunnels)
{
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
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<float>(tile.x()) + 0.5f,
static_cast<float>(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());
}

View File

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

View File

@@ -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<std::pair<int, int>, 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<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;
};
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(

View File

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

View File

@@ -51,6 +51,7 @@ struct OverlayVisuals
QColor copyConfig;
QColor lockedAsteroid;
QColor modalDim;
QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE)
};
struct ToastVisuals

View File

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