Animate items emerging from building output ports

Produced items now emerge across a per-port virtual output belt (progress
0.5 -> 1.0) before handing off to the adjacent real belt, instead of
appearing instantly (REQ-MAT-OUTPUT-EMERGE). Emerging items still count
against the output buffer and are drawn occluded by the building so they
slide out of the port.

The belt movement/geometry is extracted into shared BeltSlot helpers
(advanceBeltSlots, beltSlotWorldPos) reused by belts, tunnel entries/exits,
and the new building output belts. The virtual belt state lives on the
Building; BuildingSystem::tickOutputBelts advances it at the BeltSystem's
own speed and feeds it from the output buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
2026-07-14 14:24:46 +02:00
parent e7f1f49f01
commit 6ed58d42e3
12 changed files with 274 additions and 120 deletions

45
src/lib/sim/BeltSlot.cpp Normal file
View File

@@ -0,0 +1,45 @@
#include "BeltSlot.h"
#include <cstddef>
void advanceBeltSlots(std::vector<BeltItemSlot>& slots, double progressPerTick)
{
for (std::size_t i = 0; i < slots.size(); ++i)
{
slots[i].progress += progressPerTick;
// Absolute cap: slot i cannot exceed 1.0 - i * 0.25.
const double absoluteCap = 1.0 - static_cast<double>(i) * 0.25;
if (slots[i].progress > absoluteCap)
{
slots[i].progress = absoluteCap;
}
// Gap constraint: must stay 0.25 behind the slot ahead.
if (i > 0)
{
const double gapCap = slots[i - 1].progress - 0.25;
if (slots[i].progress > gapCap)
{
slots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
}
QPointF beltSlotWorldPos(QPoint tile, Rotation dir, double progress)
{
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
// Progress 0 = entered from opposite side; 1 = at output edge.
const double baseX = tile.x() + 0.5;
const double baseY = tile.y() + 0.5;
switch (dir)
{
case Rotation::North: return {baseX, baseY - (progress - 0.5)};
case Rotation::East: return {baseX + (progress - 0.5), baseY};
case Rotation::South: return {baseX, baseY + (progress - 0.5)};
case Rotation::West: return {baseX - (progress - 0.5), baseY};
}
return {baseX, baseY};
}

30
src/lib/sim/BeltSlot.h Normal file
View File

@@ -0,0 +1,30 @@
#pragma once
#include <vector>
#include <QPoint>
#include <QPointF>
#include "Item.h"
#include "Rotation.h"
// A single item on a belt-like lane: an item plus its fractional progress along
// the lane's travel direction. Shared by BeltSystem's belt/tunnel tiles and by a
// building's virtual output belt (REQ-MAT-OUTPUT-EMERGE) so the packing and
// geometry live in exactly one place.
struct BeltItemSlot
{
Item item;
double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge
};
// Advances every slot in `slots` by `progressPerTick`, applying the standard belt
// packing: the front (index 0) carries the highest progress; each following slot
// stays at least 0.25 behind the slot ahead and is capped at 1.0 - i * 0.25.
// `slots` must be ordered front (highest progress) first. This is the per-tile
// advance shared by belts, tunnel entries, and tunnel exits.
void advanceBeltSlots(std::vector<BeltItemSlot>& slots, double progressPerTick);
// World-space centre (in tile units) of a slot at `progress` on a lane occupying
// `tile` and flowing in `dir`. Progress 0 = entry edge, 1 = output edge.
QPointF beltSlotWorldPos(QPoint tile, Rotation dir, double progress);

View File

@@ -77,23 +77,6 @@ bool BeltSystem::entersThroughOutputEdge(QPoint tile, Rotation travelDir) const
return false; return false;
} }
QPointF BeltSystem::slotWorldPos(QPoint tile, Rotation dir, double progress)
{
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
// Progress 0 = entered from opposite side; 1 = at output edge.
double baseX = tile.x() + 0.5;
double baseY = tile.y() + 0.5;
switch (dir)
{
case Rotation::North: return {baseX, baseY - (progress - 0.5)};
case Rotation::East: return {baseX + (progress - 0.5), baseY};
case Rotation::South: return {baseX, baseY + (progress - 0.5)};
case Rotation::West: return {baseX - (progress - 0.5), baseY};
}
return {baseX, baseY};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Construction / placement // Construction / placement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -467,29 +450,7 @@ void BeltSystem::advanceProgress()
for (std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.begin(); for (std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.begin();
it != m_belts.end(); ++it) it != m_belts.end(); ++it)
{ {
BeltTile& bt = it->second; advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
for (std::size_t i = 0; i < bt.itemSlots.size(); ++i)
{
bt.itemSlots[i].progress += m_progressPerTick_tpt;
// Absolute cap: slot i cannot exceed 1.0 - i * 0.25.
const double absoluteCap = 1.0 - i * 0.25;
if (bt.itemSlots[i].progress > absoluteCap)
{
bt.itemSlots[i].progress = absoluteCap;
}
// Gap constraint: must stay 0.25 behind the slot ahead.
if (i > 0)
{
const double gapCap = bt.itemSlots[i - 1].progress - 0.25;
if (bt.itemSlots[i].progress > gapCap)
{
bt.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
} }
for (std::map<std::pair<int, int>, SplitterTile>::iterator it = m_splitters.begin(); for (std::map<std::pair<int, int>, SplitterTile>::iterator it = m_splitters.begin();
@@ -545,53 +506,13 @@ void BeltSystem::advanceTunnelProgress()
for (std::map<std::pair<int, int>, TunnelEntryTile>::iterator it = m_tunnelEntries.begin(); for (std::map<std::pair<int, int>, TunnelEntryTile>::iterator it = m_tunnelEntries.begin();
it != m_tunnelEntries.end(); ++it) it != m_tunnelEntries.end(); ++it)
{ {
TunnelEntryTile& te = it->second; advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
for (std::size_t i = 0; i < te.itemSlots.size(); ++i)
{
te.itemSlots[i].progress += m_progressPerTick_tpt;
const double absoluteCap = 1.0 - i * 0.25;
if (te.itemSlots[i].progress > absoluteCap)
{
te.itemSlots[i].progress = absoluteCap;
}
if (i > 0)
{
const double gapCap = te.itemSlots[i - 1].progress - 0.25;
if (te.itemSlots[i].progress > gapCap)
{
te.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
} }
for (std::map<std::pair<int, int>, TunnelExitTile>::iterator it = m_tunnelExits.begin(); for (std::map<std::pair<int, int>, TunnelExitTile>::iterator it = m_tunnelExits.begin();
it != m_tunnelExits.end(); ++it) it != m_tunnelExits.end(); ++it)
{ {
TunnelExitTile& tx = it->second; advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
for (std::size_t i = 0; i < tx.itemSlots.size(); ++i)
{
tx.itemSlots[i].progress += m_progressPerTick_tpt;
const double absoluteCap = 1.0 - i * 0.25;
if (tx.itemSlots[i].progress > absoluteCap)
{
tx.itemSlots[i].progress = absoluteCap;
}
if (i > 0)
{
const double gapCap = tx.itemSlots[i - 1].progress - 0.25;
if (tx.itemSlots[i].progress > gapCap)
{
tx.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
} }
for (TunnelLink& link : m_tunnelLinks) for (TunnelLink& link : m_tunnelLinks)
@@ -940,7 +861,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{ {
VisualItem vi; VisualItem vi;
vi.type = bt.itemSlots[i].item.type; vi.type = bt.itemSlots[i].item.type;
vi.worldPos = slotWorldPos(tile, bt.direction, bt.itemSlots[i].progress); vi.worldPos = beltSlotWorldPos(tile, bt.direction, bt.itemSlots[i].progress);
visit(vi); visit(vi);
} }
} }
@@ -960,7 +881,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{ {
VisualItem vi; VisualItem vi;
vi.type = st.back[i].item.type; vi.type = st.back[i].item.type;
vi.worldPos = slotWorldPos(tile, st.backDir[i], st.back[i].progress); vi.worldPos = beltSlotWorldPos(tile, st.backDir[i], st.back[i].progress);
visit(vi); visit(vi);
} }
@@ -986,7 +907,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{ {
VisualItem vi; VisualItem vi;
vi.type = slot->item.type; vi.type = slot->item.type;
vi.worldPos = slotWorldPos(tile, dir, slot->progress); vi.worldPos = beltSlotWorldPos(tile, dir, slot->progress);
visit(vi); visit(vi);
} }
}; };
@@ -1016,7 +937,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{ {
VisualItem vi; VisualItem vi;
vi.type = te.itemSlots[i].item.type; vi.type = te.itemSlots[i].item.type;
vi.worldPos = slotWorldPos(tile, te.direction, te.itemSlots[i].progress); vi.worldPos = beltSlotWorldPos(tile, te.direction, te.itemSlots[i].progress);
visit(vi); visit(vi);
} }
} }
@@ -1034,7 +955,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{ {
VisualItem vi; VisualItem vi;
vi.type = tx.itemSlots[i].item.type; vi.type = tx.itemSlots[i].item.type;
vi.worldPos = slotWorldPos(tile, tx.direction, tx.itemSlots[i].progress); vi.worldPos = beltSlotWorldPos(tile, tx.direction, tx.itemSlots[i].progress);
visit(vi); visit(vi);
} }
} }

View File

@@ -10,6 +10,7 @@
#include <QPointF> #include <QPointF>
#include <QRect> #include <QRect>
#include "BeltSlot.h"
#include "Item.h" #include "Item.h"
#include "ItemType.h" #include "ItemType.h"
#include "Port.h" #include "Port.h"
@@ -88,6 +89,11 @@ public:
// Returns nullopt if tile is not a belt, direction mismatches, or tile empty. // Returns nullopt if tile is not a belt, direction mismatches, or tile empty.
std::optional<ItemType> peekItem(Port port) const; std::optional<ItemType> peekItem(Port port) const;
// Progress advanced per tick at the configured belt speed (tile fraction per
// tick). Shared with building output belts so emerging items travel at exactly
// the same speed as real belts (REQ-MAT-OUTPUT-EMERGE).
double getProgressPerTick_tpt() const { return m_progressPerTick_tpt; }
// -- Maintenance --------------------------------------------------------- // -- Maintenance ---------------------------------------------------------
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
void tick(); void tick();
@@ -126,15 +132,6 @@ private:
// refused). Returns false if no transport tile occupies `tile`. // refused). Returns false if no transport tile occupies `tile`.
bool entersThroughOutputEdge(QPoint tile, Rotation travelDir) const; bool entersThroughOutputEdge(QPoint tile, Rotation travelDir) const;
// Returns the world-space centre of a slot given tile origin and progress.
static QPointF slotWorldPos(QPoint tile, Rotation dir, double progress);
struct BeltItemSlot
{
Item item;
double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge
};
struct BeltTile struct BeltTile
{ {
Rotation direction; Rotation direction;

View File

@@ -12,6 +12,7 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "BeltSlot.h"
#include "Item.h" #include "Item.h"
#include "ItemType.h" #include "ItemType.h"
#include "Port.h" #include "Port.h"
@@ -75,6 +76,26 @@ struct Building
OutputBuffer outputBuffer; OutputBuffer outputBuffer;
std::optional<Production> production; std::optional<Production> production;
// Items currently emerging from each output port on its virtual output belt
// (REQ-MAT-OUTPUT-EMERGE); one lane per output port, parallel to outputPorts.
// Each lane holds slots at progress [0.5, 1.0], front (highest progress) first.
// An emerging item still counts as residing in the output buffer until it hands
// off onto a real belt at progress 1.0.
std::vector<std::vector<BeltItemSlot>> emergingItems;
// Total items held on the output side: buffered plus still-emerging. The
// output-buffer capacity rule (REQ-MAT-OUTPUT-BUFFER) counts emerging items,
// since they have not yet left the building.
int outputItemCount() const
{
int count = static_cast<int>(outputBuffer.items.size());
for (const std::vector<BeltItemSlot>& lane : emergingItems)
{
count += static_cast<int>(lane.size());
}
return count;
}
// Pre-computed from surface mask at placement; in absolute world coordinates. // Pre-computed from surface mask at placement; in absolute world coordinates.
std::vector<QPoint> bodyCells; std::vector<QPoint> bodyCells;
std::vector<Port> outputPorts; std::vector<Port> outputPorts;

View File

@@ -20,6 +20,21 @@ bool isAutoRecipeBuildingType(BuildingType type)
return type == BuildingType::Smelter return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant; || type == BuildingType::ReprocessingPlant;
} }
// The building body tile that owns an output port, given the port's outside tile
// (port.tile) and its facing direction. The virtual output belt occupies this tile
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
QPoint outputBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
} // namespace } // namespace
BuildingSystem::BuildingSystem(const GameConfig& config, BuildingSystem::BuildingSystem(const GameConfig& config,
@@ -523,6 +538,9 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
building.inputBuffer.caps.clear(); building.inputBuffer.caps.clear();
building.outputBuffer.items.clear(); building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0; building.outputBuffer.capacity = 0;
// Emerging items are part of the output buffer, so clearing it on a
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE).
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
building.production = std::nullopt; building.production = std::nullopt;
if (!recipeId.empty()) if (!recipeId.empty())
@@ -569,6 +587,7 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
building.inputBuffer.caps.clear(); building.inputBuffer.caps.clear();
building.outputBuffer.items.clear(); building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0; building.outputBuffer.capacity = 0;
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
{ {
initShipyardBuffers(building); initShipyardBuffers(building);
@@ -672,6 +691,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
absPort.direction = port.direction; absPort.direction = port.direction;
building.outputPorts.push_back(absPort); building.outputPorts.push_back(absPort);
} }
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
if (building.type == BuildingType::SalvageBay) if (building.type == BuildingType::SalvageBay)
@@ -929,8 +949,9 @@ void BuildingSystem::tickProduction(Tick currentTick)
} }
} }
// 3. Output buffer has space for chosen outputs? // 3. Output buffer has space for chosen outputs? Emerging items still
const int newSize = static_cast<int>(building.outputBuffer.items.size()) // count against the buffer (REQ-MAT-OUTPUT-EMERGE).
const int newSize = building.outputItemCount()
+ static_cast<int>(chosen.size()); + static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity) if (newSize > building.outputBuffer.capacity)
{ {
@@ -1064,31 +1085,69 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
} }
} }
void BuildingSystem::tickBeltPush() void BuildingSystem::tickOutputBelts()
{ {
TRACE(); TRACE();
// Use BeltSystem's own per-tick step so emerging items travel at exactly the
// same speed as real belts (REQ-GW-BELT-SPEED, REQ-MAT-OUTPUT-EMERGE).
const double progressPerTick = m_belts.getProgressPerTick_tpt();
for (Building& building : m_buildings) for (Building& building : m_buildings)
{ {
if (building.outputBuffer.items.empty()) for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
{ {
continue; const Port& port = building.outputPorts[p];
std::vector<BeltItemSlot>& lane = building.emergingItems[p];
// 1. Advance emerging items using the shared belt packing (progress
// caps to 0.5 / 0.75 / 1.0 for up to three items).
advanceBeltSlots(lane, progressPerTick);
// 2. Hand the front item off onto the adjacent real belt once it reaches
// the output edge (progress 1.0). On refusal — no belt, output-edge
// (REQ-MAT-ACCEPT-DIR), or a full belt — it stays stuck at 1.0.
if (!lane.empty() && lane.front().progress >= 1.0
&& m_belts.tryPutItem(port.tile, lane.front().item, port.direction))
{
lane.erase(lane.begin());
} }
for (const Port& outputPort : building.outputPorts) // 3. Feed the next buffered item onto the lane at progress 0.5 when the
{ // entry slot is free — the lane holds at most three items and a new
if (building.outputBuffer.items.empty()) // one needs a quarter-tile clearance ahead of 0.5.
{ if (!building.outputBuffer.items.empty()
break; && lane.size() < 3
} && (lane.empty() || lane.back().progress >= 0.75))
const Item item = building.outputBuffer.items.front();
if (m_belts.tryPutItem(outputPort.tile, item, outputPort.direction))
{ {
lane.push_back(BeltItemSlot{building.outputBuffer.items.front(), 0.5});
building.outputBuffer.items.erase(building.outputBuffer.items.begin()); building.outputBuffer.items.erase(building.outputBuffer.items.begin());
} }
} }
} }
} }
void BuildingSystem::forEachEmergingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const
{
for (const Building& building : m_buildings)
{
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
{
const Port& port = building.outputPorts[p];
const QPoint bodyTile = outputBodyTile(port.tile, port.direction);
const std::vector<BeltItemSlot>& lane = building.emergingItems[p];
// Render least-progressed first (bottom) → most-progressed last (top),
// matching belt item ordering (REQ-GW-TILE-SIZE).
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
{
visit(lane[i].item.type,
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
}
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Queries // Queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1278,6 +1337,10 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
absPort.direction = port.direction; absPort.direction = port.direction;
b.outputPorts.push_back(absPort); b.outputPorts.push_back(absPort);
} }
// The output ports moved; discard any in-flight emerging items and re-size
// the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE).
b.emergingItems.clear();
b.emergingItems.resize(b.outputPorts.size());
b.inputPorts = computeInputPorts(b); b.inputPorts = computeInputPorts(b);
// Re-register with BeltSystem (items on tile are discarded). // Re-register with BeltSystem (items on tile are discarded).
@@ -1347,7 +1410,9 @@ bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
{ {
return false; return false;
} }
if (static_cast<int>(bay->outputBuffer.items.size()) >= bay->outputBuffer.capacity) // Emerging scrap still counts against the bay's holding capacity
// (REQ-MAT-OUTPUT-EMERGE).
if (bay->outputItemCount() >= bay->outputBuffer.capacity)
{ {
return false; return false;
} }
@@ -1382,6 +1447,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
absPort.direction = port.direction; absPort.direction = port.direction;
building.outputPorts.push_back(absPort); building.outputPorts.push_back(absPort);
} }
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
if (type == BuildingType::SalvageBay) if (type == BuildingType::SalvageBay)
@@ -1488,6 +1554,16 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
appendInputBuffer(hasher, b.inputBuffer); appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items); appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity); hasher.append(b.outputBuffer.capacity);
hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
hasher.append(b.production.has_value()); hasher.append(b.production.has_value());
if (b.production.has_value()) if (b.production.has_value())
{ {

View File

@@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QPointF>
#include <QVector2D> #include <QVector2D>
#include "BeltSystem.h" #include "BeltSystem.h"
@@ -94,7 +95,10 @@ public:
void tickBeltPull(); void tickBeltPull();
void tickProduction(Tick currentTick); void tickProduction(Tick currentTick);
void tickShipyardProduction(Tick currentTick); void tickShipyardProduction(Tick currentTick);
void tickBeltPush(); // Advances each building's virtual output belts, hands finished items off onto
// the adjacent real belt, and feeds new buffered items into them
// (REQ-MAT-OUTPUT-EMERGE).
void tickOutputBelts();
// -- Queries ------------------------------------------------------------- // -- Queries -------------------------------------------------------------
struct BeltTileInfo struct BeltTileInfo
@@ -121,6 +125,13 @@ public:
std::vector<BeltTileInfo> allBeltTiles() const; std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const; bool isTileOccupied(QPoint tile) const;
// Visits every item currently emerging from a building output port on its
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom) so
// callers can paint in visit order (REQ-GW-TILE-SIZE ordering).
void forEachEmergingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Returns the entity id of the building or construction site whose footprint // Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same // exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise. // building type. Returns nullopt otherwise.

View File

@@ -8,6 +8,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
@@ -31,6 +32,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp

View File

@@ -315,7 +315,7 @@ void Simulation::tick()
m_buildingSystem->tickBeltPull(); // step 3 m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4 m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b
m_buildingSystem->tickBeltPush(); // step 5 m_buildingSystem->tickOutputBelts(); // step 5
m_beltSystem.tick(); // step 6 m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority) // Step 7: ship behavior systems (movement arbitration via intent priority)

View File

@@ -58,12 +58,28 @@ static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick)
bs.tickConstruction(tick); bs.tickConstruction(tick);
bs.tickBeltPull(); bs.tickBeltPull();
bs.tickProduction(tick); bs.tickProduction(tick);
bs.tickBeltPush(); bs.tickOutputBelts();
belts.tick(); belts.tick();
++tick; ++tick;
} }
} }
// All items currently on a building's output side: buffered plus still-emerging on
// the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the
// output buffer the moment it starts emerging, so tests count both.
static std::vector<Item> outputSideItems(const Building& b)
{
std::vector<Item> items = b.outputBuffer.items;
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
items.push_back(slot.item);
}
}
return items;
}
// Owns a BuildingSystem and its dependencies for placement-bounds tests. // Owns a BuildingSystem and its dependencies for placement-bounds tests.
struct PlacementFixture struct PlacementFixture
{ {
@@ -402,8 +418,11 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE_FALSE(b->outputBuffer.items.empty()); // No belt at the output port, so the produced item emerges and stays on the
REQUIRE(b->outputBuffer.items.front().type.id == "iron_ore"); // building's virtual output belt (REQ-MAT-OUTPUT-EMERGE).
const std::vector<Item> out = outputSideItems(*b);
REQUIRE(out.size() == 1);
REQUIRE(out.front().type.id == "iron_ore");
} }
TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
@@ -436,7 +455,9 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2); // Both produced items are held on the output side (buffer + emerging lane),
// which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->outputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
} }
@@ -515,7 +536,7 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2); REQUIRE(b->outputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
REQUIRE(bs.activeProductionBuildingCount() == 0); REQUIRE(bs.activeProductionBuildingCount() == 0);
} }
@@ -603,7 +624,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
const Building* b = bs.findBuilding(sid); const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
bool hasIronIngot = false; bool hasIronIngot = false;
for (const Item& item : b->outputBuffer.items) for (const Item& item : outputSideItems(*b))
{ {
if (item.type.id == "iron_ingot") { hasIronIngot = true; } if (item.type.id == "iron_ingot") { hasIronIngot = true; }
} }
@@ -652,7 +673,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
// Copper was smelted; the lone iron_ore still waits for a second unit. // Copper was smelted; the lone iron_ore still waits for a second unit.
bool hasCopperIngot = false; bool hasCopperIngot = false;
for (const Item& item : b->outputBuffer.items) for (const Item& item : outputSideItems(*b))
{ {
if (item.type.id == "copper_ingot") { hasCopperIngot = true; } if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
} }
@@ -734,13 +755,15 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
{ {
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE_FALSE(b->outputBuffer.items.empty()); REQUIRE(b->outputItemCount() > 0);
} }
bs.setRecipe(id, "mine_copper_ore"); bs.setRecipe(id, "mine_copper_ore");
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b->outputBuffer.items.empty()); // Clearing the output buffer on a recipe change also discards emerging items
// (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->outputItemCount() == 0);
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
} }

