Compare commits

...

5 Commits

19 changed files with 1179 additions and 24 deletions

View File

@@ -12,6 +12,20 @@ player_placeable = true
construction_time_seconds = 1
surface_mask = ["<A>"]
[[building]]
id = "tunnel_entry"
cost = 5
player_placeable = true
construction_time_seconds = 3
surface_mask = ["A>"]
[[building]]
id = "tunnel_exit"
cost = 5
player_placeable = true
construction_time_seconds = 3
surface_mask = ["A>"]
[[building]]
id = "miner"
cost = 15

View File

@@ -72,6 +72,16 @@ fill = "#7a7a5a"
outline = "#9a9a7a"
glyph = ""
[buildings.tunnel_entry]
fill = "#4a6a5a"
outline = "#8aaa9a"
glyph = "Te"
[buildings.tunnel_exit]
fill = "#5a6a4a"
outline = "#9aaa8a"
glyph = "Tx"
# -----------------------------------------------------------------------------
# Stations
#

View File

@@ -4,6 +4,7 @@ refund_percentage = 75
starting_building_blocks = 100
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
tunnel_max_distance = 10
[regions]
asteroid_width = 40

View File

@@ -79,10 +79,19 @@ Output port indicators are not building tiles themselves. A building may have mo
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
- If neither output has a filter, items are distributed by strict alternation.
- In all alternation cases, if one output is blocked the item goes to the other output until it unblocks.
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with Q/E. Items arriving from an adjacent belt tile whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
- REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with Q/E. Items received from the paired Tunnel Entry emerge from the output side of the exit tile — the tile adjacent in the exit's facing direction — continuing in that direction. If the exit is unpaired or its output is blocked, it holds received items until they can advance.
- REQ-BLD-TUNNEL-PAIR: **Tunnel pairing rules.** Pairing is re-evaluated for all Tunnel Entries whenever any Tunnel Entry or Tunnel Exit is placed or demolished.
- A Tunnel Entry searches tile-by-tile in its facing direction for a partner. Any tunnel building (entry or exit) that faces a *different* direction is ignored and skipped. The search stops at the first tunnel building that faces the *same* direction as the searching entry.
- If that first same-direction tunnel building is a Tunnel Exit, is within `tunnel_max_distance` tiles of the entry, and is not already paired with a closer entry, the two form a pair.
- Otherwise the entry is unpaired.
- 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.
## Material Transport & Buffers
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts and splitters.
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels.
- REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items are placed onto the belt at the output port tile regardless of that belt's direction.
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or blueprint, all items in all input buffers are cleared.
@@ -204,7 +213,7 @@ The screen is divided into three vertical sections:
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection.
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown.
- REQ-UI-CONFIG-INLINE: Recipe, blueprint, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel.
- REQ-UI-BELT-CLEAR: When one or more belt or splitter tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. This can be used to resolve stalled belts and splitters.
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
### Build Button Grid

View File

