From 6ed58d42e374a1ed188f5bb071dd1a52fbdadfd1 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 14 Jul 2026 14:24:46 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps --- src/lib/sim/BeltSlot.cpp | 45 +++++++++++++++ src/lib/sim/BeltSlot.h | 30 ++++++++++ src/lib/sim/BeltSystem.cpp | 95 +++--------------------------- src/lib/sim/BeltSystem.h | 15 ++--- src/lib/sim/Building.h | 21 +++++++ src/lib/sim/BuildingSystem.cpp | 102 ++++++++++++++++++++++++++++----- src/lib/sim/BuildingSystem.h | 13 ++++- src/lib/sim/CMakeLists.txt | 2 + src/lib/sim/Simulation.cpp | 2 +- src/test/BuildingTest.cpp | 41 ++++++++++--- src/ui/GameWorldView.cpp | 27 +++++++++ src/ui/GameWorldView.h | 1 + 12 files changed, 274 insertions(+), 120 deletions(-) create mode 100644 src/lib/sim/BeltSlot.cpp create mode 100644 src/lib/sim/BeltSlot.h diff --git a/src/lib/sim/BeltSlot.cpp b/src/lib/sim/BeltSlot.cpp new file mode 100644 index 0000000..854e0cc --- /dev/null +++ b/src/lib/sim/BeltSlot.cpp @@ -0,0 +1,45 @@ +#include "BeltSlot.h" + +#include + +void advanceBeltSlots(std::vector& 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(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}; +} diff --git a/src/lib/sim/BeltSlot.h b/src/lib/sim/BeltSlot.h new file mode 100644 index 0000000..b75e1b5 --- /dev/null +++ b/src/lib/sim/BeltSlot.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include +#include + +#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& 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); diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index 9d21a61..3953384 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -77,23 +77,6 @@ bool BeltSystem::entersThroughOutputEdge(QPoint tile, Rotation travelDir) const 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 // --------------------------------------------------------------------------- @@ -467,29 +450,7 @@ void BeltSystem::advanceProgress() for (std::map, BeltTile>::iterator it = m_belts.begin(); it != m_belts.end(); ++it) { - BeltTile& bt = it->second; - - 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); - } - } - } + advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt); } for (std::map, SplitterTile>::iterator it = m_splitters.begin(); @@ -545,53 +506,13 @@ void BeltSystem::advanceTunnelProgress() for (std::map, TunnelEntryTile>::iterator it = m_tunnelEntries.begin(); it != m_tunnelEntries.end(); ++it) { - TunnelEntryTile& te = it->second; - - 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); - } - } - } + advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt); } for (std::map, TunnelExitTile>::iterator it = m_tunnelExits.begin(); it != m_tunnelExits.end(); ++it) { - TunnelExitTile& tx = it->second; - - 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); - } - } - } + advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt); } for (TunnelLink& link : m_tunnelLinks) @@ -940,7 +861,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, { VisualItem vi; 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); } } @@ -960,7 +881,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, { VisualItem vi; 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); } @@ -986,7 +907,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, { VisualItem vi; vi.type = slot->item.type; - vi.worldPos = slotWorldPos(tile, dir, slot->progress); + vi.worldPos = beltSlotWorldPos(tile, dir, slot->progress); visit(vi); } }; @@ -1016,7 +937,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, { VisualItem vi; 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); } } @@ -1034,7 +955,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, { VisualItem vi; 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); } } diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h index 6454e21..084e220 100644 --- a/src/lib/sim/BeltSystem.h +++ b/src/lib/sim/BeltSystem.h @@ -10,6 +10,7 @@ #include #include +#include "BeltSlot.h" #include "Item.h" #include "ItemType.h" #include "Port.h" @@ -88,6 +89,11 @@ public: // Returns nullopt if tile is not a belt, direction mismatches, or tile empty. std::optional 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 --------------------------------------------------------- void clearTiles(const std::vector& tiles); // REQ-UI-BELT-CLEAR void tick(); @@ -126,15 +132,6 @@ private: // refused). Returns false if no transport tile occupies `tile`. 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 { Rotation direction; diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index bf19a99..ac675d0 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -12,6 +12,7 @@ #include "BuildingId.h" #include "entt/entity/entity.hpp" +#include "BeltSlot.h" #include "Item.h" #include "ItemType.h" #include "Port.h" @@ -75,6 +76,26 @@ struct Building OutputBuffer outputBuffer; std::optional 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> 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(outputBuffer.items.size()); + for (const std::vector& lane : emergingItems) + { + count += static_cast(lane.size()); + } + return count; + } + // Pre-computed from surface mask at placement; in absolute world coordinates. std::vector bodyCells; std::vector outputPorts; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 2cffde0..5da7e13 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -20,6 +20,21 @@ bool isAutoRecipeBuildingType(BuildingType type) return type == BuildingType::Smelter || 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 BuildingSystem::BuildingSystem(const GameConfig& config, @@ -523,6 +538,9 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId) building.inputBuffer.caps.clear(); building.outputBuffer.items.clear(); 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& lane : building.emergingItems) { lane.clear(); } building.production = std::nullopt; if (!recipeId.empty()) @@ -569,6 +587,7 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout building.inputBuffer.caps.clear(); building.outputBuffer.items.clear(); building.outputBuffer.capacity = 0; + for (std::vector& lane : building.emergingItems) { lane.clear(); } if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) { initShipyardBuffers(building); @@ -672,6 +691,7 @@ void BuildingSystem::tickConstruction(Tick currentTick) absPort.direction = port.direction; building.outputPorts.push_back(absPort); } + building.emergingItems.resize(building.outputPorts.size()); building.inputPorts = computeInputPorts(building); if (building.type == BuildingType::SalvageBay) @@ -929,8 +949,9 @@ void BuildingSystem::tickProduction(Tick currentTick) } } - // 3. Output buffer has space for chosen outputs? - const int newSize = static_cast(building.outputBuffer.items.size()) + // 3. Output buffer has space for chosen outputs? Emerging items still + // count against the buffer (REQ-MAT-OUTPUT-EMERGE). + const int newSize = building.outputItemCount() + static_cast(chosen.size()); if (newSize > building.outputBuffer.capacity) { @@ -1064,31 +1085,69 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick) } } -void BuildingSystem::tickBeltPush() +void BuildingSystem::tickOutputBelts() { 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) { - 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& lane = building.emergingItems[p]; - for (const Port& outputPort : building.outputPorts) - { - if (building.outputBuffer.items.empty()) + // 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)) { - break; + lane.erase(lane.begin()); } - const Item item = building.outputBuffer.items.front(); - if (m_belts.tryPutItem(outputPort.tile, item, outputPort.direction)) + + // 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 + // one needs a quarter-tile clearance ahead of 0.5. + if (!building.outputBuffer.items.empty() + && lane.size() < 3 + && (lane.empty() || lane.back().progress >= 0.75)) { + lane.push_back(BeltItemSlot{building.outputBuffer.items.front(), 0.5}); building.outputBuffer.items.erase(building.outputBuffer.items.begin()); } } } } +void BuildingSystem::forEachEmergingItem( + const std::function& 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& 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(lane.size()) - 1; i >= 0; --i) + { + visit(lane[i].item.type, + beltSlotWorldPos(bodyTile, port.direction, lane[i].progress)); + } + } + } +} + // --------------------------------------------------------------------------- // Queries // --------------------------------------------------------------------------- @@ -1278,6 +1337,10 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation) absPort.direction = port.direction; 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); // Re-register with BeltSystem (items on tile are discarded). @@ -1347,7 +1410,9 @@ bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId) { return false; } - if (static_cast(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; } @@ -1382,6 +1447,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type, absPort.direction = port.direction; building.outputPorts.push_back(absPort); } + building.emergingItems.resize(building.outputPorts.size()); building.inputPorts = computeInputPorts(building); if (type == BuildingType::SalvageBay) @@ -1488,6 +1554,16 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const appendInputBuffer(hasher, b.inputBuffer); appendItems(hasher, b.outputBuffer.items); hasher.append(b.outputBuffer.capacity); + hasher.append(b.emergingItems.size()); + for (const std::vector& 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()); if (b.production.has_value()) { diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 98274e0..83eb149 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -10,6 +10,7 @@ #include #include +#include #include #include "BeltSystem.h" @@ -94,7 +95,10 @@ public: void tickBeltPull(); void tickProduction(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 ------------------------------------------------------------- struct BeltTileInfo @@ -121,6 +125,13 @@ public: std::vector allBeltTiles() 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& visit) const; + // 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 // building type. Returns nullopt otherwise. diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 0c37cc5..d2eac1f 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -8,6 +8,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h + ${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h @@ -31,6 +32,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index bc40fe9..5979bb2 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -315,7 +315,7 @@ void Simulation::tick() m_buildingSystem->tickBeltPull(); // step 3 m_buildingSystem->tickProduction(m_currentTick); // step 4 m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b - m_buildingSystem->tickBeltPush(); // step 5 + m_buildingSystem->tickOutputBelts(); // step 5 m_beltSystem.tick(); // step 6 // Step 7: ship behavior systems (movement arbitration via intent priority) diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 37c37d1..75e4787 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -58,12 +58,28 @@ static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick) bs.tickConstruction(tick); bs.tickBeltPull(); bs.tickProduction(tick); - bs.tickBeltPush(); + bs.tickOutputBelts(); belts.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 outputSideItems(const Building& b) +{ + std::vector items = b.outputBuffer.items; + for (const std::vector& 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. struct PlacementFixture { @@ -402,8 +418,11 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); - REQUIRE_FALSE(b->outputBuffer.items.empty()); - REQUIRE(b->outputBuffer.items.front().type.id == "iron_ore"); + // No belt at the output port, so the produced item emerges and stays on the + // building's virtual output belt (REQ-MAT-OUTPUT-EMERGE). + const std::vector out = outputSideItems(*b); + REQUIRE(out.size() == 1); + REQUIRE(out.front().type.id == "iron_ore"); } 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); REQUIRE(b != nullptr); - REQUIRE(static_cast(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()); } @@ -515,7 +536,7 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle const Building* b = bs.findBuilding(id); REQUIRE(b != nullptr); - REQUIRE(static_cast(b->outputBuffer.items.size()) == 2); + REQUIRE(b->outputItemCount() == 2); REQUIRE_FALSE(b->production.has_value()); 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); REQUIRE(b != nullptr); bool hasIronIngot = false; - for (const Item& item : b->outputBuffer.items) + for (const Item& item : outputSideItems(*b)) { 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. bool hasCopperIngot = false; - for (const Item& item : b->outputBuffer.items) + for (const Item& item : outputSideItems(*b)) { 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); REQUIRE(b != nullptr); - REQUIRE_FALSE(b->outputBuffer.items.empty()); + REQUIRE(b->outputItemCount() > 0); } bs.setRecipe(id, "mine_copper_ore"); 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()); } diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 76cb3f7..3dc7c30 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -402,6 +402,10 @@ void GameWorldView::paintGL() painter.setRenderHint(QPainter::Antialiasing, false); 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); drawCopyConfigFeedback(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::const_iterator it = + m_visuals->items.find(type.id); + if (it == m_visuals->items.end()) { return; } + + const QPointF center = worldToWidget( + QVector2D(static_cast(worldPos.x()), + static_cast(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) { const float halfPx = tilePx() * 0.5f * 0.5f; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 1c89fbe..49e69c0 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -115,6 +115,7 @@ private: bool canAfford(BuildingType type) const; void drawTiles(QPainter& painter); + void drawEmergingItems(QPainter& painter); void drawBuildings(QPainter& painter); void drawSelectionHighlights(QPainter& painter); void drawCopyConfigFeedback(QPainter& painter);