Compare commits
6 Commits
1cbc695bc5
...
a8a6a04f1e
| Author | SHA1 | Date | |
|---|---|---|---|
| a8a6a04f1e | |||
| a75222f111 | |||
| 9b63af6ccb | |||
| 4ca5b332cd | |||
| 0c4eb480be | |||
| b2c1ea34fd |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -100,14 +100,25 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
|
||||
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
|
||||
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
|
||||
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again.
|
||||
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.)
|
||||
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
||||
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock.
|
||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
|
||||
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
||||
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
||||
- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture.
|
||||
- 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-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires demolishing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement (invalid in normal builder mode; skipped in blueprint placement mode). This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
||||
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
|
||||
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
|
||||
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
|
||||
- **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-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.
|
||||
@@ -143,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
|
||||
|
||||
@@ -469,7 +481,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
|
||||
@@ -523,7 +535,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.
|
||||
|
||||
94
src/lib/core/BeltDragPath.cpp
Normal file
94
src/lib/core/BeltDragPath.cpp
Normal file
@@ -0,0 +1,94 @@
|
||||
#include "BeltDragPath.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace
|
||||
{
|
||||
int signOf(int value)
|
||||
{
|
||||
if (value > 0) { return 1; }
|
||||
if (value < 0) { return -1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Direction stepping from one tile to an orthogonally adjacent tile.
|
||||
Rotation directionBetween(QPoint from, QPoint to)
|
||||
{
|
||||
const QPoint delta = to - from;
|
||||
if (delta.x() > 0) { return Rotation::East; }
|
||||
if (delta.x() < 0) { return Rotation::West; }
|
||||
if (delta.y() > 0) { return Rotation::South; }
|
||||
return Rotation::North;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<BeltPathTile> computeBeltDragPath(QPoint anchor, QPoint cursor,
|
||||
Rotation orientation)
|
||||
{
|
||||
const bool horizontalFirst =
|
||||
(orientation == Rotation::East || orientation == Rotation::West);
|
||||
|
||||
// Build the ordered tile coordinates: first leg along the primary axis to the
|
||||
// corner, then the orthogonal leg to the cursor (no duplicated corner tile).
|
||||
std::vector<QPoint> coords;
|
||||
if (horizontalFirst)
|
||||
{
|
||||
const int stepX = signOf(cursor.x() - anchor.x());
|
||||
for (int x = anchor.x(); ; x += stepX)
|
||||
{
|
||||
coords.push_back(QPoint(x, anchor.y()));
|
||||
if (x == cursor.x() || stepX == 0) { break; }
|
||||
}
|
||||
const int stepY = signOf(cursor.y() - anchor.y());
|
||||
if (stepY != 0)
|
||||
{
|
||||
for (int y = anchor.y() + stepY; ; y += stepY)
|
||||
{
|
||||
coords.push_back(QPoint(cursor.x(), y));
|
||||
if (y == cursor.y()) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const int stepY = signOf(cursor.y() - anchor.y());
|
||||
for (int y = anchor.y(); ; y += stepY)
|
||||
{
|
||||
coords.push_back(QPoint(anchor.x(), y));
|
||||
if (y == cursor.y() || stepY == 0) { break; }
|
||||
}
|
||||
const int stepX = signOf(cursor.x() - anchor.x());
|
||||
if (stepX != 0)
|
||||
{
|
||||
for (int x = anchor.x() + stepX; ; x += stepX)
|
||||
{
|
||||
coords.push_back(QPoint(x, cursor.y()));
|
||||
if (x == cursor.x()) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign each tile the direction toward the next tile; the last tile keeps its
|
||||
// incoming step direction, and a single-tile path keeps the belt orientation.
|
||||
std::vector<BeltPathTile> path;
|
||||
path.reserve(coords.size());
|
||||
const std::size_t count = coords.size();
|
||||
for (std::size_t index = 0; index < count; ++index)
|
||||
{
|
||||
Rotation rotation;
|
||||
if (count == 1)
|
||||
{
|
||||
rotation = orientation;
|
||||
}
|
||||
else if (index + 1 < count)
|
||||
{
|
||||
rotation = directionBetween(coords[index], coords[index + 1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
rotation = directionBetween(coords[index - 1], coords[index]);
|
||||
}
|
||||
path.push_back(BeltPathTile{ coords[index], rotation });
|
||||
}
|
||||
return path;
|
||||
}
|
||||
27
src/lib/core/BeltDragPath.h
Normal file
27
src/lib/core/BeltDragPath.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "Rotation.h"
|
||||
|
||||
// One tile of a belt drag-placement path: the tile coordinate and the belt
|
||||
// orientation it should be given (REQ-BLD-BELT-DRAG).
|
||||
struct BeltPathTile
|
||||
{
|
||||
QPoint tile;
|
||||
Rotation rotation;
|
||||
};
|
||||
|
||||
// Computes the rectilinear (L-shaped) belt path from `anchor` to `cursor` for a
|
||||
// belt whose current orientation is `orientation` (REQ-BLD-BELT-DRAG). The path
|
||||
// first runs along the axis parallel to `orientation` (horizontal for East/West,
|
||||
// vertical for North/South), stepping toward the cursor's coordinate on that axis
|
||||
// to the corner tile, then runs along the orthogonal axis to the cursor tile. Each
|
||||
// tile is oriented to point toward the next tile along the path; the final tile
|
||||
// keeps the direction of its incoming step, and a single-tile path keeps
|
||||
// `orientation`. Returned tiles are ordered from anchor to cursor with no duplicate
|
||||
// corner tile.
|
||||
std::vector<BeltPathTile> computeBeltDragPath(QPoint anchor, QPoint cursor,
|
||||
Rotation orientation);
|
||||
@@ -11,6 +11,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
|
||||
${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
|
||||
)
|
||||
|
||||
@@ -19,6 +21,8 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
|
||||
${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
|
||||
)
|
||||
|
||||
|
||||
155
src/lib/core/TunnelCompletion.cpp
Normal file
155
src/lib/core/TunnelCompletion.cpp
Normal file
@@ -0,0 +1,155 @@
|
||||
#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};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
66
src/lib/core/TunnelCompletion.h
Normal file
66
src/lib/core/TunnelCompletion.h
Normal file
@@ -0,0 +1,66 @@
|
||||
#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);
|
||||
|
||||
// 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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -267,16 +267,23 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
{
|
||||
return computeInputPorts(b.bodyCells, b.outputPorts);
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
const std::vector<Port>& outputPorts) const
|
||||
{
|
||||
// Build lookup sets for quick membership checks.
|
||||
std::set<std::pair<int, int>> bodySet;
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
for (const QPoint& cell : bodyCells)
|
||||
{
|
||||
bodySet.insert({cell.x(), cell.y()});
|
||||
}
|
||||
|
||||
std::set<std::pair<int, int>> outputPortTiles;
|
||||
for (const Port& port : b.outputPorts)
|
||||
for (const Port& port : outputPorts)
|
||||
{
|
||||
outputPortTiles.insert({port.tile.x(), port.tile.y()});
|
||||
}
|
||||
@@ -294,7 +301,7 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
std::set<std::pair<int, int>> seen;
|
||||
std::vector<Port> inputPorts;
|
||||
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
for (const QPoint& cell : bodyCells)
|
||||
{
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
@@ -317,6 +324,30 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
return inputPorts;
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::getInputPorts(BuildingId id) const
|
||||
{
|
||||
if (const Building* building = findBuilding(id))
|
||||
{
|
||||
return building->inputPorts;
|
||||
}
|
||||
if (const ConstructionSite* site = findSite(id))
|
||||
{
|
||||
// A site stores no ports; derive its output ports from the mask (absolute)
|
||||
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
|
||||
const BuildingDef* def = findBuildingDef(site->type);
|
||||
if (def == nullptr) { return {}; }
|
||||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
|
||||
std::vector<Port> outputPortsAbsolute;
|
||||
outputPortsAbsolute.reserve(mask.outputPorts.size());
|
||||
for (const Port& port : mask.outputPorts)
|
||||
{
|
||||
outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction });
|
||||
}
|
||||
return computeInputPorts(site->bodyCells, outputPortsAbsolute);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
|
||||
{
|
||||
std::vector<const RecipeOutput*> eligible;
|
||||
@@ -1461,6 +1492,13 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const
|
||||
std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
|
||||
BuildingType type, QPoint anchor, Rotation rot) const
|
||||
{
|
||||
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
||||
// tunnel requires demolishing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
||||
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const BuildingDef* def = findBuildingDef(type);
|
||||
if (!def) { return std::nullopt; }
|
||||
|
||||
|
||||
@@ -171,6 +171,12 @@ public:
|
||||
// Find nearest operational building of the given type; nullptr if none.
|
||||
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;
|
||||
|
||||
// Input-capable adjacent tiles for a building or construction site
|
||||
// (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the
|
||||
// outside adjacent tile and Port.direction is the belt facing that points into
|
||||
// the target. Output-port edges are excluded. Empty for an unknown id.
|
||||
std::vector<Port> getInputPorts(BuildingId id) const;
|
||||
|
||||
// Register / unregister tile occupancy for ECS station entities.
|
||||
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
|
||||
void unregisterTileOccupancy(const std::vector<QPoint>& cells);
|
||||
@@ -246,6 +252,9 @@ private:
|
||||
void initShipyardBuffers(Building& b) const;
|
||||
void initSalvageBayBuffer(Building& b) const;
|
||||
std::vector<Port> computeInputPorts(const Building& b) const;
|
||||
// Core input-edge scan shared by operational buildings and construction sites.
|
||||
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
|
||||
const std::vector<Port>& outputPorts) const;
|
||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
||||
bool bodyCellsWithinWorldBounds(
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
|
||||
157
src/test/BeltDragPathTest.cpp
Normal file
157
src/test/BeltDragPathTest.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "BeltDragPath.h"
|
||||
#include "Rotation.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::vector<QPoint> tilesOf(const std::vector<BeltPathTile>& path)
|
||||
{
|
||||
std::vector<QPoint> tiles;
|
||||
for (const BeltPathTile& entry : path) { tiles.push_back(entry.tile); }
|
||||
return tiles;
|
||||
}
|
||||
|
||||
static std::vector<Rotation> rotationsOf(const std::vector<BeltPathTile>& path)
|
||||
{
|
||||
std::vector<Rotation> rotations;
|
||||
for (const BeltPathTile& entry : path) { rotations.push_back(entry.rotation); }
|
||||
return rotations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single tile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Single-tile path keeps the belt orientation")
|
||||
{
|
||||
for (Rotation orientation : { Rotation::North, Rotation::East,
|
||||
Rotation::South, Rotation::West })
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(3, 4), QPoint(3, 4), orientation);
|
||||
REQUIRE(path.size() == 1);
|
||||
REQUIRE(path[0].tile == QPoint(3, 4));
|
||||
REQUIRE(path[0].rotation == orientation);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Straight runs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Straight horizontal run faces along the row toward the cursor")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(3, 0), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(3, 0) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::East, Rotation::East, Rotation::East, Rotation::East });
|
||||
}
|
||||
|
||||
TEST_CASE("Straight horizontal run toward the left faces West")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::West, Rotation::West, Rotation::West });
|
||||
}
|
||||
|
||||
TEST_CASE("Straight vertical run faces along the column toward the cursor")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(0, 3), Rotation::South);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(0, 3) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::South, Rotation::South });
|
||||
}
|
||||
|
||||
// A vertical target with a horizontal orientation still yields a straight vertical
|
||||
// line (the parallel-axis leg is zero-length).
|
||||
TEST_CASE("Vertical target with horizontal orientation is a straight vertical line")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(2, 0), QPoint(2, 2), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::South });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// L-shaped paths — horizontal-first (East/West orientation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("East orientation goes horizontal then vertical (down-right)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(2, 2), Rotation::East);
|
||||
// Leg 1 East to the corner (2,0), then Leg 2 South to the cursor (2,2).
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::East, Rotation::East, Rotation::South, Rotation::South,
|
||||
Rotation::South });
|
||||
}
|
||||
|
||||
TEST_CASE("East orientation with cursor up-left goes horizontal (West) then vertical (North)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(-2, -2), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0), QPoint(-2, -1),
|
||||
QPoint(-2, -2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::West, Rotation::West, Rotation::North, Rotation::North,
|
||||
Rotation::North });
|
||||
}
|
||||
|
||||
TEST_CASE("West orientation is horizontal-first as well (up-right cursor)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(2, -2), Rotation::West);
|
||||
// Horizontal axis first: East toward the cursor to the corner (2,0), then North.
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, -1), QPoint(2, -2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::East, Rotation::East, Rotation::North, Rotation::North,
|
||||
Rotation::North });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// L-shaped paths — vertical-first (North/South orientation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("South orientation goes vertical then horizontal (down-right)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(2, 2), Rotation::South);
|
||||
// Leg 1 South to the corner (0,2), then Leg 2 East to the cursor (2,2).
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(1, 2), QPoint(2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::East, Rotation::East,
|
||||
Rotation::East });
|
||||
}
|
||||
|
||||
TEST_CASE("North orientation is vertical-first (down-left cursor)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(-2, 2), Rotation::North);
|
||||
// Vertical axis first: South toward the cursor to the corner (0,2), then West.
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(-1, 2), QPoint(-2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::West, Rotation::West,
|
||||
Rotation::West });
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
@@ -1113,6 +1115,32 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
|
||||
bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place",
|
||||
"[building][rotate-in-place]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||||
int stock = 0;
|
||||
std::mt19937 rng(0);
|
||||
BuildingId nextBuildingId = 1;
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextBuildingId]() { return nextBuildingId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng);
|
||||
|
||||
// Even with a coincident same-type tunnel under the ghost, rotate-in-place is
|
||||
// never offered for tunnels (REQ-BLD-ROTATE-IN-PLACE exception).
|
||||
bs.place(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0);
|
||||
bs.place(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
|
||||
|
||||
REQUIRE_FALSE(
|
||||
bs.findRotateInPlaceTarget(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value());
|
||||
REQUIRE_FALSE(
|
||||
bs.findRotateInPlaceTarget(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
|
||||
"[building][rotate-in-place]")
|
||||
{
|
||||
@@ -1421,3 +1449,106 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace
|
||||
{
|
||||
QPoint directionDelta(Rotation direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case Rotation::North: return QPoint(0, -1);
|
||||
case Rotation::East: return QPoint(1, 0);
|
||||
case Rotation::South: return QPoint(0, 1);
|
||||
case Rotation::West: return QPoint(-1, 0);
|
||||
}
|
||||
return QPoint(0, 0);
|
||||
}
|
||||
|
||||
bool hasInputPort(const std::vector<Port>& ports, QPoint tile, Rotation direction)
|
||||
{
|
||||
for (const Port& port : ports)
|
||||
{
|
||||
if (port.tile == tile && port.direction == direction) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Advances the sim until the given site becomes an operational building, or a
|
||||
// safety cap is reached.
|
||||
void buildToCompletion(BuildingSystem& bs, BeltSystem& belts, BuildingId id,
|
||||
Tick& tick)
|
||||
{
|
||||
for (int i = 0; i < 20000 && bs.findBuilding(id) == nullptr; ++i)
|
||||
{
|
||||
runTicks(bs, belts, 1, tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
// Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East.
|
||||
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||
|
||||
const std::vector<Port> ports = f.bs.getInputPorts(id);
|
||||
|
||||
// Every perimeter edge except the output-port edge at (1,1), each pointing in.
|
||||
REQUIRE(ports.size() == 6);
|
||||
REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East));
|
||||
REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South));
|
||||
REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West));
|
||||
REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South));
|
||||
REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East));
|
||||
REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North));
|
||||
|
||||
// The output-port tile is never an input port.
|
||||
REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North));
|
||||
REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West));
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building",
|
||||
"[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
Tick tick = 0;
|
||||
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||
|
||||
const std::vector<Port> sitePorts = f.bs.getInputPorts(id);
|
||||
buildToCompletion(f.bs, f.belts, id, tick);
|
||||
REQUIRE(f.bs.findBuilding(id) != nullptr);
|
||||
const std::vector<Port> builtPorts = f.bs.getInputPorts(id);
|
||||
|
||||
// The operational path (stored inputPorts) agrees with the site path (mask-derived).
|
||||
REQUIRE(builtPorts.size() == sitePorts.size());
|
||||
for (const Port& port : sitePorts)
|
||||
{
|
||||
REQUIRE(hasInputPort(builtPorts, port.tile, port.direction));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value();
|
||||
const ConstructionSite* site = f.bs.findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
|
||||
std::set<std::pair<int, int>> bodySet;
|
||||
for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); }
|
||||
|
||||
const std::vector<Port> ports = f.bs.getInputPorts(id);
|
||||
REQUIRE_FALSE(ports.empty());
|
||||
for (const Port& port : ports)
|
||||
{
|
||||
// Each port tile is outside the footprint...
|
||||
REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0);
|
||||
// ...and its direction points into an adjacent body cell.
|
||||
const QPoint into = port.tile + directionDelta(port.direction);
|
||||
REQUIRE(bodySet.count({into.x(), into.y()}) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ add_files(
|
||||
SimulationTest.cpp
|
||||
BeltSystemTest.cpp
|
||||
SurfaceMaskTest.cpp
|
||||
BeltDragPathTest.cpp
|
||||
TunnelCompletionTest.cpp
|
||||
BuildingTest.cpp
|
||||
BuildingConfigTest.cpp
|
||||
ShipTest.cpp
|
||||
|
||||
248
src/test/TunnelCompletionTest.cpp
Normal file
248
src/test/TunnelCompletionTest.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
#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());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,74 @@ 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;
|
||||
}
|
||||
|
||||
std::map<std::pair<int, int>, TunnelTileInfo> GameWorldView::collectTunnelTiles() const
|
||||
{
|
||||
// Index every tunnel entry/exit — built or still a construction site — by its
|
||||
// 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())
|
||||
{
|
||||
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};
|
||||
}
|
||||
}
|
||||
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 =
|
||||
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,37 +965,18 @@ void GameWorldView::placeAtTile(QPoint tile)
|
||||
return;
|
||||
}
|
||||
|
||||
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
|
||||
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
|
||||
// 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.
|
||||
if (type == BuildingType::Belt)
|
||||
{
|
||||
if (m_beltDragTiles.count(tile) > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type))
|
||||
{
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
m_beltDragTiles.insert(tile);
|
||||
}
|
||||
}
|
||||
else if (type == BuildingType::Splitter
|
||||
// (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)
|
||||
{
|
||||
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
|
||||
@@ -942,6 +985,134 @@ void GameWorldView::placeAtTile(QPoint tile)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Belt drag placement (REQ-BLD-BELT-DRAG)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
|
||||
{
|
||||
QPoint endTile = cursorTile;
|
||||
std::optional<Rotation> forcedEndRotation;
|
||||
|
||||
// If the cursor is over a non-belt building or construction site, snap the end
|
||||
// tile to the input-capable adjacent tile closest to the cursor, pointing into
|
||||
// the target (REQ-BLD-BELT-DRAG).
|
||||
std::optional<BuildingId> targetId = buildingAtTile(cursorTile);
|
||||
std::optional<BuildingType> targetType;
|
||||
if (targetId.has_value())
|
||||
{
|
||||
if (const Building* building = m_sim->getBuildings().findBuilding(*targetId))
|
||||
{
|
||||
targetType = building->type;
|
||||
}
|
||||
}
|
||||
else if (std::optional<BuildingId> siteId = siteAtTile(cursorTile); siteId.has_value())
|
||||
{
|
||||
targetId = siteId;
|
||||
if (const ConstructionSite* site = m_sim->getBuildings().findSite(*siteId))
|
||||
{
|
||||
targetType = site->type;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetId.has_value() && targetType.has_value()
|
||||
&& *targetType != BuildingType::Belt)
|
||||
{
|
||||
const std::vector<Port> inputPorts = m_sim->getBuildings().getInputPorts(*targetId);
|
||||
std::optional<Port> best;
|
||||
float bestDistanceSq = 0.0f;
|
||||
for (const Port& port : inputPorts)
|
||||
{
|
||||
const QVector2D center(static_cast<float>(port.tile.x()) + 0.5f,
|
||||
static_cast<float>(port.tile.y()) + 0.5f);
|
||||
const float distanceSq = (center - m_cursorWorldPos).lengthSquared();
|
||||
if (!best.has_value() || distanceSq < bestDistanceSq)
|
||||
{
|
||||
best = port;
|
||||
bestDistanceSq = distanceSq;
|
||||
}
|
||||
}
|
||||
if (best.has_value())
|
||||
{
|
||||
endTile = best->tile;
|
||||
forcedEndRotation = best->direction;
|
||||
}
|
||||
}
|
||||
|
||||
m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, endTile, m_ghostRotation);
|
||||
if (forcedEndRotation.has_value() && !m_beltDragPath.empty())
|
||||
{
|
||||
// The end tile points into the target, overriding its incoming-step
|
||||
// orientation (REQ-BLD-BELT-DRAG "Snapping to a building").
|
||||
m_beltDragPath.back().rotation = *forcedEndRotation;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath() const
|
||||
{
|
||||
std::vector<BeltDragResolved> resolved;
|
||||
resolved.reserve(m_beltDragPath.size());
|
||||
|
||||
const BuildingDef* def = findBuildingDef(BuildingType::Belt);
|
||||
const int beltCost = (def != nullptr) ? def->cost : 0;
|
||||
const int stock = m_sim->getBuildingBlocksStock();
|
||||
int spent = 0;
|
||||
|
||||
for (const BeltPathTile& entry : m_beltDragPath)
|
||||
{
|
||||
BeltDragResolved item;
|
||||
const std::optional<BuildingId> rotateTarget =
|
||||
m_sim->getBuildings().findRotateInPlaceTarget(
|
||||
BuildingType::Belt, entry.tile, entry.rotation);
|
||||
if (rotateTarget.has_value())
|
||||
{
|
||||
// A tile holding only a belt (or belt site) is re-oriented, no cost.
|
||||
item.action = BeltTileAction::RotateInPlace;
|
||||
item.affordable = true;
|
||||
item.rotateId = rotateTarget;
|
||||
}
|
||||
else if (isValidPlacement(BuildingType::Belt, entry.tile, entry.rotation))
|
||||
{
|
||||
// Empty, valid cell: a new belt, subject to cumulative affordability.
|
||||
item.action = BeltTileAction::PlaceNew;
|
||||
item.affordable = (spent + beltCost <= stock);
|
||||
item.rotateId = std::nullopt;
|
||||
if (item.affordable) { spent += beltCost; }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Occupied by a non-belt building/site, or otherwise invalid terrain.
|
||||
item.action = BeltTileAction::Invalid;
|
||||
item.affordable = false;
|
||||
item.rotateId = std::nullopt;
|
||||
}
|
||||
resolved.push_back(item);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
void GameWorldView::applyBeltDragPath()
|
||||
{
|
||||
const std::vector<BeltDragResolved> resolved = resolveBeltDragPath();
|
||||
for (std::size_t index = 0; index < resolved.size(); ++index)
|
||||
{
|
||||
const BeltDragResolved& item = resolved[index];
|
||||
const BeltPathTile& entry = m_beltDragPath[index];
|
||||
if (item.action == BeltTileAction::PlaceNew && item.affordable)
|
||||
{
|
||||
enqueuePlaceBuilding(BuildingType::Belt, entry.tile, entry.rotation);
|
||||
}
|
||||
else if (item.action == BeltTileAction::RotateInPlace)
|
||||
{
|
||||
std::shared_ptr<RotateInPlaceCommand> command =
|
||||
std::make_shared<RotateInPlaceCommand>();
|
||||
command->id = *item.rotateId;
|
||||
command->newRotation = entry.rotation;
|
||||
enqueueCommand(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port glyph helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1654,14 +1825,120 @@ 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())
|
||||
{
|
||||
drawBuildingGhost(painter, *m_builderType, m_ghostTile,
|
||||
m_ghostRotation, m_ghostValid,
|
||||
/*showPortTargetGlyphs*/ true);
|
||||
if (*m_builderType == BuildingType::Belt && m_dragging)
|
||||
{
|
||||
// Belt drag: a ghost per path tile (REQ-BLD-BELT-DRAG). Rotate-in-place
|
||||
// and affordable new tiles use the belt colors; occupied/invalid tiles
|
||||
// use the invalid color; unaffordable tiles show no ghost at all.
|
||||
const std::vector<BeltDragResolved> resolved = resolveBeltDragPath();
|
||||
for (std::size_t index = 0; index < resolved.size(); ++index)
|
||||
{
|
||||
const BeltDragResolved& item = resolved[index];
|
||||
if (item.action == BeltTileAction::PlaceNew && !item.affordable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const BeltPathTile& entry = m_beltDragPath[index];
|
||||
drawBuildingGhost(painter, BuildingType::Belt, entry.tile,
|
||||
entry.rotation,
|
||||
/*valid*/ item.action != BeltTileAction::Invalid,
|
||||
/*showPortTargetGlyphs*/ true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Blueprint placement ghost
|
||||
@@ -1959,8 +2236,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
|
||||
@@ -2061,7 +2337,20 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
if (event->button() == Qt::RightButton)
|
||||
{
|
||||
if (m_builderType.has_value()) { exitBuilderMode(); }
|
||||
if (m_builderType.has_value())
|
||||
{
|
||||
if (m_dragging)
|
||||
{
|
||||
// Cancel the in-progress belt drag without placing anything;
|
||||
// stay in belt builder mode (REQ-BLD-BELT-DRAG).
|
||||
m_dragging = false;
|
||||
m_beltDragPath.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
exitBuilderMode();
|
||||
}
|
||||
}
|
||||
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
||||
else if (m_demolishMode) { toggleDemolishMode(); }
|
||||
else if (event->modifiers() & Qt::ShiftModifier)
|
||||
@@ -2084,9 +2373,12 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
const BuildingType type = *m_builderType;
|
||||
if (type == BuildingType::Belt)
|
||||
{
|
||||
m_dragging = true;
|
||||
m_beltDragTiles.clear();
|
||||
placeAtTile(tile);
|
||||
// Deferred placement: start the drag and show the path ghost; nothing
|
||||
// is placed until release (REQ-BLD-BELT-DRAG).
|
||||
m_dragging = true;
|
||||
m_beltDragAnchor = tile;
|
||||
m_cursorWorldPos = widgetToWorld(event->pos());
|
||||
recomputeBeltDragPath(tile);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2249,15 +2541,25 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
||||
{
|
||||
const QPoint tile = widgetToTile(event->pos());
|
||||
m_cursorWorldPos = widgetToWorld(event->pos());
|
||||
|
||||
if (m_builderType.has_value())
|
||||
{
|
||||
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)
|
||||
{
|
||||
placeAtTile(tile);
|
||||
// Belt drag: update the previewed path; placement happens on release
|
||||
// (REQ-BLD-BELT-DRAG).
|
||||
recomputeBeltDragPath(tile);
|
||||
}
|
||||
}
|
||||
else if (m_blueprintMode.has_value())
|
||||
@@ -2281,8 +2583,11 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
|
||||
if (m_dragging)
|
||||
{
|
||||
// Apply the previewed belt path now that the button is released
|
||||
// (REQ-BLD-BELT-DRAG).
|
||||
applyBeltDragPath();
|
||||
m_dragging = false;
|
||||
m_beltDragTiles.clear();
|
||||
m_beltDragPath.clear();
|
||||
}
|
||||
|
||||
if (m_boxSelecting)
|
||||
@@ -2425,6 +2730,11 @@ 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); }
|
||||
}
|
||||
else if (m_blueprintMode.has_value())
|
||||
{
|
||||
@@ -2536,9 +2846,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(
|
||||
@@ -2565,7 +2877,7 @@ void GameWorldView::exitBlueprintMode()
|
||||
void GameWorldView::exitBuilderMode()
|
||||
{
|
||||
m_builderType.reset();
|
||||
m_beltDragTiles.clear();
|
||||
m_beltDragPath.clear();
|
||||
m_dragging = false;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BuilderModeExitedEvent>());
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QElapsedTimer>
|
||||
@@ -35,12 +37,14 @@
|
||||
#include "SpeedChangeRequestedEvent.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "BeltDragPath.h"
|
||||
#include "CommandManager.h"
|
||||
#include "EntitySelectionChangedEvent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
#include "TunnelCompletion.h"
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
struct Command;
|
||||
@@ -204,6 +208,40 @@ 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();
|
||||
// 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.
|
||||
enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid };
|
||||
struct BeltDragResolved
|
||||
{
|
||||
BeltTileAction action;
|
||||
bool affordable; // meaningful only for PlaceNew
|
||||
std::optional<BuildingId> rotateId; // set only for RotateInPlace
|
||||
};
|
||||
// Recomputes m_beltDragPath from m_beltDragAnchor to cursorTile using the
|
||||
// current ghost orientation.
|
||||
void recomputeBeltDragPath(QPoint cursorTile);
|
||||
// Classifies each path tile against the current sim state, applying cumulative
|
||||
// affordability to the PlaceNew tiles.
|
||||
std::vector<BeltDragResolved> resolveBeltDragPath() const;
|
||||
// Enqueues placements and rotate-in-place commands for the resolved path.
|
||||
void applyBeltDragPath();
|
||||
|
||||
// Copy-settings gesture (REQ-BLD-COPY-CONFIG): Shift+right-click copies a
|
||||
// building's configuration into m_copiedConfig; Shift+left-click applies it to
|
||||
// another building of the same type via the existing configuration commands.
|
||||
@@ -255,7 +293,20 @@ private:
|
||||
Rotation m_ghostRotation;
|
||||
QPoint m_ghostTile;
|
||||
bool m_ghostValid;
|
||||
std::set<QPoint, QPointCompare> m_beltDragTiles;
|
||||
// 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.
|
||||
std::vector<BeltPathTile> m_beltDragPath;
|
||||
QPoint m_beltDragAnchor;
|
||||
// Last known cursor position in world (tile) units; used to pick the belt-drag
|
||||
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG).
|
||||
QVector2D m_cursorWorldPos;
|
||||
bool m_dragging;
|
||||
|
||||
std::optional<Blueprint> m_blueprintMode;
|
||||
|
||||
@@ -51,6 +51,7 @@ struct OverlayVisuals
|
||||
QColor copyConfig;
|
||||
QColor lockedAsteroid;
|
||||
QColor modalDim;
|
||||
QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE)
|
||||
};
|
||||
|
||||
struct ToastVisuals
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user