@@ -224,6 +224,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.startingBuildingBlocks = static_cast<int>(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds");
cfg.beltSpeedTilesPerSecond = requireDouble(tbl["world"]["belt_speed_tiles_per_second"], file, "world.belt_speed_tiles_per_second");
cfg.tunnelMaxDistance = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance"], file, "world.tunnel_max_distance"));
cfg.regions.asteroidWidth = static_cast<int>(requireInt(tbl["regions"]["asteroid_width"], file, "regions.asteroid_width"));
cfg.regions.playerBufferWidth = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width"], file, "regions.player_buffer_width"));

View File

@@ -42,6 +42,7 @@ struct WorldConfig
int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS
double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP
double beltSpeedTilesPerSecond; // REQ-GW-BELT-SPEED
int tunnelMaxDistance; // REQ-BLD-TUNNEL-PAIR
WorldRegions regions;
WorldExpansion expansion;

View File

@@ -10,6 +10,8 @@ std::optional<BuildingType> parseBuildingType(const std::string& id)
if (id == "salvage_bay") { return BuildingType::SalvageBay; }
if (id == "belt") { return BuildingType::Belt; }
if (id == "splitter") { return BuildingType::Splitter; }
if (id == "tunnel_entry") { return BuildingType::TunnelEntry; }
if (id == "tunnel_exit") { return BuildingType::TunnelExit; }
if (id == "hq") { return BuildingType::Hq; }
if (id == "player_defence_station") { return BuildingType::PlayerDefenceStation; }
if (id == "enemy_defence_station") { return BuildingType::EnemyDefenceStation; }
@@ -28,6 +30,8 @@ std::string buildingTypeId(BuildingType type)
case BuildingType::SalvageBay: return "salvage_bay";
case BuildingType::Belt: return "belt";
case BuildingType::Splitter: return "splitter";
case BuildingType::TunnelEntry: return "tunnel_entry";
case BuildingType::TunnelExit: return "tunnel_exit";
case BuildingType::Hq: return "hq";
case BuildingType::PlayerDefenceStation: return "player_defence_station";
case BuildingType::EnemyDefenceStation: return "enemy_defence_station";

View File

@@ -16,6 +16,8 @@ enum class BuildingType
SalvageBay,
Belt,
Splitter,
TunnelEntry,
TunnelExit,
Hq,
PlayerDefenceStation,
EnemyDefenceStation,

View File

@@ -70,10 +70,39 @@ void BeltSystem::placeSplitter(QPoint tile, Rotation outputA, Rotation outputB)
m_splitters[key(tile)] = st;
}
void BeltSystem::removeTile(QPoint tile)
void BeltSystem::placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance)
{
m_belts.erase(key(tile));
m_splitters.erase(key(tile));
m_tunnelExits.erase(key(tile));
TunnelEntryTile te;
te.direction = direction;
te.maxDistance = maxDistance;
m_tunnelEntries[key(tile)] = te;
reevaluateTunnelPairing();
}
void BeltSystem::placeTunnelExit(QPoint tile, Rotation direction)
{
m_belts.erase(key(tile));
m_splitters.erase(key(tile));
m_tunnelEntries.erase(key(tile));
TunnelExitTile tx;
tx.direction = direction;
m_tunnelExits[key(tile)] = tx;
reevaluateTunnelPairing();
}
void BeltSystem::removeTile(QPoint tile)
{
const bool wasTunnel = (m_tunnelEntries.erase(key(tile)) > 0)
| (m_tunnelExits.erase(key(tile)) > 0);
m_belts.erase(key(tile));
m_splitters.erase(key(tile));
if (wasTunnel)
{
reevaluateTunnelPairing();
}
}
void BeltSystem::setSplitterFilters(QPoint tile,
@@ -89,18 +118,114 @@ void BeltSystem::setSplitterFilters(QPoint tile,
it->second.filterB = filterB;
}
std::optional<BeltSystem::SplitterInfo> BeltSystem::getSplitterInfo(QPoint tile) const
{
const std::map<std::pair<int, int>, SplitterTile>::const_iterator it =
m_splitters.find(key(tile));
if (it == m_splitters.end())
{
return std::nullopt;
}
return SplitterInfo{
it->second.outputA,
it->second.outputB,
it->second.filterA,
it->second.filterB
};
}
// ---------------------------------------------------------------------------
// Tunnel pairing
// ---------------------------------------------------------------------------
void BeltSystem::reevaluateTunnelPairing()
{
std::vector<TunnelLink> oldLinks;
std::swap(oldLinks, m_tunnelLinks);
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)
{
QPoint probe = entryPos;
for (int step = 0; step < d; ++step)
{
probe = adjacentTile(probe, dir);
}
// 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)
{
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
}
}
}
}
// ---------------------------------------------------------------------------
// Port interface
// ---------------------------------------------------------------------------
bool BeltSystem::tryPutItem(QPoint tile, Item item)
{
const std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.find(key(tile));
if (it == m_belts.end())
const std::map<std::pair<int, int>, BeltTile>::iterator bIt = m_belts.find(key(tile));
if (bIt != m_belts.end())
{
return tryPlaceOnBelt(tile, item);
}
const std::map<std::pair<int, int>, TunnelEntryTile>::iterator teIt =
m_tunnelEntries.find(key(tile));
if (teIt != m_tunnelEntries.end())
{
TunnelEntryTile& te = teIt->second;
if (!te.front)
{
te.front = BeltItemSlot{item, 0.0};
return true;
}
if (!te.back)
{
te.back = BeltItemSlot{item, 0.0};
return true;
}
return false;
}
return tryPlaceOnBelt(tile, item);
return false;
}
std::optional<Item> BeltSystem::tryTakeItem(Port port)
@@ -142,6 +267,20 @@ std::optional<Item> BeltSystem::tryTakeItem(Port port)
}
}
const std::map<std::pair<int, int>, TunnelExitTile>::iterator txIt =
m_tunnelExits.find(key(port.tile));
if (txIt != m_tunnelExits.end())
{
TunnelExitTile& tx = txIt->second;
if (tx.direction == port.direction && tx.front && tx.front->progress >= 1.0)
{
const Item taken = tx.front->item;
tx.front = tx.back;
tx.back = std::nullopt;
return taken;
}
}
return std::nullopt;
}
@@ -178,6 +317,17 @@ std::optional<ItemType> BeltSystem::peekItem(Port port) const
}
}
const std::map<std::pair<int, int>, TunnelExitTile>::const_iterator txIt =
m_tunnelExits.find(key(port.tile));
if (txIt != m_tunnelExits.end())
{
const TunnelExitTile& tx = txIt->second;
if (tx.direction == port.direction && tx.front && tx.front->progress >= 1.0)
{
return tx.front->item.type;
}
}
return std::nullopt;
}
@@ -203,6 +353,36 @@ void BeltSystem::clearTiles(const std::vector<QPoint>& tiles)
sIt->second.frontA = std::nullopt;
sIt->second.frontB = std::nullopt;
}
const std::map<std::pair<int, int>, TunnelEntryTile>::iterator teIt =
m_tunnelEntries.find(key(tile));
if (teIt != m_tunnelEntries.end())
{
teIt->second.front = std::nullopt;
teIt->second.back = std::nullopt;
for (TunnelLink& link : m_tunnelLinks)
{
if (link.entryTile == tile)
{
link.items.clear();
}
}
}
const std::map<std::pair<int, int>, TunnelExitTile>::iterator txIt =
m_tunnelExits.find(key(tile));
if (txIt != m_tunnelExits.end())
{
txIt->second.front = std::nullopt;
txIt->second.back = std::nullopt;
for (TunnelLink& link : m_tunnelLinks)
{
if (link.exitTile == tile)
{
link.items.clear();
}
}
}
}
}
@@ -213,7 +393,9 @@ void BeltSystem::clearTiles(const std::vector<QPoint>& tiles)
void BeltSystem::tick()
{
advanceProgress();
advanceTunnelProgress();
moveItemsToNextTile();
moveTunnelItems();
routeSplitterItems();
}
@@ -288,6 +470,98 @@ void BeltSystem::advanceProgress()
}
}
void BeltSystem::advanceTunnelProgress()
{
for (std::map<std::pair<int, int>, TunnelEntryTile>::iterator it = m_tunnelEntries.begin();
it != m_tunnelEntries.end(); ++it)
{
TunnelEntryTile& te = it->second;
if (te.front)
{
te.front->progress += m_progressPerTick;
if (te.front->progress > 1.0)
{
te.front->progress = 1.0;
}
}
if (te.back)
{
te.back->progress += m_progressPerTick;
if (te.front && te.back->progress >= te.front->progress)
{
te.back->progress = te.front->progress - m_progressPerTick;
if (te.back->progress < 0.0)
{
te.back->progress = 0.0;
}
}
if (te.back->progress > 0.5)
{
te.back->progress = 0.5;
}
}
}
for (std::map<std::pair<int, int>, TunnelExitTile>::iterator it = m_tunnelExits.begin();
it != m_tunnelExits.end(); ++it)
{
TunnelExitTile& tx = it->second;
if (tx.front)
{
tx.front->progress += m_progressPerTick;
if (tx.front->progress > 1.0)
{
tx.front->progress = 1.0;
}
}
if (tx.back)
{
tx.back->progress += m_progressPerTick;
if (tx.front && tx.back->progress >= tx.front->progress)
{
tx.back->progress = tx.front->progress - m_progressPerTick;
if (tx.back->progress < 0.0)
{
tx.back->progress = 0.0;
}
}
if (tx.back->progress > 0.5)
{
tx.back->progress = 0.5;
}
}
}
for (TunnelLink& link : m_tunnelLinks)
{
for (std::size_t i = 0; i < link.items.size(); ++i)
{
TunnelTransitItem& ti = link.items[i];
ti.progress += m_progressPerTick;
if (ti.progress > link.length)
{
ti.progress = link.length;
}
if (i > 0)
{
const double maxProgress = link.items[i - 1].progress - 0.5;
if (ti.progress > maxProgress)
{
ti.progress = maxProgress;
if (ti.progress < 0.0)
{
ti.progress = 0.0;
}
}
}
}
}
}
void BeltSystem::moveItemsToNextTile()
{
// Belt items advancing into the next tile.
@@ -324,11 +598,25 @@ void BeltSystem::moveItemsToNextTile()
bt.front = bt.back;
bt.back = std::nullopt;
}
// else: splitter back occupied — item stays blocked at progress 1.0.
}
// else: no tile registered (e.g. open space, or building input port).
// Items leaving into unregistered tiles are not consumed here — the
// building pull step uses tryTakeItem for that.
else
{
const std::map<std::pair<int, int>, TunnelEntryTile>::iterator nextEntry =
m_tunnelEntries.find(key(next));
if (nextEntry != m_tunnelEntries.end() && !nextEntry->second.back)
{
if (!nextEntry->second.front)
{
nextEntry->second.front = BeltItemSlot{bt.front->item, 0.0};
}
else
{
nextEntry->second.back = BeltItemSlot{bt.front->item, 0.0};
}
bt.front = bt.back;
bt.back = std::nullopt;
}
}
}
// Splitter front slots advancing into downstream belt tiles.
@@ -341,23 +629,89 @@ void BeltSystem::moveItemsToNextTile()
if (st.frontA && st.frontA->progress >= 1.0)
{
const QPoint dest = adjacentTile(here, st.outputA);
if (tryPlaceOnBelt(dest, st.frontA->item))
if (tryPushToTile(dest, st.frontA->item, st.outputA))
{
st.frontA = std::nullopt;
}
// else: downstream belt full or absent — item stays at progress 1.0
// for building pickup via tryTakeItem.
}
if (st.frontB && st.frontB->progress >= 1.0)
{
const QPoint dest = adjacentTile(here, st.outputB);
if (tryPlaceOnBelt(dest, st.frontB->item))
if (tryPushToTile(dest, st.frontB->item, st.outputB))
{
st.frontB = std::nullopt;
}
}
}
// Tunnel exit items advancing into downstream tiles.
for (std::map<std::pair<int, int>, TunnelExitTile>::iterator it = m_tunnelExits.begin();
it != m_tunnelExits.end(); ++it)
{
TunnelExitTile& tx = it->second;
if (!tx.front || tx.front->progress < 1.0)
{
continue;
}
const QPoint here = QPoint(it->first.first, it->first.second);
const QPoint next = adjacentTile(here, tx.direction);
if (tryPushToTile(next, tx.front->item, tx.direction))
{
tx.front = tx.back;
tx.back = std::nullopt;
}
}
}
void BeltSystem::moveTunnelItems()
{
for (TunnelLink& link : m_tunnelLinks)
{
// Entry front → transit
const std::map<std::pair<int, int>, TunnelEntryTile>::iterator teIt =
m_tunnelEntries.find(key(link.entryTile));
if (teIt != m_tunnelEntries.end())
{
TunnelEntryTile& te = teIt->second;
if (te.front && te.front->progress >= 1.0)
{
const bool canEnter = link.items.empty()
|| link.items.back().progress >= 0.5;
if (canEnter)
{
TunnelTransitItem ti;
ti.item = te.front->item;
ti.progress = 0.0;
link.items.push_back(ti);
te.front = te.back;
te.back = std::nullopt;
}
}
}
// Transit front → exit back
if (!link.items.empty() && link.items.front().progress >= link.length)
{
const std::map<std::pair<int, int>, TunnelExitTile>::iterator txIt =
m_tunnelExits.find(key(link.exitTile));
if (txIt != m_tunnelExits.end())
{
TunnelExitTile& tx = txIt->second;
if (!tx.back && !tx.front)
{
tx.front = BeltItemSlot{link.items.front().item, 0.0};
link.items.erase(link.items.begin());
}
else if (!tx.back && tx.front)
{
tx.back = BeltItemSlot{link.items.front().item, 0.0};
link.items.erase(link.items.begin());
}
}
}
}
}
void BeltSystem::routeSplitterItems()
@@ -458,6 +812,65 @@ bool BeltSystem::tryPlaceOnBelt(QPoint tile, Item item)
return false; // both slots occupied
}
bool BeltSystem::tryPushToTile(QPoint dest, Item item, Rotation fromDir)
{
if (tryPlaceOnBelt(dest, item))
{
return true;
}
const std::map<std::pair<int, int>, SplitterTile>::iterator splIt =
m_splitters.find(key(dest));
if (splIt != m_splitters.end())
{
if (!splIt->second.back)
{
splIt->second.back = BeltItemSlot{item, 0.0};
splIt->second.backDir = fromDir;
return true;
}
return false;
}
const std::map<std::pair<int, int>, TunnelEntryTile>::iterator teIt =
m_tunnelEntries.find(key(dest));
if (teIt != m_tunnelEntries.end())
{
TunnelEntryTile& te = teIt->second;
if (!te.front)
{
te.front = BeltItemSlot{item, 0.0};
return true;
}
if (!te.back)
{
te.back = BeltItemSlot{item, 0.0};
return true;
}
return false;
}
const std::map<std::pair<int, int>, TunnelExitTile>::iterator txIt =
m_tunnelExits.find(key(dest));
if (txIt != m_tunnelExits.end())
{
TunnelExitTile& tx = txIt->second;
if (!tx.front)
{
tx.front = BeltItemSlot{item, 0.0};
return true;
}
if (!tx.back)
{
tx.back = BeltItemSlot{item, 0.0};
return true;
}
return false;
}
return false;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
@@ -526,4 +939,54 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
visit(vi);
}
}
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
{
const QPoint tile(entry.first.first, entry.first.second);
if (!viewportTiles.contains(tile))
{
continue;
}
const TunnelEntryTile& te = entry.second;
if (te.front)
{
VisualItem vi;
vi.type = te.front->item.type;
vi.worldPos = slotWorldPos(tile, te.direction, te.front->progress);
visit(vi);
}
if (te.back)
{
VisualItem vi;
vi.type = te.back->item.type;
vi.worldPos = slotWorldPos(tile, te.direction, te.back->progress);
visit(vi);
}
}
for (const std::pair<const std::pair<int, int>, TunnelExitTile>& entry : m_tunnelExits)
{
const QPoint tile(entry.first.first, entry.first.second);
if (!viewportTiles.contains(tile))
{
continue;
}
const TunnelExitTile& tx = entry.second;
if (tx.front)
{
VisualItem vi;
vi.type = tx.front->item.type;
vi.worldPos = slotWorldPos(tile, tx.direction, tx.front->progress);
visit(vi);
}
if (tx.back)
{
VisualItem vi;
vi.type = tx.back->item.type;
vi.worldPos = slotWorldPos(tile, tx.direction, tx.back->progress);
visit(vi);
}
}
}