View File

@@ -402,6 +402,10 @@ void GameWorldView::paintGL()
painter.setRenderHint(QPainter::Antialiasing, false); painter.setRenderHint(QPainter::Antialiasing, false);
drawTiles(painter); drawTiles(painter);
// Emerging items are drawn before the buildings so the building body occludes
// the portion still inside the footprint, making items appear to slide out of
// the output port rather than pop into existence (REQ-MAT-OUTPUT-EMERGE).
drawEmergingItems(painter);
drawBuildings(painter); drawBuildings(painter);
drawCopyConfigFeedback(painter); drawCopyConfigFeedback(painter);
drawStations(painter); drawStations(painter);
@@ -1153,6 +1157,29 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
} }
} }
void GameWorldView::drawEmergingItems(QPainter& painter)
{
const float halfPx = tilePx() * 0.5f * 0.5f;
m_sim->buildings().forEachEmergingItem(
[&](const ItemType& type, QPointF worldPos)
{
const std::map<std::string, ItemVisuals>::const_iterator it =
m_visuals->items.find(type.id);
if (it == m_visuals->items.end()) { return; }
const QPointF center = worldToWidget(
QVector2D(static_cast<float>(worldPos.x()),
static_cast<float>(worldPos.y())));
const QRectF rect(center.x() - halfPx, center.y() - halfPx,
halfPx * 2, halfPx * 2);
painter.fillRect(rect, it->second.fill);
painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(Qt::NoBrush);
painter.drawRect(rect);
});
}
void GameWorldView::drawBeltItems(QPainter& painter) void GameWorldView::drawBeltItems(QPainter& painter)
{ {
const float halfPx = tilePx() * 0.5f * 0.5f; const float halfPx = tilePx() * 0.5f * 0.5f;

View File

@@ -115,6 +115,7 @@ private:
bool canAfford(BuildingType type) const; bool canAfford(BuildingType type) const;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawEmergingItems(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter); void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter); void drawCopyConfigFeedback(QPainter& painter);