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:
45
src/lib/sim/BeltSlot.cpp
Normal file
45
src/lib/sim/BeltSlot.cpp
Normal 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
30
src/lib/sim/BeltSlot.h
Normal 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);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user