View File

@@ -37,6 +37,12 @@ public:
// Register a new belt tile. Any items already on this tile are cleared.
void placeBelt(QPoint tile, Rotation direction);
// Register a new tunnel entry tile.
void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance);
// Register a new tunnel exit tile.
void placeTunnelExit(QPoint tile, Rotation direction);
// Register a new splitter tile. outputA and outputB are the two exit
// directions (e.g. West and East for a default-rotation splitter).
// Items entering from any adjacent belt whose direction points into this
@@ -52,6 +58,15 @@ public:
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB);
struct SplitterInfo
{
Rotation outputA;
Rotation outputB;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
std::optional<SplitterInfo> getSplitterInfo(QPoint tile) const;
// -- Port interface (buildings <-> belts) --------------------------------
// port.tile = the belt tile adjacent to the building
// port.direction = direction items flow on that tile
@@ -78,13 +93,20 @@ public:
private:
void advanceProgress();
void advanceTunnelProgress();
void moveItemsToNextTile();
void moveTunnelItems();
void routeSplitterItems();
// Place item into back slot of an existing belt tile at progress 0.
// Returns false if tile is not a belt or is full.
bool tryPlaceOnBelt(QPoint tile, Item item);
// Push an item to any tile type (belt, splitter, tunnel entry, tunnel exit).
bool tryPushToTile(QPoint dest, Item item, Rotation fromDir);
void reevaluateTunnelPairing();
static std::pair<int, int> key(QPoint tile);
static QPoint adjacentTile(QPoint tile, Rotation dir);
@@ -117,9 +139,41 @@ private:
std::optional<BeltItemSlot> frontB; // progress [0, 1]; routed to outputB
};
struct TunnelEntryTile
{
Rotation direction;
int maxDistance;
std::optional<BeltItemSlot> front;
std::optional<BeltItemSlot> back;
};
struct TunnelExitTile
{
Rotation direction;
std::optional<BeltItemSlot> front;
std::optional<BeltItemSlot> back;
};
struct TunnelTransitItem
{
Item item;
double progress;
};
struct TunnelLink
{
QPoint entryTile;
QPoint exitTile;
double length;
std::vector<TunnelTransitItem> items; // front (highest progress) to back
};
double m_progressPerTick; // beltSpeedTilesPerSecond / kTickRateHz
std::map<std::pair<int, int>, BeltTile> m_belts;
std::map<std::pair<int, int>, SplitterTile> m_splitters;
std::map<std::pair<int, int>, BeltTile> m_belts;
std::map<std::pair<int, int>, SplitterTile> m_splitters;
std::map<std::pair<int, int>, TunnelEntryTile> m_tunnelEntries;
std::map<std::pair<int, int>, TunnelExitTile> m_tunnelExits;
std::vector<TunnelLink> m_tunnelLinks;
};

View File

@@ -269,7 +269,8 @@ int BuildingSystem::demolish(EntityId id)
{
if (it->id == id)
{
if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter)
if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter
|| it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit)
{
m_belts.removeTile(it->anchor);
}
@@ -424,6 +425,14 @@ void BuildingSystem::tickConstruction(Tick currentTick)
mask.outputPorts[0].direction,
mask.outputPorts[1].direction);
}
else if (front.type == BuildingType::TunnelEntry)
{
m_belts.placeTunnelEntry(front.anchor, front.rotation, m_config.world.tunnelMaxDistance);
}
else if (front.type == BuildingType::TunnelExit)
{
m_belts.placeTunnelExit(front.anchor, front.rotation);
}
m_buildings.push_back(std::move(building));
@@ -902,7 +911,8 @@ bool BuildingSystem::removeBuilding(EntityId id)
{
if (it->id == id)
{
if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter)
if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter
|| it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit)
{
m_belts.removeTile(it->anchor);
}

View File

@@ -365,8 +365,10 @@ TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]
// Splitter — filter routing
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: splitter routes filtered item to matching output", "[belt]")
TEST_CASE("BeltSystem: splitter routes to preferred output when item matches both filters", "[belt]")
{
// filterA = iron_ore, filterB = {} (accept all) → iron_ore matches both.
// With nextOutputIsA=true initially, alternation sends the item to A.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
@@ -379,13 +381,11 @@ TEST_CASE("BeltSystem: splitter routes filtered item to matching output", "[belt
bs.placeBelt(tileA, Rotation::North);
bs.placeBelt(tileB, Rotation::South);
// Filter: outputA = iron_ore only; outputB = accept all.
// iron_ore matches both filters → alternation; preferred = outputA (nextOutputIsA=true).
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA (filter + alternation preferred outputA)
bs.tick(); // back -> frontA (both match, alternation, preferred A)
bs.tick(); // frontA -> tileA
bs.tick(); // item at tileA output edge
@@ -393,6 +393,163 @@ TEST_CASE("BeltSystem: splitter routes filtered item to matching output", "[belt
REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter routes item to output A when only filter A matches", "[belt]")
{
// filterA = {iron_ore}, filterB = {copper_ore}: iron_ore matches A exclusively → goes to A.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA (exclusive match to A)
bs.tick(); // frontA reaches 1.0; no downstream belt, waits for building pickup
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter routes item to output B when only filter B matches", "[belt]")
{
// filterA = {copper_ore}, filterB = {iron_ore}: iron_ore matches B exclusively → goes to B.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter alternates A then B when item matches both explicit filters", "[belt]")
{
// filterA = {iron_ore}, filterB = {iron_ore}: both match → strict alternation A, B, A.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true initially).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// Item 2 → preferred B (nextOutputIsA toggled to false).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// Item 3 → preferred A again (nextOutputIsA toggled back to true).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output", "[belt]")
{
// filterA = {copper_ore} (non-empty), filterB = {} (accept all).
// iron_ore: matchesA=false, matchesB=true → goes to B.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt]")
{
// filterA = {copper_ore}, filterB = {iron_ingot}: iron_ore matches neither → stall.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back reaches 0.5; routing fires but stalls (no filter match)
bs.tick(); // back stays at 0.5; stall persists
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blocked", "[belt]")
{
// filterA = filterB = {iron_ore}: both match → alternation.
// When preferred output is occupied, item goes to the other without toggling nextOutputIsA.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileIn(0, 0);
const QPoint tileSpl(1, 0);
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true → false after routing).
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontA = item1 at 1.0
// Item 2 → preferred B (nextOutputIsA=false → true after routing). Take item2 to free frontB.
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontB = item2 at 1.0
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// frontA still holds item1; nextOutputIsA=true (prefer A).
// Item 3: both match, preferred A is occupied → fallback to B without toggling nextOutputIsA.
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontB = item3 at 1.0
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item1 still in A
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); // item3 in B via fallback
// nextOutputIsA was not toggled by the fallback: next item should still prefer A.
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); // free frontA
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // free frontB
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item4 → A (preferA still true)
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
}
// ---------------------------------------------------------------------------
// Splitter — direct building input (no output belts)
// ---------------------------------------------------------------------------
@@ -503,3 +660,257 @@ TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (bui
bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
}
// ---------------------------------------------------------------------------
// Tunnel — pairing
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(3, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
// With kFastBeltSpeed, items cross one tile per tick.
// entry tile: 1 tick to reach front progress 1.0
// transit: 3 tiles distance → 3 ticks
// exit tile: 1 tick to reach front progress 1.0
// Total: 1 (entry) + 1 (entry→transit) + 3 (transit) + 1 (transit→exit) + 1 (exit advance) = ~5-7 ticks
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{exit, Rotation::East}).has_value());
const std::optional<Item> taken = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken.has_value());
REQUIRE(taken->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelExit(QPoint(3, 0), Rotation::North);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
// Exit faces North, not East — no pair formed, item stuck in entry.
REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::North}).has_value());
}
TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2);
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing", "[belt]")
{
// Entry1 at (0,0) East, Entry2 at (2,0) East, Exit at (4,0) East.
// Entry2 is closer to Exit → Entry2 pairs with Exit; Entry1 is blocked by Entry2.
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelEntry(QPoint(2, 0), Rotation::East, 10);
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
// Put item on Entry2 — should reach exit.
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East});
// Put item on Entry1 — should NOT reach exit (Entry1 is unpaired).
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "[belt]")
{
// Entry1 at (0,0) East, Entry2 at (2,0) North (different dir), Exit at (4,0) East.
// Entry2 faces North → ignored → Entry1 pairs with Exit.
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10);
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{QPoint(4, 0), Rotation::East}).has_value());
}
// ---------------------------------------------------------------------------
// Tunnel — item transit
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
// No exit placed — entry is unpaired.
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 10; ++i)
{
bs.tick();
}
// Item should not vanish — it stays in the entry.
// We can verify by placing an exit and seeing item eventually arrive.
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
for (int i = 0; i < 20; ++i)
{
bs.tick();
}
REQUIRE(bs.peekItem(Port{QPoint(3, 0), Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(5, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
// Advance just enough for item to enter transit but not reach exit.
bs.tick(); // item enters entry front
bs.tick(); // entry front → transit (progress 0)
bs.removeTile(entry);
// Even with many more ticks, nothing arrives at exit.
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(5, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
bs.tick();
bs.tick();
bs.clearTiles({entry});
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
REQUIRE_FALSE(bs.peekItem(Port{exit, Rotation::East}).has_value());
}
TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint beltIn(0, 0);
const QPoint entry(1, 0);
const QPoint exit(4, 0);
const QPoint beltOut(5, 0);
bs.placeBelt(beltIn, Rotation::East);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.placeBelt(beltOut, Rotation::East);
bs.tryPutItem(beltIn, makeItem("iron_ore"));
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
// Item should have arrived on beltOut.
REQUIRE(bs.peekItem(eastPort(beltOut)).has_value());
const std::optional<Item> taken = bs.tryTakeItem(eastPort(beltOut));
REQUIRE(taken.has_value());
REQUIRE(taken->type.id == "iron_ore");
}
TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
const QPoint exit(3, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("item1"));
bs.tick();
bs.tick(); // item1 enters transit
bs.tryPutItem(entry, makeItem("item2"));
for (int i = 0; i < 30; ++i)
{
bs.tick();
}
// item1 should arrive first.
const std::optional<Item> taken1 = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken1.has_value());
REQUIRE(taken1->type.id == "item1");
for (int i = 0; i < 10; ++i)
{
bs.tick();
}
const std::optional<Item> taken2 = bs.tryTakeItem(Port{exit, Rotation::East});
REQUIRE(taken2.has_value());
REQUIRE(taken2->type.id == "item2");
}

View File

@@ -164,6 +164,7 @@ refund_percentage = 75
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
starting_building_blocks = 100
tunnel_max_distance = 10
[regions]
asteroid_width = 40
@@ -209,6 +210,7 @@ refund_percentage = 75
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
starting_building_blocks = 100
tunnel_max_distance = 10
[regions]
asteroid_width = 40
@@ -322,3 +324,4 @@ duration_seconds = 1.0
ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()),
std::runtime_error);
}

View File

@@ -12,6 +12,20 @@ player_placeable = true
construction_time_seconds = 1
surface_mask = ["<A>"]
[[building]]
id = "tunnel_entry"
cost = 5
player_placeable = true
construction_time_seconds = 3
surface_mask = ["A>"]
[[building]]
id = "tunnel_exit"
cost = 5
player_placeable = true
construction_time_seconds = 3
surface_mask = ["A>"]
[[building]]
id = "miner"
cost = 15

View File

@@ -4,6 +4,7 @@ refund_percentage = 75
starting_building_blocks = 100
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
tunnel_max_distance = 10
[regions]
asteroid_width = 40

View File

@@ -481,7 +481,9 @@ void GameWorldView::placeAtTile(QPoint tile)
}
}
}
else if (type == BuildingType::Splitter)
else if (type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit)
{
if (!m_sim->buildings().isTileOccupied(tile))
{

View File

@@ -3,16 +3,21 @@
#include <algorithm>
#include <cctype>
#include <map>
#include <set>
#include <string>
#include <QComboBox>
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "Rotation.h"
#include "Simulation.h"
namespace
@@ -54,7 +59,20 @@ bool isProductionBuilding(BuildingType type)
bool isBeltLike(BuildingType type)
{
return type == BuildingType::Belt || type == BuildingType::Splitter;
return type == BuildingType::Belt || type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit;
}
QString rotationLabel(Rotation r)
{
switch (r)
{
case Rotation::North: return "North (↑)";
case Rotation::East: return "East (→)";
case Rotation::South: return "South (↓)";
case Rotation::West: return "West (←)";
}
return "";
}
} // namespace
@@ -67,6 +85,7 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
, m_sim(sim)
, m_config(config)
, m_singleId(kInvalidEntityId)
, m_splitterTile(0, 0)
{
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(8, 8, 8, 8);
@@ -76,18 +95,33 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
m_titleLabel = new QLabel(this);
m_recipeCombo = new QComboBox(this);
m_clearBeltBtn = new QPushButton("Clear Items", this);
m_filterALabel = new QLabel(this);
m_filterAList = new QListWidget(this);
m_filterBLabel = new QLabel(this);
m_filterBList = new QListWidget(this);
m_buffersLabel = new QLabel(this);
m_buffersLabel->setWordWrap(true);
m_filterAList->setMaximumHeight(100);
m_filterBList->setMaximumHeight(100);
m_layout->addWidget(m_titleLabel);
m_layout->addWidget(m_recipeCombo);
m_layout->addWidget(m_clearBeltBtn);
m_layout->addWidget(m_filterALabel);
m_layout->addWidget(m_filterAList);
m_layout->addWidget(m_filterBLabel);
m_layout->addWidget(m_filterBList);
m_layout->addWidget(m_buffersLabel);
connect(m_recipeCombo, qOverload<int>(&QComboBox::currentIndexChanged),
this, &SelectedBuildingPanel::onRecipeChanged);
connect(m_clearBeltBtn, &QPushButton::clicked,
this, &SelectedBuildingPanel::onClearBelt);
connect(m_filterAList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged);
connect(m_filterBList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged);
buildEmpty();
}
@@ -120,6 +154,10 @@ void SelectedBuildingPanel::buildEmpty()
m_titleLabel->hide();
m_recipeCombo->hide();
m_clearBeltBtn->hide();
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
m_buffersLabel->hide();
}
@@ -189,6 +227,19 @@ void SelectedBuildingPanel::buildSingle(EntityId id)
m_clearBeltBtn->hide();
}
if (b->type == BuildingType::Splitter)
{
m_splitterTile = b->anchor;
buildSplitterFilters(m_splitterTile);
}
else
{
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
}
refreshBuffers(b);
}
@@ -329,6 +380,10 @@ void SelectedBuildingPanel::buildMulti(const std::vector<EntityId>& ids)
m_singleId = kInvalidEntityId;
m_recipeCombo->hide();
m_clearBeltBtn->hide();
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
m_buffersLabel->hide();
std::map<BuildingType, int> counts;
@@ -371,6 +426,94 @@ void SelectedBuildingPanel::onRecipeChanged(int comboIndex)
m_sim->buildings().setRecipe(m_singleId, recipeId.toStdString());
}
void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile)
{
const std::optional<BeltSystem::SplitterInfo> info =
m_sim->belts().getSplitterInfo(splitterTile);
if (!info.has_value())
{
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
return;
}
const std::vector<std::string> items = allItemIds();
auto populateList = [&](QListWidget* list, QLabel* label,
const QString& dirLabel,
const std::vector<ItemType>& filter)
{
label->setText(dirLabel + " filter (empty = all):");
list->blockSignals(true);
list->clear();
for (const std::string& itemId : items)
{
QListWidgetItem* row = new QListWidgetItem(
QString::fromStdString(itemId), list);
const bool checked = filter.empty()
? false
: std::find(filter.begin(), filter.end(),
ItemType{itemId}) != filter.end();
row->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
row->setFlags(row->flags() | Qt::ItemIsUserCheckable);
}
list->blockSignals(false);
label->show();
list->show();
};
populateList(m_filterAList, m_filterALabel,
rotationLabel(info->outputA), info->filterA);
populateList(m_filterBList, m_filterBLabel,
rotationLabel(info->outputB), info->filterB);
}
void SelectedBuildingPanel::onSplitterFilterChanged()
{
if (m_singleId == kInvalidEntityId)
{
return;
}
auto collectFilter = [](QListWidget* list) -> std::vector<ItemType>
{
std::vector<ItemType> filter;
for (int i = 0; i < list->count(); ++i)
{
const QListWidgetItem* row = list->item(i);
if (row->checkState() == Qt::Checked)
{
filter.push_back(ItemType{row->text().toStdString()});
}
}
return filter;
};
m_sim->belts().setSplitterFilters(
m_splitterTile,
collectFilter(m_filterAList),
collectFilter(m_filterBList));
}
std::vector<std::string> SelectedBuildingPanel::allItemIds() const
{
std::set<std::string> seen;
for (const RecipeDef& recipe : m_config->recipes.recipes)
{
for (const RecipeIngredient& ing : recipe.inputs)
{
seen.insert(ing.item);
}
for (const RecipeOutput& out : recipe.outputs)
{
seen.insert(out.item);
}
}
return std::vector<std::string>(seen.begin(), seen.end());
}
void SelectedBuildingPanel::onClearBelt()
{
std::vector<QPoint> tiles;

View File

@@ -3,6 +3,7 @@
#include <string>
#include <vector>
#include <QPoint>
#include <QWidget>
#include "Building.h"
@@ -15,6 +16,7 @@
class Simulation;
class QLabel;
class QComboBox;
class QListWidget;
class QPushButton;
class QVBoxLayout;
@@ -33,6 +35,7 @@ public slots:
private slots:
void onRecipeChanged(int comboIndex);
void onClearBelt();
void onSplitterFilterChanged();
private:
void rebuild();
@@ -41,8 +44,10 @@ private:
void buildSingle(EntityId id);
void buildMulti(const std::vector<EntityId>& ids);
void refreshBuffers(const Building* b);
void buildSplitterFilters(QPoint splitterTile);
const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const;
std::vector<std::string> allItemIds() const;
Simulation* m_sim;
const GameConfig* m_config;
@@ -52,8 +57,13 @@ private:
QLabel* m_titleLabel;
QComboBox* m_recipeCombo;
QPushButton* m_clearBeltBtn;
QLabel* m_filterALabel;
QListWidget* m_filterAList;
QLabel* m_filterBLabel;
QListWidget* m_filterBList;
QLabel* m_buffersLabel;
EntityId m_singleId;
QPoint m_splitterTile;
std::string m_currentRecipeId;
};

View File

@@ -124,6 +124,8 @@ const BuildingEntry kBuildingEntries[] = {
{ "salvage_bay", BuildingType::SalvageBay },
{ "belt", BuildingType::Belt },
{ "splitter", BuildingType::Splitter },
{ "tunnel_entry", BuildingType::TunnelEntry },
{ "tunnel_exit", BuildingType::TunnelExit },
};
} // namespace