13 Commits

40 changed files with 4466 additions and 2167 deletions

View File

@@ -329,7 +329,11 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
## Rendering
The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the copy-settings feedback, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
### Render Loop
@@ -357,9 +361,11 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
### Coordinates and Scrolling
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL).
- At the start of `paintEvent`, a single `painter.translate(-scrollXTiles * tilePx, 0)` maps world tile units into widget pixels (`tilePx = 20`, per REQ-GW-TILE-SIZE).
- Mouse input converts the other way: `worldX = mouseX / tilePx + scrollXTiles`; apply `floor` for a tile. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles.
- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
### Culling
@@ -369,6 +375,8 @@ The renderer iterates only entities and tiles whose world X lies within the visi
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
Sketch of `visuals.toml`:

View File

@@ -30,6 +30,7 @@
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "DebrisComponent.h"
#include "WorldPrimitives.h"
namespace
{
@@ -176,55 +177,36 @@ void ArenaView::paintGL()
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false);
drawTiles(painter);
drawBuildings(painter);
drawStations(painter);
drawDebris(painter);
// One transform snapshot for the whole frame; every draw below reads the
// viewport through it.
const WorldCoordinates coordinates = getCoordinates();
drawTiles(painter, coordinates);
drawBuildings(painter, coordinates);
drawStations(painter, coordinates);
drawDebris(painter, coordinates);
if (m_debugDraw)
{
drawDebugSensorRanges(painter);
drawDebugTargetLines(painter);
drawDebugSensorRanges(painter, coordinates);
drawDebugTargetLines(painter, coordinates);
}
drawShips(painter);
drawBeams(painter);
drawShips(painter, coordinates);
drawBeams(painter, coordinates);
}
// ---------------------------------------------------------------------------
// Coordinate helpers
// ---------------------------------------------------------------------------
float ArenaView::getTilePx() const
WorldCoordinates ArenaView::getCoordinates() const
{
// The arena is a fixed, fully visible world — unlike the game view it has no
// scrolling, so the tile size comes from fitting the whole arena in the widget.
const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles;
const int totalHeight = ac.heightTiles;
if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; }
const float pxPerTileH = static_cast<float>(height()) / static_cast<float>(totalHeight);
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
return std::min(pxPerTileH, pxPerTileW);
}
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>(worldPos.x() * getTilePx()),
static_cast<qreal>(worldPos.y() * getTilePx()));
}
QPointF ArenaView::tileToWidget(QPoint tile) const
{
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
static_cast<float>(tile.y())));
}
QRectF ArenaView::tileRect(QPoint tile) const
{
const QPointF tl = tileToWidget(tile);
return QRectF(tl.x(), tl.y(),
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
}
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
@@ -236,19 +218,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
return m_sim->getAdmin().get<PositionComponent>(entity).value;
}
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
{
const float px = getTilePx();
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
return QVector2D(static_cast<float>(widgetPt.x()) / px,
static_cast<float>(widgetPt.y()) / px);
}
void ArenaView::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton)
{
const QVector2D worldPos = widgetToWorld(event->pos());
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (hit != entt::null)
@@ -287,7 +261,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
// Rendering
// ---------------------------------------------------------------------------
void ArenaView::drawTiles(QPainter& painter)
void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
{
const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
@@ -300,12 +274,12 @@ void ArenaView::drawTiles(QPainter& painter)
{
for (int y = 0; y < totalHeight; ++y)
{
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill);
painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
}
}
}
void ArenaView::drawBuildings(QPainter& painter)
void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
{
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{
@@ -317,13 +291,13 @@ void ArenaView::drawBuildings(QPainter& painter)
painter.setPen(Qt::NoPen);
for (const QPoint& cell : b.bodyCells)
{
painter.fillRect(tileRect(cell), bv.fill);
painter.fillRect(coordinates.tileRect(cell), bv.fill);
}
const QPointF tl = tileToWidget(b.anchor);
const QPointF tl = coordinates.tileToWidget(b.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
b.footprint.width() * static_cast<qreal>(getTilePx()),
b.footprint.height() * static_cast<qreal>(getTilePx()));
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -337,20 +311,16 @@ void ArenaView::drawBuildings(QPainter& painter)
}
}
void ArenaView::drawDebris(QPainter& painter)
void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
{
const float r = getTilePx() * 0.2f;
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
{
const QPointF center = worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90));
painter.setPen(QPen(QColor(50, 40, 30), 1));
painter.drawEllipse(center,
static_cast<qreal>(r), static_cast<qreal>(r));
drawDebrisMarker(painter, coordinates,
coordinates.worldToWidget(debris.position));
}
}
void ArenaView::drawStations(QPainter& painter)
void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
{
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
@@ -366,13 +336,13 @@ void ArenaView::drawStations(QPainter& painter)
painter.setPen(Qt::NoPen);
for (const QPoint& cell : sb.bodyCells)
{
painter.fillRect(tileRect(cell), bv.fill);
painter.fillRect(coordinates.tileRect(cell), bv.fill);
}
const QPointF tl = tileToWidget(sb.anchor);
const QPointF tl = coordinates.tileToWidget(sb.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(getTilePx()),
sb.footprint.height() * static_cast<qreal>(getTilePx()));
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -380,14 +350,9 @@ void ArenaView::drawStations(QPainter& painter)
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
drawHealthBar(painter, coordinates, bboxRect.left(),
bboxRect.bottom() + 1.0, bboxRect.width(),
h.hp / h.maxHp, f.isEnemy);
}
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
@@ -399,8 +364,10 @@ void ArenaView::drawStations(QPainter& painter)
});
}
void ArenaView::drawShips(QPainter& painter)
void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
{
const float forward = getShipForwardExtentPx(coordinates);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](entt::entity e, const ShipIdentityComponent& si,
@@ -411,40 +378,22 @@ void ArenaView::drawShips(QPainter& painter)
m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value);
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
const QVector2D perp(-dir.y(), dir.x());
const float fwd = getTilePx() * 0.45f;
const float side = getTilePx() * 0.25f;
QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
painter.setPen(QPen(it->second.outline, 1));
painter.setBrush(it->second.fill);
painter.drawPolygon(tri);
const QPointF center = coordinates.worldToWidget(pos.value);
drawShipBody(painter, coordinates, center, facing.radians,
it->second.fill, it->second.outline);
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
const qreal barW = static_cast<qreal>(forward) * 2.0;
const qreal barX = center.x() - static_cast<qreal>(forward);
const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
drawHealthBar(painter, coordinates, barX, barY, barW,
h.hp / h.maxHp, fac.isEnemy);
}
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
{
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
painter.setPen(QPen(QColor(255, 255, 0), 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
@@ -452,9 +401,9 @@ void ArenaView::drawShips(QPainter& painter)
});
}
void ArenaView::drawDebugSensorRanges(QPainter& painter)
void ArenaView::drawDebugSensorRanges(QPainter& painter,
const WorldCoordinates& coordinates)
{
painter.setBrush(Qt::NoBrush);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor)
@@ -463,17 +412,14 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(getTilePx());
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
painter.drawEllipse(center, radiusPx, radiusPx);
drawSensorRange(painter, coordinates,
coordinates.worldToWidget(pos.value),
sensor.value_tiles, it->second.outline);
});
}
void ArenaView::drawDebugTargetLines(QPainter& painter)
void ArenaView::drawDebugTargetLines(QPainter& painter,
const WorldCoordinates& coordinates)
{
// Draw a thin translucent line from a ship to a target, colored by the ship's
// team to match the per-side HQ/station colors used elsewhere in the arena
@@ -491,7 +437,8 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
QColor lineColor = it->second.fill;
lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1));
painter.drawLine(worldToWidget(from), worldToWidget(to));
painter.drawLine(coordinates.worldToWidget(from),
coordinates.worldToWidget(to));
};
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
@@ -536,7 +483,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
});
}
void ArenaView::drawBeams(QPainter& painter)
void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
{
for (const ActiveBeam& beam : m_activeBeams)
{
@@ -552,7 +499,7 @@ void ArenaView::drawBeams(QPainter& painter)
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset));
painter.drawLine(coordinates.worldToWidget(*shooterPos),
coordinates.worldToWidget(*targetPos + beam.targetOffset));
}
}

View File

@@ -17,6 +17,7 @@
#include "Tick.h"
#include "TickDriver.h"
#include "VisualsConfig.h"
#include "WorldCoordinates.h"
class ArenaSimulation;
class QPainter;
@@ -47,22 +48,22 @@ private slots:
private:
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter);
void drawDebris(QPainter& painter);
void drawShips(QPainter& painter);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawBeams(QPainter& painter);
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
float getTilePx() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QRectF tileRect(QPoint tile) const;
// The world <-> widget transform for the current viewport size. The arena
// shows the whole world at once and never scrolls, so this fits the arena's
// full extent into the widget; like GameWorldView's, it is a per-frame
// snapshot rather than cached state.
WorldCoordinates getCoordinates() const;
std::optional<QVector2D> entityPosition(entt::entity entity) const;
QVector2D widgetToWorld(QPoint widgetPt) const;
struct ActiveBeam
{

View File

@@ -9,6 +9,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
# Shared world-space shapes so the arena keeps looking like the game
# (see WorldPrimitives.h). The balancing target does not link the ui library,
# so the few ui files it needs are compiled into it, as above.
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.h
PARENT_SCOPE
)
@@ -23,5 +27,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
PARENT_SCOPE
)

View File

@@ -0,0 +1,261 @@
#include "BuildModeController.h"
#include <memory>
#include <utility>
#include "BlueprintModeExitedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "DeconstructModeChangedEvent.h"
#include "EventManager.h"
namespace
{
Rotation rotateClockwise(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return Rotation::East;
case Rotation::East: return Rotation::South;
case Rotation::South: return Rotation::West;
case Rotation::West: return Rotation::North;
}
return Rotation::East;
}
Rotation rotateCounterClockwise(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return Rotation::West;
case Rotation::East: return Rotation::North;
case Rotation::South: return Rotation::East;
case Rotation::West: return Rotation::South;
}
return Rotation::East;
}
} // namespace
BuildMode BuildModeController::getMode() const
{
return m_mode;
}
bool BuildModeController::isBuilderMode() const
{
return m_mode == BuildMode::Builder;
}
bool BuildModeController::isBlueprintMode() const
{
return m_mode == BuildMode::Blueprint;
}
bool BuildModeController::isDeconstructMode() const
{
return m_mode == BuildMode::Deconstruct;
}
void BuildModeController::enterMode(BuildMode mode)
{
if (m_mode == mode) { return; }
// Leave the current mode properly, so its widget hears about it however the
// player left. Each exit clears only its own state.
switch (m_mode)
{
case BuildMode::Builder:
m_draggingBelt = false;
m_beltDragPath.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuilderModeExitedEvent>());
break;
case BuildMode::Blueprint:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
break;
case BuildMode::Deconstruct:
m_deconstructHoverBuildingId.reset();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(false));
break;
case BuildMode::None:
break;
}
m_mode = mode;
if (mode == BuildMode::Deconstruct)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(true));
}
}
void BuildModeController::enterBuilderMode(BuildingType type)
{
enterMode(BuildMode::Builder);
m_builderType = type;
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_tunnelGhostType = BuildingType::TunnelEntry;
m_tunnelPartnerTile.reset();
}
void BuildModeController::enterBlueprintMode(Blueprint blueprint)
{
enterMode(BuildMode::Blueprint);
// The layout starts where the builder ghost last was, so switching from a
// building to a blueprint does not jump the preview across the world.
m_blueprintGhostTile = m_ghostTile;
m_blueprint = std::move(blueprint);
}
void BuildModeController::toggleDeconstructMode()
{
enterMode(isDeconstructMode() ? BuildMode::None : BuildMode::Deconstruct);
}
void BuildModeController::exitBuilderMode()
{
if (!isBuilderMode()) { return; }
enterMode(BuildMode::None);
}
void BuildModeController::exitBlueprintMode()
{
if (!isBlueprintMode()) { return; }
enterMode(BuildMode::None);
}
void BuildModeController::exitCurrentMode()
{
enterMode(BuildMode::None);
}
BuildingType BuildModeController::getBuilderType() const
{
return m_builderType;
}
bool BuildModeController::isTunnelMode() const
{
return isBuilderMode() && m_builderType == BuildingType::TunnelEntry;
}
BuildingType BuildModeController::getEffectiveBuilderType() const
{
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
}
QPoint BuildModeController::getGhostTile() const
{
return m_ghostTile;
}
Rotation BuildModeController::getGhostRotation() const
{
return m_ghostRotation;
}
bool BuildModeController::isGhostValid() const
{
return m_ghostValid;
}
void BuildModeController::setGhostTile(QPoint tile)
{
m_ghostTile = tile;
}
void BuildModeController::setGhostValidity(bool valid)
{
m_ghostValid = valid;
}
void BuildModeController::rotateGhost(bool clockwise)
{
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
: rotateCounterClockwise(m_ghostRotation);
}
BuildingType BuildModeController::getTunnelGhostType() const
{
return m_tunnelGhostType;
}
const std::optional<QPoint>& BuildModeController::getTunnelPartnerTile() const
{
return m_tunnelPartnerTile;
}
void BuildModeController::setTunnelGhost(BuildingType resolvedType,
std::optional<QPoint> partnerTile)
{
m_tunnelGhostType = resolvedType;
m_tunnelPartnerTile = std::move(partnerTile);
}
bool BuildModeController::isDraggingBelt() const
{
return m_draggingBelt;
}
QPoint BuildModeController::getBeltDragAnchor() const
{
return m_beltDragAnchor;
}
const std::vector<BeltPathTile>& BuildModeController::getBeltDragPath() const
{
return m_beltDragPath;
}
void BuildModeController::beginBeltDrag(QPoint anchorTile)
{
m_draggingBelt = true;
m_beltDragAnchor = anchorTile;
}
void BuildModeController::setBeltDragPath(std::vector<BeltPathTile> path)
{
m_beltDragPath = std::move(path);
}
void BuildModeController::cancelBeltDrag()
{
m_draggingBelt = false;
m_beltDragPath.clear();
}
const Blueprint& BuildModeController::getBlueprint() const
{
return m_blueprint;
}
Blueprint& BuildModeController::getMutableBlueprint()
{
return m_blueprint;
}
QPoint BuildModeController::getBlueprintGhostTile() const
{
return m_blueprintGhostTile;
}
void BuildModeController::setBlueprintGhostTile(QPoint tile)
{
m_blueprintGhostTile = tile;
}
const std::optional<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const
{
return m_deconstructHoverBuildingId;
}
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
{
m_deconstructHoverBuildingId = std::move(id);
}

View File

@@ -0,0 +1,123 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#include "BeltDragPath.h"
#include "Blueprint.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
// Which of the mutually exclusive world-interaction modes is active
// (REQ-UI-HOTKEYS, REQ-BLD-GHOST, REQ-UI-BLUEPRINT-PLACE, REQ-BLD-DECONSTRUCT).
enum class BuildMode
{
None, // plain selection
Builder, // placing one building type, ghost following the cursor
Blueprint, // placing a saved multi-building layout
Deconstruct // marking buildings for demolition
};
// The active build mode and the transient state that belongs to it.
//
// These modes were previously three independent flags, and every entry point
// cleared the other two by hand — inconsistently, which is how entering builder
// mode came to drop a blueprint without announcing it. Here exclusivity is
// structural: one mode is active, and every transition runs through enterMode(),
// which exits whatever was active first and publishes the same events regardless
// of which way the player got there.
//
// Everything needing the simulation — placement validity, tunnel matching, belt
// path building — stays with the caller, which computes and hands back the result
// (setGhostValidity, setTunnelGhost, setBeltDragPath). That keeps this a plain
// value that can be tested without a world.
class BuildModeController
{
public:
BuildMode getMode() const;
bool isBuilderMode() const;
bool isBlueprintMode() const;
bool isDeconstructMode() const;
// --- transitions ----------------------------------------------------------
// Each leaves the previously active mode with its proper exit event.
void enterBuilderMode(BuildingType type);
void enterBlueprintMode(Blueprint blueprint);
// Leaves deconstruct mode if it is active, enters it otherwise
// (REQ-BLD-DECONSTRUCT-CLICK).
void toggleDeconstructMode();
void exitBuilderMode();
void exitBlueprintMode();
// Backs out of whichever mode is active, if any (the Q key and right-click).
void exitCurrentMode();
// --- builder mode ---------------------------------------------------------
// Only meaningful while isBuilderMode().
BuildingType getBuilderType() const;
// True while the builder type is TunnelEntry, where the ghost resolves to an
// entry or an exit by hovered position (REQ-BLD-TUNNEL-MODE).
bool isTunnelMode() const;
// The type the ghost currently represents: the position-resolved tunnel type in
// tunnel mode, the plain builder type otherwise.
BuildingType getEffectiveBuilderType() const;
QPoint getGhostTile() const;
Rotation getGhostRotation() const;
bool isGhostValid() const;
void setGhostTile(QPoint tile);
void setGhostValidity(bool valid);
// Turns the ghost one quarter turn. Validity is not rechecked here; the caller
// does that and calls setGhostValidity, because only it can see the world.
void rotateGhost(bool clockwise);
BuildingType getTunnelGhostType() const;
const std::optional<QPoint>& getTunnelPartnerTile() const;
void setTunnelGhost(BuildingType resolvedType, std::optional<QPoint> partnerTile);
// --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------
bool isDraggingBelt() const;
QPoint getBeltDragAnchor() const;
const std::vector<BeltPathTile>& getBeltDragPath() const;
void beginBeltDrag(QPoint anchorTile);
void setBeltDragPath(std::vector<BeltPathTile> path);
// Drops the drag without placing anything, staying in builder mode.
void cancelBeltDrag();
// --- blueprint mode -------------------------------------------------------
// Only meaningful while isBlueprintMode().
const Blueprint& getBlueprint() const;
// Mutable so the caller can rotate the layout in place; rotating a blueprint
// needs building footprints from the config, which does not belong here.
Blueprint& getMutableBlueprint();
QPoint getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile);
// --- deconstruct mode -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
private:
// The single transition point: leaves the active mode, then enters `mode`.
void enterMode(BuildMode mode);
BuildMode m_mode = BuildMode::None;
BuildingType m_builderType = BuildingType::Belt;
QPoint m_ghostTile;
Rotation m_ghostRotation = Rotation::East;
bool m_ghostValid = false;
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
std::optional<QPoint> m_tunnelPartnerTile;
bool m_draggingBelt = false;
QPoint m_beltDragAnchor;
std::vector<BeltPathTile> m_beltDragPath;
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

View File

@@ -14,6 +14,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
PARENT_SCOPE
)
@@ -25,6 +29,10 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
PARENT_SCOPE
)

View File

@@ -0,0 +1,165 @@
#include "SelectionController.h"
#include <algorithm>
#include <memory>
#include "DebrisSelectionChangedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "SelectionChangedEvent.h"
namespace
{
template <typename T>
bool contains(const std::vector<T>& items, const T& item)
{
return std::find(items.begin(), items.end(), item) != items.end();
}
// Applies `hits` to `selection` per `mode`. Replace is handled by the caller so
// that an empty hit list can mean "clear this category" there but "leave this
// category alone" here.
template <typename T>
void combine(std::vector<T>& selection, const std::vector<T>& hits, SelectionMode mode)
{
for (const T& hit : hits)
{
const typename std::vector<T>::iterator it =
std::find(selection.begin(), selection.end(), hit);
if (it == selection.end())
{
selection.push_back(hit);
}
else if (mode == SelectionMode::Toggle)
{
// Only a toggle removes; an additive box drag never deselects.
selection.erase(it);
}
}
}
} // namespace
const std::vector<BuildingId>& SelectionController::getSelectedBuildings() const
{
return m_buildings;
}
const std::vector<entt::entity>& SelectionController::getSelectedActors() const
{
return m_actors;
}
const std::vector<entt::entity>& SelectionController::getSelectedDebris() const
{
return m_debris;
}
bool SelectionController::isActorSelected(entt::entity actor) const
{
return contains(m_actors, actor);
}
bool SelectionController::isDebrisSelected(entt::entity debris) const
{
return contains(m_debris, debris);
}
void SelectionController::selectBuildings(const std::vector<BuildingId>& ids,
SelectionMode mode)
{
// Buildings win over field objects (REQ-UI-SELECTION-CATEGORIES).
if (clearActorsQuietly()) { publishActors(); }
if (clearDebrisQuietly()) { publishDebris(); }
if (mode == SelectionMode::Replace)
{
m_buildings = ids;
}
else
{
combine(m_buildings, ids, mode);
}
publishBuildings();
}
void SelectionController::selectFieldObjects(const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris,
SelectionMode mode)
{
if (clearBuildingsQuietly()) { publishBuildings(); }
if (mode == SelectionMode::Replace)
{
m_actors = actors;
m_debris = debris;
}
else
{
combine(m_actors, actors, mode);
combine(m_debris, debris, mode);
}
publishActors();
publishDebris();
}
void SelectionController::clearAll()
{
if (clearBuildingsQuietly()) { publishBuildings(); }
if (clearActorsQuietly()) { publishActors(); }
if (clearDebrisQuietly()) { publishDebris(); }
}
void SelectionController::setSelectedActors(std::vector<entt::entity> actors)
{
if (actors == m_actors) { return; }
m_actors = std::move(actors);
publishActors();
}
void SelectionController::setSelectedDebris(std::vector<entt::entity> debris)
{
if (debris == m_debris) { return; }
m_debris = std::move(debris);
publishDebris();
}
bool SelectionController::clearBuildingsQuietly()
{
if (m_buildings.empty()) { return false; }
m_buildings.clear();
return true;
}
bool SelectionController::clearActorsQuietly()
{
if (m_actors.empty()) { return false; }
m_actors.clear();
return true;
}
bool SelectionController::clearDebrisQuietly()
{
if (m_debris.empty()) { return false; }
m_debris.clear();
return true;
}
void SelectionController::publishBuildings() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_buildings));
}
void SelectionController::publishActors() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_actors));
}
void SelectionController::publishDebris() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebrisSelectionChangedEvent>(m_debris));
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "entt/entity/entity.hpp"
// How a new hit combines with what is already selected.
enum class SelectionMode
{
Replace, // plain click or drag: the hit becomes the whole selection
Toggle, // Ctrl + click: the hit joins the selection, or leaves it if present
Add // Ctrl + box drag: the hits join the selection, never leave it
};
// The player's current selection across the three categories, and the rules that
// govern moving between them (REQ-UI-SELECTION-CATEGORIES).
//
// The rules were previously written out once for point-clicks and once for box
// drags, which is why they live here now: buildings win over field objects, so
// selecting a building clears actors and debris, and selecting either of those
// clears buildings — but actors and debris coexist with each other
// (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT, REQ-UI-MULTI-SELECT,
// REQ-UI-DEBRIS-MULTI-SELECT). A point-click and a box drag then differ only in
// the SelectionMode they pass and in how many hits they pass.
//
// Every mutator publishes the change events, so callers never emit them by hand.
// Hit-testing is not done here: callers resolve what was hit and pass the result
// in, which keeps this free of any simulation dependency.
class SelectionController
{
public:
const std::vector<BuildingId>& getSelectedBuildings() const;
const std::vector<entt::entity>& getSelectedActors() const;
const std::vector<entt::entity>& getSelectedDebris() const;
bool isActorSelected(entt::entity actor) const;
bool isDebrisSelected(entt::entity debris) const;
// Selects buildings and/or construction sites, clearing any field selection.
// Always publishes SelectionChangedEvent, even when the result is unchanged,
// so a click on an already-selected building still refreshes its panel.
void selectBuildings(const std::vector<BuildingId>& ids, SelectionMode mode);
// Selects field objects, clearing any building selection. Actors and debris are
// set together so a Replace can express "these actors and no debris" — which is
// what a plain click on an actor means — while a Toggle or Add leaves the
// category whose vector is empty untouched. Always publishes both field events.
void selectFieldObjects(const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris,
SelectionMode mode);
// Empties all three categories, publishing only for those that were non-empty.
void clearAll();
// Replace one category outright, publishing only if it actually changed. For
// the per-frame prune of entities that despawned or died: the liveness query
// belongs to the caller, which is the one that can see the simulation.
void setSelectedActors(std::vector<entt::entity> actors);
void setSelectedDebris(std::vector<entt::entity> debris);
private:
// Each returns whether anything changed, without publishing.
bool clearBuildingsQuietly();
bool clearActorsQuietly();
bool clearDebrisQuietly();
void publishBuildings() const;
void publishActors() const;
void publishDebris() const;
std::vector<BuildingId> m_buildings;
std::vector<entt::entity> m_actors;
std::vector<entt::entity> m_debris;
};

View File

@@ -153,3 +153,13 @@ std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
return std::nullopt;
}
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels)
{
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
{
const TunnelTileMap::const_iterator it = tunnels.find(tile);
if (it == tunnels.end()) { return std::nullopt; }
return it->second;
};
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include <functional>
#include <map>
#include <optional>
#include <QPoint>
@@ -9,6 +10,17 @@
#include "BuildingType.h"
#include "Rotation.h"
// QPoint has no operator<, so an explicit ordering is needed to key a map or set
// by tile.
struct QPointCompare
{
bool operator()(const QPoint& a, const QPoint& b) const
{
if (a.x() != b.x()) { return a.x() < b.x(); }
return a.y() < b.y();
}
};
// A tunnel building occupying a single tile: whether it is an entry or an exit and
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
@@ -22,6 +34,13 @@ struct TunnelTileInfo
// direction, or std::nullopt when the tile holds no tunnel building.
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
// Wraps a tunnel tile index in the lookup functor the helpers below take. The
// returned functor references `tunnels`, which must outlive it.
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
// any other direction are skipped, mirroring the "stop at the first same-direction

View File

@@ -0,0 +1,72 @@
#include "WorldCamera.h"
#include <algorithm>
namespace
{
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
{
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
return valueAt0 + (valueAt1 - valueAt0) * t;
}
}
WorldCamera::WorldCamera(const WorldScroll& scroll, const WorldRegions& regions)
: m_scroll(&scroll)
, m_regions(&regions)
, m_viewCenterXTiles(0.0f)
{
}
bool WorldCamera::advance(PanDirection direction, qint64 elapsedMs,
ScrollBounds bounds)
{
const float before = m_viewCenterXTiles;
if (direction != PanDirection::None)
{
const float distance =
getPanSpeedTilesPerSecondAt(m_viewCenterXTiles, bounds.rightTiles)
* static_cast<float>(elapsedMs) / 1000.0f;
m_viewCenterXTiles += (direction == PanDirection::Left) ? -distance : distance;
}
m_viewCenterXTiles = std::max(bounds.leftTiles,
std::min(m_viewCenterXTiles, bounds.rightTiles));
return m_viewCenterXTiles != before;
}
float WorldCamera::getViewCenterXTiles() const
{
return m_viewCenterXTiles;
}
void WorldCamera::reset()
{
m_viewCenterXTiles = 0.0f;
}
float WorldCamera::getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const
{
// Slow near the asteroid/player buffer, fast across the contest zone, with a
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
// contest zone spans from the player buffer's right edge to the enemy stations,
// the latter tracked live so the ramp follows the front line as it is pushed.
const float slow = static_cast<float>(m_scroll->panSpeedSlow_tps);
const float fast = static_cast<float>(m_scroll->panSpeedFast_tps);
const float half = static_cast<float>(m_scroll->panRampBandWidth_tiles) / 2.0f;
const float leftEdge = static_cast<float>(m_regions->playerBufferWidth_tiles);
const float rightEdge = contestZoneRightEdgeTiles;
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
// below the fast speed where the two ramps cross.
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
return std::min(leftRamp, rightRamp);
}

View File

@@ -0,0 +1,72 @@
#pragma once
#include <QtGlobal>
#include "WorldConfig.h"
// Which way the player is currently panning the view (REQ-UI-SCROLL). A plain
// direction rather than key state: the camera is deliberately agnostic about how
// the intent was expressed, so rebindable controls would change nothing here.
enum class PanDirection
{
None,
Left,
Right
};
// The horizontal limits of the view center, in world tiles (REQ-GW-SCROLL-LIMIT):
// the view can pan left until the asteroid's left edge is centered and right until
// the enemy stations are. Both move as the game progresses — the left edge with
// asteroid expansion (REQ-GW-ASTEROID-EXPAND), the right edge as stations are
// pushed back (REQ-GW-PUSH-EXPAND) — so they are supplied per frame by the caller
// that can see the simulation, rather than queried here. That keeps the camera a
// value with no simulation dependency.
struct ScrollBounds
{
float leftTiles;
float rightTiles;
};
// Horizontal view position for the game world (REQ-UI-SCROLL, REQ-UI-SCROLL-SPEED).
// Works purely in world units — tiles and tiles per second, never pixels. Turning
// the resulting position into a widget transform is WorldCoordinates' job; the two
// meet only where the view feeds getViewCenterXTiles() into that transform.
class WorldCamera
{
public:
// Both config structs are referenced rather than copied: they live inside the
// Simulation's GameConfig, which is assigned in place on restart
// (REQ-CFG-RELOAD), so a camera built once still picks up reloaded tuning.
WorldCamera(const WorldScroll& scroll, const WorldRegions& regions);
// Pans by `direction` for `elapsedMs` of wall-clock time, then clamps into
// `bounds`. Wall clock rather than ticks because panning is presentation only
// (REQ-UI-NO-ZOOM's sibling concern) and keeps working while the simulation is
// paused. Clamping happens on every call, not just when panning, so the view
// follows the bounds inward when they shrink.
//
// Returns true when the view center actually moved — including when it moved
// only because the bounds did. Callers use that to refresh anything anchored to
// the world under a stationary cursor, such as the box-select rectangle.
bool advance(PanDirection direction, qint64 elapsedMs, ScrollBounds bounds);
// World X (tiles) at the center of the viewport.
float getViewCenterXTiles() const;
// Returns the view to the start-of-run position. Deliberately does not clamp:
// a new run's bounds are not known here, and the next advance() clamps anyway.
void reset();
// Pan speed at a given view center, in tiles/s (REQ-UI-SCROLL-SPEED). Public
// because the ramp shape is the subtle part of this class and is worth testing
// directly; advance() uses it internally. `contestZoneRightEdgeTiles` is the
// live right-hand boundary — the same value as ScrollBounds::rightTiles — so
// the ramp follows the front line as it is pushed.
float getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
float contestZoneRightEdgeTiles) const;
private:
const WorldScroll* m_scroll;
const WorldRegions* m_regions;
float m_viewCenterXTiles;
};

View File

@@ -0,0 +1,118 @@
#include "WorldCoordinates.h"
#include <algorithm>
#include <cmath>
namespace
{
// A zero-height widget, a not-yet-shown widget, or a degenerate world size
// would otherwise make every conversion divide by zero.
float sanitizeTilePx(float tilePx)
{
return tilePx > 0.0f ? tilePx : 1.0f;
}
}
WorldCoordinates WorldCoordinates::scrolling(QSize widgetSize_px,
int worldHeight_tiles,
float viewCenterX_tiles)
{
float tilePx = 1.0f;
if (worldHeight_tiles > 0)
{
tilePx = static_cast<float>(widgetSize_px.height())
/ static_cast<float>(worldHeight_tiles);
}
tilePx = sanitizeTilePx(tilePx);
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
viewCenterX_tiles - viewportWidthTiles / 2.0f,
worldHeight_tiles);
}
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
int worldWidth_tiles,
int worldHeight_tiles)
{
float tilePx = 1.0f;
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
{
// The tighter of the two fits, so the whole world stays on screen.
tilePx = std::min(
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
}
tilePx = sanitizeTilePx(tilePx);
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
0.0f, worldHeight_tiles);
}
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
float viewLeft_tiles, int worldHeight_tiles)
: m_tilePx(tilePx)
, m_viewportWidthTiles(viewportWidth_px / tilePx)
, m_viewLeftTiles(viewLeft_tiles)
, m_worldHeightTiles(worldHeight_tiles)
{
}
float WorldCoordinates::getTilePx() const
{
return m_tilePx;
}
float WorldCoordinates::getViewportWidthTiles() const
{
return m_viewportWidthTiles;
}
float WorldCoordinates::getViewLeftTiles() const
{
return m_viewLeftTiles;
}
QPointF WorldCoordinates::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>((worldPos.x() - m_viewLeftTiles) * m_tilePx),
static_cast<qreal>(worldPos.y() * m_tilePx));
}
QPointF WorldCoordinates::tileToWidget(QPoint tile) const
{
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
static_cast<float>(tile.y())));
}
QPoint WorldCoordinates::widgetToTile(QPoint widgetPoint) const
{
const QVector2D world = widgetToWorld(widgetPoint);
return QPoint(static_cast<int>(std::floor(world.x())),
static_cast<int>(std::floor(world.y())));
}
QVector2D WorldCoordinates::widgetToWorld(QPoint widgetPoint) const
{
return QVector2D(
static_cast<float>(widgetPoint.x()) / m_tilePx + m_viewLeftTiles,
static_cast<float>(widgetPoint.y()) / m_tilePx);
}
QRectF WorldCoordinates::tileRect(QPoint tile) const
{
const QPointF topLeft = tileToWidget(tile);
return QRectF(topLeft.x(), topLeft.y(),
static_cast<qreal>(m_tilePx), static_cast<qreal>(m_tilePx));
}
QRect WorldCoordinates::getViewportRect() const
{
const int left = static_cast<int>(std::floor(m_viewLeftTiles)) - 1;
const int top = 0;
const int right = static_cast<int>(
std::ceil(m_viewLeftTiles + m_viewportWidthTiles)) + 1;
const int bottom = m_worldHeightTiles;
return QRect(left, top, right - left, bottom - top);
}

View File

@@ -0,0 +1,63 @@
#pragma once
#include <QPoint>
#include <QPointF>
#include <QRect>
#include <QRectF>
#include <QSize>
#include <QVector2D>
// Immutable snapshot of the world <-> widget transform for one viewport state
// (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
// the tile size and the left edge are derived, and everything downstream of that
// is shared.
//
// The transform is a value: it is constructed from the viewport size and the view
// state, and never observes them again. A caller therefore builds one per frame
// (or per event) rather than holding one across a resize or a scroll, which would
// silently go stale.
class WorldCoordinates
{
public:
// The scrolling game world: the tile size is whatever makes the world height
// exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
// REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
// world X at the center of the viewport, matching how the scroll position is
// stored and clamped (REQ-GW-SCROLL-LIMIT).
static WorldCoordinates scrolling(QSize widgetSize_px, int worldHeight_tiles,
float viewCenterX_tiles);
// A whole world shown at once with no scrolling, as the balancing tool's arena
// does: the tile size is whichever axis is the tighter fit, so nothing is cut
// off, and the world origin sits at the widget's top-left. A viewport wider
// than the fitted world leaves empty space to the right rather than centering.
static WorldCoordinates fitToWorld(QSize widgetSize_px, int worldWidth_tiles,
int worldHeight_tiles);
// Side length of one tile in pixels. Always positive: a degenerate world or
// viewport size falls back to 1.0 so no conversion below divides by zero.
float getTilePx() const;
float getViewportWidthTiles() const;
// World X (tiles) at the left edge of the viewport.
float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPoint) const;
QVector2D widgetToWorld(QPoint widgetPoint) const;
// Widget-space rect covering the whole of `tile`.
QRectF tileRect(QPoint tile) const;
// Tile-space rect of everything currently on screen, widened by one column on
// each side so items straddling an edge are still drawn.
QRect getViewportRect() const;
private:
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
int worldHeight_tiles);
float m_tilePx;
float m_viewportWidthTiles;
float m_viewLeftTiles;
int m_worldHeightTiles;
};

View File

@@ -16,6 +16,12 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h

View File

@@ -0,0 +1,12 @@
#pragma once
#include "Event.h"
// The player asked to toggle the debug overlays (REQ-UI-HOTKEYS). The request to
// flip the flag; DebugDrawToggledEvent is the announcement that it was flipped and
// what it now is. Splitting the two keeps the flag itself in one owner.
class DebugDrawToggleRequestedEvent : public Event
{
public:
DebugDrawToggleRequestedEvent() = default;
};

View File

@@ -0,0 +1,13 @@
#pragma once
#include "Event.h"
// The player asked to rotate the placement ghost (REQ-BLD-ROTATE, REQ-UI-HOTKEYS).
// Sent whether or not a builder or blueprint mode is actually active; deciding
// there is nothing to rotate is the receiver's job.
class GhostRotationRequestedEvent : public Event
{
public:
explicit GhostRotationRequestedEvent(bool clockwise) : clockwise(clockwise) {}
const bool clockwise;
};

View File

@@ -0,0 +1,13 @@
#pragma once
#include "Event.h"
// The player pressed the one "get me out of the current mode" key (REQ-UI-HOTKEYS).
// Intentionally says only that, not which mode to leave: which of builder,
// blueprint placement, or deconstruct is active — and that the key falls through to
// entering deconstruct mode when none of them is — is state only the receiver has.
class ModeCancelRequestedEvent : public Event
{
public:
ModeCancelRequestedEvent() = default;
};

View File

@@ -0,0 +1,22 @@
#pragma once
#include "Event.h"
#include "WorldCamera.h"
// The direction the player is currently panning the view has changed
// (REQ-UI-SCROLL). Deliberately level-triggered: the payload is the complete
// current direction, including PanDirection::None when panning stops, rather than
// separate started/stopped events. A receiver that only ever saw edges would have
// to reconstruct the state and would be left panning forever if one edge went
// missing — which is exactly what happens when the widget loses focus mid-pan.
//
// Unlike the state-change events elsewhere in the UI, the receiver does cache this
// payload instead of re-reading the value from somewhere authoritative. That is
// correct here: input has no other source of truth to re-read from, so the
// publisher (InputMapper) is the authority and the payload is the value.
class PanDirectionChangedEvent : public Event
{
public:
explicit PanDirectionChangedEvent(PanDirection direction) : direction(direction) {}
const PanDirection direction;
};

View File

@@ -0,0 +1,12 @@
#pragma once
#include "Event.h"
// The player asked to pause or unpause (REQ-UI-HOTKEYS). Carries no speed: which
// speed to restore on unpause is the receiver's business, since it is the one that
// remembers what was running before the pause.
class PauseToggleRequestedEvent : public Event
{
public:
PauseToggleRequestedEvent() = default;
};

View File

@@ -0,0 +1,14 @@
#pragma once
#include "Event.h"
// The player asked to step the game speed one notch (REQ-UI-HOTKEYS): +1 faster,
// -1 slower. A relative step rather than a target speed, because the ladder of
// available speeds belongs to the receiver — contrast SpeedChangeRequestedEvent,
// which names an absolute multiplier and is what the speed buttons send.
class SpeedStepRequestedEvent : public Event
{
public:
explicit SpeedStepRequestedEvent(int delta) : delta(delta) {}
const int delta;
};

View File

@@ -1,5 +1,6 @@
#include "FactoryQueries.h"
#include <algorithm>
#include <limits>
#include "PortGeometry.h"
@@ -179,3 +180,61 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin
return std::nullopt;
}
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB)
{
const int x0 = std::min(cornerA.x(), cornerB.x());
const int y0 = std::min(cornerA.y(), cornerB.y());
const int x1 = std::max(cornerA.x(), cornerB.x());
const int y1 = std::max(cornerA.y(), cornerB.y());
const auto covers = [&](const std::vector<QPoint>& bodyCells)
{
for (const QPoint& cell : bodyCells)
{
if (cell.x() >= x0 && cell.x() <= x1
&& cell.y() >= y0 && cell.y() <= y1)
{
return true;
}
}
return false;
};
std::vector<BuildingId> ids;
for (const Building& building : getAllBuildings(state))
{
if (covers(building.bodyCells)) { ids.push_back(building.id); }
}
for (const ConstructionSite& site : getAllSites(state))
{
if (covers(site.bodyCells)) { ids.push_back(site.id); }
}
return ids;
}
TunnelTileMap collectTunnelTiles(const FactoryState& state)
{
// Index every tunnel entry/exit — built or still a construction site — by its
// single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
TunnelTileMap tunnels;
for (const Building& building : getAllBuildings(state))
{
if (building.type == BuildingType::TunnelEntry
|| building.type == BuildingType::TunnelExit)
{
tunnels[building.anchor] = TunnelTileInfo{building.type, building.rotation};
}
}
for (const ConstructionSite& site : getAllSites(state))
{
if (site.type == BuildingType::TunnelEntry
|| site.type == BuildingType::TunnelExit)
{
tunnels[site.anchor] = TunnelTileInfo{site.type, site.rotation};
}
}
return tunnels;
}

View File

@@ -12,6 +12,7 @@
#include "FactoryState.h"
#include "GameConfig.h"
#include "Port.h"
#include "TunnelCompletion.h"
// Queries and operations over the factory's world data that need nothing but that
// data — no config, no belts, no RNG. Free functions rather than BuildingSystem
@@ -69,3 +70,13 @@ std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& con
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
const GameConfig& config,
BuildingId id);
// Ids of all buildings and construction sites whose footprint intersects the tile
// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT,
// REQ-BLD-DECONSTRUCT-BOX).
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
QPoint cornerA, QPoint cornerB);
// Every tunnel entry and exit, built or still a construction site, indexed by its
// single-cell tile. Shared by the placement preview and the selection highlight.
TunnelTileMap collectTunnelTiles(const FactoryState& state);

View File

@@ -119,3 +119,82 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, con
return std::nullopt;
}
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation)
{
// Terrain and world-bounds validity first (REQ-BLD-PLACE-VALID); occupancy is
// the extra rule this adds.
if (!isPlacementValid(state, config, type, anchor, rotation))
{
return false;
}
const BuildingDef* def = config.buildings.findBuildingDef(type);
if (!def) { return false; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
bool anyOccupied = false;
for (const QPoint& relativeCell : parsed.bodyCells)
{
if (isTileOccupied(state, anchor + relativeCell))
{
anyOccupied = true;
break;
}
}
if (anyOccupied)
{
// Occupied is still placeable when what is there is the same building being
// re-oriented (REQ-BLD-ROTATE-IN-PLACE).
return findRotateInPlaceTarget(state, config, type, anchor, rotation).has_value();
}
return true;
}
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
const FactoryState& state,
const GameConfig& config,
int buildingBlocksStock)
{
std::vector<BeltDragResolved> resolved;
resolved.reserve(path.size());
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::Belt);
const int beltCost = (def != nullptr) ? def->cost : 0;
int spent = 0;
for (const BeltPathTile& entry : path)
{
BeltDragResolved item;
const std::optional<BuildingId> rotateTarget =
findRotateInPlaceTarget(state, config, BuildingType::Belt,
entry.tile, entry.rotation);
if (rotateTarget.has_value())
{
// A tile holding only a belt (or belt site) is re-oriented, no cost.
item.action = BeltTileAction::RotateInPlace;
item.affordable = true;
item.rotateId = rotateTarget;
}
else if (canPlaceBuilding(state, config, BuildingType::Belt,
entry.tile, entry.rotation))
{
// Empty, valid cell: a new belt, subject to cumulative affordability.
item.action = BeltTileAction::PlaceNew;
item.affordable = (spent + beltCost <= buildingBlocksStock);
item.rotateId = std::nullopt;
if (item.affordable) { spent += beltCost; }
}
else
{
// Occupied by a non-belt building/site, or otherwise invalid terrain.
item.action = BeltTileAction::Invalid;
item.affordable = false;
item.rotateId = std::nullopt;
}
resolved.push_back(item);
}
return resolved;
}

View File

@@ -5,6 +5,7 @@
#include <QPoint>
#include "BeltDragPath.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "FactoryState.h"
@@ -36,3 +37,35 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
const GameConfig& config,
BuildingType type, QPoint anchor,
Rotation rot);
// True if placing here would actually do something: the terrain and bounds rules of
// isPlacementValid hold, and the body cells are either all free or occupied only by
// a building this placement would rotate in place. This is the question the ghost
// asks to colour itself and the click path asks before enqueuing a command
// (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID, REQ-BLD-ROTATE-IN-PLACE).
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation);
// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG).
enum class BeltTileAction
{
PlaceNew, // empty, valid cell: a new belt, subject to affordability
RotateInPlace, // already a belt (or belt site): re-oriented, free
Invalid // occupied by something else, or invalid terrain
};
struct BeltDragResolved
{
BeltTileAction action;
bool affordable; // meaningful only for PlaceNew
std::optional<BuildingId> rotateId; // set only for RotateInPlace
};
// Classifies every tile of a belt drag path against the current factory state,
// spending `buildingBlocksStock` cumulatively across the PlaceNew tiles so a path
// longer than the player can afford is only partly buildable (REQ-BLD-BELT-DRAG).
// Shared so the previewed ghosts and the placement on release cannot disagree.
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
const FactoryState& state,
const GameConfig& config,
int buildingBlocksStock);

View File

@@ -0,0 +1,301 @@
#include "catch.hpp"
#include <memory>
#include "BlueprintModeExitedEvent.h"
#include "BuildModeController.h"
#include "BuilderModeExitedEvent.h"
#include "DeconstructModeChangedEvent.h"
#include "EventHandler.h"
#include "EventManager.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Records the mode events, which are the half of this class's contract that the
// panels depend on: a mode that ends without announcing it leaves its button
// stuck highlighted.
class ModeEventSpy : public CombinedEventHandler<BuilderModeExitedEvent,
BlueprintModeExitedEvent,
DeconstructModeChangedEvent>
{
public:
ModeEventSpy() { registerForEvents(); }
~ModeEventSpy() { unregisterForEvents(); }
int builderExits = 0;
int blueprintExits = 0;
int deconstructChanges = 0;
bool lastDeconstructActive = false;
private:
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/) override
{
++builderExits;
}
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/) override
{
++blueprintExits;
}
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override
{
++deconstructChanges;
lastDeconstructActive = event->active;
}
};
static Blueprint makeBlueprint()
{
Blueprint blueprint;
BlueprintBuilding building;
building.type = BuildingType::Belt;
building.offset = QPoint(0, 0);
building.rotation = Rotation::East;
blueprint.buildings.push_back(building);
return blueprint;
}
// ---------------------------------------------------------------------------
// Exclusivity
// ---------------------------------------------------------------------------
TEST_CASE("Only one mode is active at a time", "[buildmode]")
{
BuildModeController controller;
REQUIRE(controller.getMode() == BuildMode::None);
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.isDeconstructMode());
controller.enterBlueprintMode(makeBlueprint());
REQUIRE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.isBuilderMode());
controller.toggleDeconstructMode();
REQUIRE(controller.isDeconstructMode());
REQUIRE_FALSE(controller.isBlueprintMode());
}
TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]")
{
// Regression: entering builder mode used to drop the blueprint silently, so the
// blueprint panel kept its button highlighted for a mode that was over.
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(spy.blueprintExits == 1);
}
TEST_CASE("Every mode announces its exit however it is left", "[buildmode]")
{
// The point of routing all transitions through one place: which mode the player
// switches to must not change what the mode they left announces.
SECTION("builder, left for a blueprint")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.enterBlueprintMode(makeBlueprint());
REQUIRE(spy.builderExits == 1);
}
SECTION("builder, left for deconstruct")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(spy.builderExits == 1);
}
SECTION("blueprint, left for deconstruct")
{
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(spy.blueprintExits == 1);
}
SECTION("deconstruct, left for builder")
{
BuildModeController controller;
controller.toggleDeconstructMode();
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(spy.deconstructChanges == 1);
REQUIRE_FALSE(spy.lastDeconstructActive);
}
}
TEST_CASE("Switching between builder types stays in builder mode", "[buildmode]")
{
// Picking a different building is not leaving the mode, so nothing is announced.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.enterBuilderMode(BuildingType::Splitter);
REQUIRE(controller.getBuilderType() == BuildingType::Splitter);
REQUIRE(spy.builderExits == 0);
}
TEST_CASE("Exiting a mode that is not active does nothing", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
ModeEventSpy spy;
controller.exitBlueprintMode();
REQUIRE(controller.isBuilderMode());
REQUIRE(spy.blueprintExits == 0);
REQUIRE(spy.builderExits == 0);
}
TEST_CASE("Deconstruct mode toggles off and announces both edges", "[buildmode]")
{
BuildModeController controller;
ModeEventSpy spy;
controller.toggleDeconstructMode();
REQUIRE(controller.isDeconstructMode());
REQUIRE(spy.lastDeconstructActive);
controller.toggleDeconstructMode();
REQUIRE(controller.getMode() == BuildMode::None);
REQUIRE_FALSE(spy.lastDeconstructActive);
REQUIRE(spy.deconstructChanges == 2);
}
TEST_CASE("Leaving the current mode works from any of them", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
controller.enterBlueprintMode(makeBlueprint());
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
controller.toggleDeconstructMode();
controller.exitCurrentMode();
REQUIRE(controller.getMode() == BuildMode::None);
}
// ---------------------------------------------------------------------------
// State cleared on transition
// ---------------------------------------------------------------------------
TEST_CASE("Leaving builder mode drops an in-progress belt drag", "[buildmode]")
{
// A drag surviving the mode change would place belts on the next release.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(3, 4));
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
REQUIRE(controller.isDraggingBelt());
controller.toggleDeconstructMode();
REQUIRE_FALSE(controller.isDraggingBelt());
REQUIRE(controller.getBeltDragPath().empty());
}
TEST_CASE("Leaving deconstruct mode drops the hovered building", "[buildmode]")
{
BuildModeController controller;
controller.toggleDeconstructMode();
controller.setDeconstructHoverBuildingId(BuildingId(4));
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
}
TEST_CASE("Entering builder mode resets the ghost", "[buildmode]")
{
// A fresh builder starts facing East and invalid until the first hover, rather
// than inheriting the previous building's facing (REQ-BLD-GHOST).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.rotateGhost(true);
controller.setGhostValidity(true);
controller.enterBuilderMode(BuildingType::Splitter);
REQUIRE(controller.getGhostRotation() == Rotation::East);
REQUIRE_FALSE(controller.isGhostValid());
}
TEST_CASE("Cancelling a belt drag stays in builder mode", "[buildmode]")
{
// Right-click during a drag abandons the path but keeps the belt selected
// (REQ-BLD-BELT-DRAG).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(1, 1));
ModeEventSpy spy;
controller.cancelBeltDrag();
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.isDraggingBelt());
REQUIRE(spy.builderExits == 0);
}
// ---------------------------------------------------------------------------
// Ghost and tunnel state
// ---------------------------------------------------------------------------
TEST_CASE("Rotating the ghost cycles through the four facings", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.rotateGhost(true);
REQUIRE(controller.getGhostRotation() == Rotation::South);
controller.rotateGhost(true);
REQUIRE(controller.getGhostRotation() == Rotation::West);
controller.rotateGhost(false);
REQUIRE(controller.getGhostRotation() == Rotation::South);
}
TEST_CASE("Tunnel mode is the tunnel entry builder type", "[buildmode]")
{
// REQ-BLD-TUNNEL-MODE: one builder type covers both tunnel ends.
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE_FALSE(controller.isTunnelMode());
controller.enterBuilderMode(BuildingType::TunnelEntry);
REQUIRE(controller.isTunnelMode());
}
TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelExit);
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
}
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
{
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
controller.enterBuilderMode(BuildingType::Belt);
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::Belt);
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
}

View File

@@ -12,6 +12,10 @@ add_files(
SurfaceMaskTest.cpp
BeltDragPathTest.cpp
TunnelCompletionTest.cpp
WorldCoordinatesTest.cpp
WorldCameraTest.cpp
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -0,0 +1,282 @@
#include "catch.hpp"
#include <memory>
#include <vector>
#include "DebrisSelectionChangedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "EventManager.h"
#include "SelectionChangedEvent.h"
#include "SelectionController.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Counts the change events the controller publishes, so the tests can assert not
// just the resulting selection but that the widgets were told about it — and, for
// the categories a change did not touch, that they were not told needlessly.
class SelectionEventSpy : public CombinedEventHandler<SelectionChangedEvent,
EntitySelectionChangedEvent,
DebrisSelectionChangedEvent>
{
public:
SelectionEventSpy() { registerForEvents(); }
~SelectionEventSpy() { unregisterForEvents(); }
int buildingEvents = 0;
int actorEvents = 0;
int debrisEvents = 0;
void reset() { buildingEvents = 0; actorEvents = 0; debrisEvents = 0; }
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> /*event*/) override
{
++buildingEvents;
}
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> /*event*/) override
{
++actorEvents;
}
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> /*event*/) override
{
++debrisEvents;
}
};
static entt::entity makeEntity(int index)
{
return static_cast<entt::entity>(index);
}
// ---------------------------------------------------------------------------
// Category precedence (REQ-UI-SELECTION-CATEGORIES)
// ---------------------------------------------------------------------------
TEST_CASE("Selecting a building clears actors and debris", "[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(controller.getSelectedBuildings() == std::vector<BuildingId>{BuildingId(7)});
REQUIRE(controller.getSelectedActors().empty());
REQUIRE(controller.getSelectedDebris().empty());
}
TEST_CASE("Selecting a field object clears buildings", "[selection]")
{
SelectionController controller;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace);
REQUIRE(controller.getSelectedBuildings().empty());
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
}
TEST_CASE("Actors and debris coexist", "[selection]")
{
// The one pair of categories that does not evict each other
// (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{makeEntity(2)});
}
TEST_CASE("A plain click on an actor drops any selected debris", "[selection]")
{
// A point click passes only the category it hit, so Replace with an empty
// debris list is what "this actor and nothing else" means
// (REQ-UI-ENTITY-CLICK-SELECT).
SelectionController controller;
controller.selectFieldObjects({}, {makeEntity(2)}, SelectionMode::Replace);
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris().empty());
}
// ---------------------------------------------------------------------------
// Replace / Toggle / Add
// ---------------------------------------------------------------------------
TEST_CASE("Replace makes the hit the whole selection", "[selection]")
{
SelectionController controller;
controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace);
controller.selectBuildings({BuildingId(3)}, SelectionMode::Replace);
REQUIRE(controller.getSelectedBuildings() == std::vector<BuildingId>{BuildingId(3)});
}
TEST_CASE("Toggle adds a building that was not selected", "[selection]")
{
SelectionController controller;
controller.selectBuildings({BuildingId(1)}, SelectionMode::Replace);
controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle);
REQUIRE(controller.getSelectedBuildings()
== std::vector<BuildingId>{BuildingId(1), BuildingId(2)});
}
TEST_CASE("Toggle removes a building that was already selected", "[selection]")
{
// Ctrl+clicking a selected building deselects it, leaving the rest alone.
SelectionController controller;
controller.selectBuildings({BuildingId(1), BuildingId(2), BuildingId(3)},
SelectionMode::Replace);
controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle);
REQUIRE(controller.getSelectedBuildings()
== std::vector<BuildingId>{BuildingId(1), BuildingId(3)});
}
TEST_CASE("Add never deselects", "[selection]")
{
// This is where a Ctrl box drag differs from a Ctrl click: dragging over
// already-selected buildings must not toggle them off (REQ-UI-MULTI-SELECT).
SelectionController controller;
controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace);
controller.selectBuildings({BuildingId(2), BuildingId(3)}, SelectionMode::Add);
REQUIRE(controller.getSelectedBuildings()
== std::vector<BuildingId>{BuildingId(1), BuildingId(2), BuildingId(3)});
}
TEST_CASE("An additive field selection leaves the untouched category alone",
"[selection]")
{
// Ctrl+clicking an actor passes an empty debris list, which must mean "do not
// touch debris" rather than "clear debris" (REQ-UI-DEBRIS-MULTI-SELECT).
SelectionController controller;
controller.selectFieldObjects({}, {makeEntity(5)}, SelectionMode::Replace);
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Toggle);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{makeEntity(5)});
}
// ---------------------------------------------------------------------------
// Membership queries used by the renderer
// ---------------------------------------------------------------------------
TEST_CASE("Membership queries answer per category", "[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
REQUIRE(controller.isActorSelected(makeEntity(1)));
REQUIRE_FALSE(controller.isActorSelected(makeEntity(2)));
REQUIRE(controller.isDebrisSelected(makeEntity(2)));
REQUIRE_FALSE(controller.isDebrisSelected(makeEntity(1)));
}
// ---------------------------------------------------------------------------
// Published events
// ---------------------------------------------------------------------------
TEST_CASE("Selecting a building announces the categories it cleared", "[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
SelectionEventSpy spy;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(spy.buildingEvents == 1);
REQUIRE(spy.actorEvents == 1);
REQUIRE(spy.debrisEvents == 1);
}
TEST_CASE("Clearing an already-empty category announces nothing", "[selection]")
{
// The panels re-read on every event, so a redundant one is only noise — but the
// point-click and box paths used to disagree about this, so it is pinned.
SelectionController controller;
SelectionEventSpy spy;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(spy.buildingEvents == 1);
REQUIRE(spy.actorEvents == 0);
REQUIRE(spy.debrisEvents == 0);
}
TEST_CASE("Re-selecting the same building still announces it", "[selection]")
{
// Clicking an already-selected building refreshes its panel, so the event
// fires even though the selection did not change.
SelectionController controller;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
SelectionEventSpy spy;
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
REQUIRE(spy.buildingEvents == 1);
}
TEST_CASE("Clearing an empty selection announces nothing at all", "[selection]")
{
SelectionController controller;
SelectionEventSpy spy;
controller.clearAll();
REQUIRE(spy.buildingEvents == 0);
REQUIRE(spy.actorEvents == 0);
REQUIRE(spy.debrisEvents == 0);
}
TEST_CASE("Clearing a populated selection announces every populated category",
"[selection]")
{
SelectionController controller;
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
SelectionMode::Replace);
SelectionEventSpy spy;
controller.clearAll();
REQUIRE(spy.buildingEvents == 0); // buildings were already empty
REQUIRE(spy.actorEvents == 1);
REQUIRE(spy.debrisEvents == 1);
REQUIRE(controller.getSelectedActors().empty());
REQUIRE(controller.getSelectedDebris().empty());
}
// ---------------------------------------------------------------------------
// Pruning despawned entities
// ---------------------------------------------------------------------------
TEST_CASE("Pruning announces only when something actually went", "[selection]")
{
// Runs every frame, so re-announcing an unchanged selection would spam the
// panels 60 times a second.
SelectionController controller;
controller.selectFieldObjects({makeEntity(1), makeEntity(2)}, {},
SelectionMode::Replace);
SelectionEventSpy spy;
controller.setSelectedActors({makeEntity(1), makeEntity(2)});
REQUIRE(spy.actorEvents == 0);
controller.setSelectedActors({makeEntity(1)});
REQUIRE(spy.actorEvents == 1);
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
}

View File

@@ -0,0 +1,243 @@
#include "catch.hpp"
#include "WorldCamera.h"
#include "WorldConfig.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Slow 10 tiles/s, fast 50 tiles/s, and a 4-tile ramp band — so each ramp spans
// 2 tiles either side of a contest-zone boundary and the arithmetic stays exact.
static WorldScroll makeScroll(int rampBandWidth_tiles = 4)
{
WorldScroll scroll;
scroll.panSpeedSlow_tps = 10.0;
scroll.panSpeedFast_tps = 50.0;
scroll.panRampBandWidth_tiles = rampBandWidth_tiles;
return scroll;
}
// The player buffer's right edge — the contest zone's left boundary — sits at 20.
static WorldRegions makeRegions()
{
WorldRegions regions;
regions.asteroidWidth_tiles = 10;
regions.playerBufferWidth_tiles = 20;
regions.contestZoneWidth_tiles = 60;
regions.enemyBufferWidth_tiles = 10;
return regions;
}
static ScrollBounds makeBounds(float leftTiles = -100.0f, float rightTiles = 100.0f)
{
return ScrollBounds{leftTiles, rightTiles};
}
// ---------------------------------------------------------------------------
// Pan speed ramp (REQ-UI-SCROLL-SPEED)
// ---------------------------------------------------------------------------
TEST_CASE("Pan speed is slow over the asteroid and player buffer", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Left of the rising ramp band (which starts at 20 - 2 = 18).
REQUIRE(camera.getPanSpeedTilesPerSecondAt(-50.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(18.0f, 80.0f) == Approx(10.0f));
}
TEST_CASE("Pan speed is fast across the middle of the contest zone", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Between the two ramp bands: past 20 + 2 and before 80 - 2.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(22.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(78.0f, 80.0f) == Approx(50.0f));
}
TEST_CASE("Pan speed ramps linearly across each contest-zone boundary", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Rising band spans [18, 22]; the boundary itself is the midpoint.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 80.0f) == Approx(30.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.0f, 80.0f) == Approx(20.0f));
// Falling band spans [78, 82], mirrored.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.0f, 80.0f) == Approx(30.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(81.0f, 80.0f) == Approx(20.0f));
}
TEST_CASE("Pan speed returns to slow past the enemy stations", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(82.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(200.0f, 80.0f) == Approx(10.0f));
}
TEST_CASE("The ramp follows the front line as it is pushed", "[camera]")
{
// The right boundary is passed in per call, so a push that moves the enemy
// stations moves the falling ramp with it (REQ-GW-PUSH-EXPAND).
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// X = 90 is past the old boundary (slow) but well inside the pushed-back one.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 140.0f) == Approx(50.0f));
}
TEST_CASE("Overlapping ramp bands peak below the fast speed", "[camera]")
{
// A contest zone narrower than the ramp band never reaches full speed: the two
// ramps cross before either tops out, leaving a single peak where they meet.
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 40);
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
// Bands are [0, 40] rising and [10, 50] falling; they cross at x = 25.
const float peak = camera.getPanSpeedTilesPerSecondAt(25.0f, 30.0f);
REQUIRE(peak > 10.0f);
REQUIRE(peak < 50.0f);
// And it really is the maximum — the neighbours on both sides are lower.
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 30.0f) < peak);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(30.0f, 30.0f) < peak);
}
TEST_CASE("A zero-width ramp band steps straight from slow to fast", "[camera]")
{
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 0);
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.99f, 80.0f) == Approx(10.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.01f, 80.0f) == Approx(10.0f));
}
// ---------------------------------------------------------------------------
// Panning
// ---------------------------------------------------------------------------
TEST_CASE("The camera starts centered on the world origin", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("Panning moves the view center at the local pan speed", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
// Starting at 0, the local speed is the slow one: 10 tiles/s for 500 ms.
REQUIRE(camera.advance(PanDirection::Right, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(5.0f));
REQUIRE(camera.advance(PanDirection::Left, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("Not panning leaves the view center alone", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 500, makeBounds());
const float before = camera.getViewCenterXTiles();
REQUIRE_FALSE(camera.advance(PanDirection::None, 500, makeBounds()));
REQUIRE(camera.getViewCenterXTiles() == Approx(before));
}
// ---------------------------------------------------------------------------
// Clamping (REQ-GW-SCROLL-LIMIT)
// ---------------------------------------------------------------------------
TEST_CASE("Panning stops at the scroll bounds", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
// Far more time than it takes to cross the bound.
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(12.0f));
camera.advance(PanDirection::Left, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(-5.0f));
}
TEST_CASE("Shrinking bounds pull the view in even without panning", "[camera]")
{
// Bounds move as the game progresses, so clamping cannot wait for the player
// to press a key — a view left outside them would show unreachable world.
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 100000, makeBounds(-50.0f, 40.0f));
REQUIRE(camera.getViewCenterXTiles() == Approx(40.0f));
// The right bound comes in; the camera must follow it despite no pan input,
// and must report that it moved.
REQUIRE(camera.advance(PanDirection::None, 16, makeBounds(-50.0f, 25.0f)));
REQUIRE(camera.getViewCenterXTiles() == Approx(25.0f));
}
TEST_CASE("Panning into a bound the view already sits on reports no movement",
"[camera]")
{
// The caller refreshes the box-select rectangle on a true return, so a camera
// pinned against its limit must not keep claiming to have moved.
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
REQUIRE_FALSE(camera.advance(PanDirection::Right, 16, makeBounds(-5.0f, 12.0f)));
}
TEST_CASE("Reset returns the view to the origin", "[camera]")
{
const WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
WorldCamera camera(scroll, regions);
camera.advance(PanDirection::Right, 2000, makeBounds());
REQUIRE(camera.getViewCenterXTiles() != Approx(0.0f));
camera.reset();
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
}
TEST_CASE("The camera tracks config edited after construction", "[camera]")
{
// The camera references the config rather than copying it, so a restart that
// reloads world.toml in place (REQ-CFG-RELOAD) changes the pan speed without
// the camera being rebuilt.
WorldScroll scroll = makeScroll();
const WorldRegions regions = makeRegions();
const WorldCamera camera(scroll, regions);
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
scroll.panSpeedSlow_tps = 3.0;
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(3.0f));
}

View File

@@ -0,0 +1,203 @@
#include "catch.hpp"
#include <QPoint>
#include <QSize>
#include <QVector2D>
#include "WorldCoordinates.h"
// A 800x400 viewport over a 20-tile-high world gives exactly 20 px per tile and a
// 40-tile-wide view, so every expectation below is a whole number.
static WorldCoordinates makeCoordinates(float viewCenterX_tiles)
{
return WorldCoordinates::scrolling(QSize(800, 400), 20, viewCenterX_tiles);
}
// ---------------------------------------------------------------------------
// Tile size and viewport extent
// ---------------------------------------------------------------------------
TEST_CASE("Tile size makes the world height fill the viewport height", "[coords]")
{
// REQ-GW-TILE-SIZE: tiles are square and sized so the world height exactly
// fills the view's height.
REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f));
REQUIRE(WorldCoordinates::scrolling(QSize(800, 600), 20, 0.0f).getTilePx()
== Approx(30.0f));
}
TEST_CASE("A degenerate world height falls back to a unit tile", "[coords]")
{
// Guards the division in every conversion; a zero or negative height would
// otherwise produce infinities.
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), 0, 0.0f).getTilePx()
== Approx(1.0f));
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), -5, 0.0f).getTilePx()
== Approx(1.0f));
}
TEST_CASE("A zero-size viewport falls back to a unit tile", "[coords]")
{
// A widget that has not been shown yet still has to answer conversions —
// the arena hit-tests through the same transform.
REQUIRE(WorldCoordinates::scrolling(QSize(0, 0), 20, 0.0f).getTilePx()
== Approx(1.0f));
REQUIRE(WorldCoordinates::fitToWorld(QSize(0, 0), 40, 20).getTilePx()
== Approx(1.0f));
}
TEST_CASE("Viewport width in tiles follows the widget width", "[coords]")
{
REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f));
REQUIRE(WorldCoordinates::scrolling(QSize(400, 400), 20, 0.0f).getViewportWidthTiles()
== Approx(20.0f));
}
TEST_CASE("The view left edge is half a viewport left of the center", "[coords]")
{
REQUIRE(makeCoordinates(0.0f).getViewLeftTiles() == Approx(-20.0f));
REQUIRE(makeCoordinates(100.0f).getViewLeftTiles() == Approx(80.0f));
}
// ---------------------------------------------------------------------------
// World <-> widget conversion
// ---------------------------------------------------------------------------
TEST_CASE("World positions map to widget pixels relative to the view left edge",
"[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
REQUIRE(coordinates.worldToWidget(QVector2D(-20.0f, 0.0f)).x() == Approx(0.0));
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 0.0f)).x() == Approx(400.0));
// Y is not scrolled: world Y maps straight through the tile size.
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 3.5f)).y() == Approx(70.0));
}
TEST_CASE("Scrolling right shifts the world left on screen", "[coords]")
{
const QVector2D worldPos(10.0f, 5.0f);
const qreal atOrigin = makeCoordinates(0.0f).worldToWidget(worldPos).x();
const qreal scrolled = makeCoordinates(4.0f).worldToWidget(worldPos).x();
// Panning the view 4 tiles right moves the same world point 4 tiles (80 px) left.
REQUIRE(scrolled == Approx(atOrigin - 80.0));
}
TEST_CASE("A tile's widget position is its top-left corner", "[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(0.0f);
REQUIRE(coordinates.tileToWidget(QPoint(-20, 0)) == QPointF(0.0, 0.0));
REQUIRE(coordinates.tileToWidget(QPoint(0, 2)) == QPointF(400.0, 40.0));
}
TEST_CASE("A tile rect covers exactly one tile", "[coords]")
{
const QRectF rect = makeCoordinates(0.0f).tileRect(QPoint(-19, 1));
REQUIRE(rect.left() == Approx(20.0));
REQUIRE(rect.top() == Approx(20.0));
REQUIRE(rect.width() == Approx(20.0));
REQUIRE(rect.height() == Approx(20.0));
}
// ---------------------------------------------------------------------------
// Widget -> world conversion
// ---------------------------------------------------------------------------
TEST_CASE("Widget points map back to the world position they came from", "[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(7.0f);
const QVector2D world = coordinates.widgetToWorld(QPoint(250, 130));
const QPointF back = coordinates.worldToWidget(world);
REQUIRE(back.x() == Approx(250.0));
REQUIRE(back.y() == Approx(130.0));
}
TEST_CASE("Widget points resolve to the tile that contains them", "[coords]")
{
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
// Anywhere inside a tile's 20px cell resolves to that tile.
REQUIRE(coordinates.widgetToTile(QPoint(0, 0)) == QPoint(-20, 0));
REQUIRE(coordinates.widgetToTile(QPoint(19, 19)) == QPoint(-20, 0));
REQUIRE(coordinates.widgetToTile(QPoint(20, 20)) == QPoint(-19, 1));
REQUIRE(coordinates.widgetToTile(QPoint(405, 45)) == QPoint(0, 2));
}
TEST_CASE("Tile resolution floors, so negative world positions round down", "[coords]")
{
// Truncation toward zero would map the whole strip from -1 to 1 onto tile 0,
// making the tile under the cursor wrong on the asteroid side (REQ-GW-COORDS:
// all asteroid tiles have x < 0).
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
REQUIRE(coordinates.widgetToTile(QPoint(399, 0)) == QPoint(-1, 0));
REQUIRE(coordinates.widgetToTile(QPoint(400, 0)) == QPoint(0, 0));
}
// ---------------------------------------------------------------------------
// Viewport rect
// ---------------------------------------------------------------------------
TEST_CASE("The viewport rect spans the visible tiles with a one-column margin",
"[coords]")
{
// The margin keeps items that straddle an edge from popping in and out.
const QRect rect = makeCoordinates(0.0f).getViewportRect(); // left edge at -20
REQUIRE(rect.left() == -21);
REQUIRE(rect.right() == 20);
REQUIRE(rect.top() == 0);
REQUIRE(rect.height() == 20); // the full world height
}
TEST_CASE("A fractional scroll position widens the viewport rect outward", "[coords]")
{
// Left edge at -19.5: the rect must still cover the partially visible columns
// on both sides, so it floors on the left and ceils on the right.
const QRect rect = makeCoordinates(0.5f).getViewportRect();
REQUIRE(rect.left() == -21);
REQUIRE(rect.right() == 21);
}
// ---------------------------------------------------------------------------
// Fitted (non-scrolling) worlds
// ---------------------------------------------------------------------------
TEST_CASE("A fitted world takes the tighter of the two axis fits", "[coords]")
{
// Height-limited: 400/20 = 20 px per tile beats 800/30 = 26.67.
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 30, 20).getTilePx()
== Approx(20.0f));
// Width-limited: 800/80 = 10 px per tile beats 400/20 = 20.
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20).getTilePx()
== Approx(10.0f));
}
TEST_CASE("A fitted world keeps its whole width on screen", "[coords]")
{
// The point of taking the tighter fit: the far edge must land inside the
// viewport, never past it.
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
REQUIRE(coordinates.worldToWidget(QVector2D(80.0f, 0.0f)).x() <= 800.0);
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 20.0f)).y() <= 400.0);
}
TEST_CASE("A fitted world puts the origin at the widget's top-left", "[coords]")
{
// No scrolling, so there is no view center to subtract.
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 40, 20);
REQUIRE(coordinates.getViewLeftTiles() == Approx(0.0f));
REQUIRE(coordinates.tileToWidget(QPoint(0, 0)) == QPointF(0.0, 0.0));
REQUIRE(coordinates.tileToWidget(QPoint(3, 2)) == QPointF(60.0, 40.0));
}
TEST_CASE("A fitted world round-trips widget points back to world positions",
"[coords]")
{
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
const QVector2D world = coordinates.widgetToWorld(QPoint(120, 55));
const QPointF back = coordinates.worldToWidget(world);
REQUIRE(back.x() == Approx(120.0));
REQUIRE(back.y() == Approx(55.0));
}

View File

@@ -6,6 +6,9 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
@@ -28,6 +31,9 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
#include <QVector2D>
#include "Blueprint.h"
#include "BuildModeController.h"
#include "BuildingConfig.h"
#include "BlueprintModeExitedEvent.h"
#include "BlueprintPlacementRequestedEvent.h"
@@ -31,6 +32,13 @@
#include "EventHandler.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggleRequestedEvent.h"
#include "GhostRotationRequestedEvent.h"
#include "InputMapper.h"
#include "ModeCancelRequestedEvent.h"
#include "PanDirectionChangedEvent.h"
#include "PauseToggleRequestedEvent.h"
#include "SpeedStepRequestedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "ArtifactCountChangedEvent.h"
#include "BeamFiredEvent.h"
@@ -44,11 +52,16 @@
#include "CommandManager.h"
#include "EntitySelectionChangedEvent.h"
#include "GameConfig.h"
#include "PlacementRules.h"
#include "Rotation.h"
#include "SelectionController.h"
#include "Tick.h"
#include "TickDriver.h"
#include "TunnelCompletion.h"
#include "VisualsConfig.h"
#include "WorldCamera.h"
#include "WorldCoordinates.h"
#include "WorldRenderer.h"
struct Command;
struct ParsedReplay;
@@ -56,19 +69,6 @@ class ItemIconCache;
class ReplayPlayer;
class Simulation;
class QPainter;
class QSvgRenderer;
struct QPointCompare
{
bool operator()(const QPoint& a, const QPoint& b) const
{
if (a.x() != b.x()) { return a.x() < b.x(); }
return a.y() < b.y();
}
};
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
class GameWorldView : public QOpenGLWidget,
public CombinedEventHandler<BeamFiredEvent,
@@ -78,6 +78,12 @@ class GameWorldView : public QOpenGLWidget,
BlueprintPlacementRequestedEvent,
ExitBlueprintModeRequestedEvent,
SpeedChangeRequestedEvent,
PanDirectionChangedEvent,
PauseToggleRequestedEvent,
SpeedStepRequestedEvent,
GhostRotationRequestedEvent,
ModeCancelRequestedEvent,
DebugDrawToggleRequestedEvent,
CommandRequestedEvent>
{
Q_OBJECT
@@ -100,8 +106,14 @@ public:
protected:
void initializeGL() override;
void paintGL() override;
// Only forwards to the input mapper; every key this widget acts on reaches it
// as a published action instead (REQ-UI-HOTKEYS).
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
// Key-up never arrives for a key that was still held when focus moved away —
// to a modal dialog, another widget, or another window. Any held action would
// otherwise stay held forever, so focus loss drops all of them.
void focusOutEvent(QFocusEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
@@ -117,6 +129,12 @@ private:
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const PauseToggleRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
// Enqueue a sim command onto the CommandManager (the single mutation path).
@@ -129,29 +147,13 @@ private:
// Used to pre-validate placements whose UI follow-up depends on success.
bool canAfford(BuildingType type) const;
void drawTiles(QPainter& painter);
void drawPortItems(QPainter& painter);
void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter);
void drawStations(QPainter& painter);
void drawBeltItems(QPainter& painter);
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
// each direction (a half-tile). Uses the item's icon when one exists
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
// visuals.toml. Shared by drawBeltItems and drawPortItems.
void drawWorldItem(QPainter& painter, const std::string& itemId,
QPointF center, float halfPx);
void drawDebris(QPainter& painter);
void drawShips(QPainter& painter);
void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
float fraction, bool isEnemy);
void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter);
void drawDebugOverlay(QPainter& painter);
void drawBeams(QPainter& painter);
void drawOverlays(QPainter& painter);
// Screen-anchored chrome, drawn after the world (see WorldRenderer): these
// need no world transform, which is exactly why they stayed here.
void drawScreenSpace(QPainter& painter);
// Threat and production readout shown while debug draw is on (F3). Positioned
// in pixels at the top-left corner, so it belongs with the chrome rather than
// with the world.
void drawDebugOverlay(QPainter& painter);
// Vignette-style border shown while the game is paused (speed 0x, REQ-UI-PAUSE-BORDER)
// to make the paused state hard to miss: a black frame whose alpha fades from 50% at
// the viewport edges to 0% toward the center.
@@ -166,110 +168,51 @@ private:
void drawVignetteBorder(QPainter& painter, const QColor& edgeColor);
void drawReplayOverlay(QPainter& painter);
float getTilePx() const;
float getViewportWidthTiles() const;
// World-X (tiles) at the left edge of the viewport. m_scrollXTiles stores the
// view center; this derives the left edge the world<->widget conversions need.
float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) const;
QRect getViewportRect() const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(BuildingId id) const;
// Gathers the interaction state the renderer needs for this frame.
WorldRenderFrame makeRenderFrame() const;
// The world <-> widget transform for the current viewport size and scroll
// position. Cheap to build and deliberately not cached: it is a snapshot that
// a resize or a scroll invalidates, so every user takes a fresh one.
WorldCoordinates getCoordinates() const;
float getAsteroidLeftEdge() const;
float getEnemyStationRightEdge() const;
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
void clampScroll();
// The camera's current pan limits, read fresh from the simulation each frame:
// both edges move with asteroid expansion and with pushes (REQ-GW-SCROLL-LIMIT).
ScrollBounds getScrollBounds() const;
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
// canPlaceBuilding (PlacementRules) against this view's simulation.
bool canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const;
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
std::optional<BuildingId> siteAtTile(QPoint tile) const;
// Ids of all buildings and construction sites whose footprint intersects
// the tile box spanned by the two (unordered) corner tiles.
std::vector<BuildingId> buildingsInBox(QPoint cornerA, QPoint cornerB) const;
QVector2D widgetToWorld(QPoint widgetPt) const;
void drawPortGlyph(QPainter& painter, QPoint tile,
Rotation direction, const QColor& color,
bool centered);
void drawBuildingGhost(QPainter& painter, BuildingType type,
QPoint anchorTile, Rotation rotation, bool valid,
bool showPortTargetGlyphs);
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
// <configDir>/../icons/buildings once at construction. Only the building
// types with a world icon are loaded (production buildings, HQ, stations);
// belts, splitters, and tunnels are deliberately excluded so their
// orientation stays readable. The SVG's chip background is stripped; the
// glyph is pre-rendered in both white and dark ink for auto-contrast.
void loadBuildingIcons(const std::string& configDir);
// Draws a building's world icon glyph centered in box, choosing the white or
// dark pre-rendered variant by fill luminance so it stays legible. Returns
// false if the type has no world icon (caller falls back to the text glyph).
bool drawBuildingIcon(QPainter& painter, BuildingType type,
const QRectF& box, const QColor& fill) const;
void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> entityPosition(entt::entity entity) const;
// Clears the debris selection, emitting an empty DebrisSelectionChangedEvent when
// it was non-empty (REQ-UI-DEBRIS-CLICK-SELECT). Used when another selection
// category takes over.
void clearDebrisSelection();
// Drops despawned or fully-collected debris from the selection and re-emits
// when it changed (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
// Drops despawned or fully-collected debris from the selection
// (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedDebris();
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
void clearEntitySelection();
// Drops despawned or dead actors from the selection and re-emits when it changed
// (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame().
// Drops despawned or dead actors from the selection (REQ-UI-ENTITY-CLICK-SELECT).
// Called each frame from onFrame().
void pruneDespawnedActors();
// True if the given actor is part of the current actor selection.
bool isEntitySelected(entt::entity entity) const;
// Resolves a click into the selection it should produce, applying the category
// precedence of REQ-UI-SELECTION-CATEGORIES; the controller owns what that then
// does to the existing selection. Returns false when the click hit nothing,
// which is the only case that goes on to start a box drag.
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive);
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type
// is TunnelEntry; the ghost then resolves to an entry or exit by hovered position.
bool inTunnelMode() const;
// The building type the ghost currently represents: the position-resolved tunnel
// type in tunnel mode, otherwise the plain builder type.
BuildingType effectiveBuilderType() const;
// Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost
// tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode.
// Re-resolves the tunnel ghost type and completion partner from the current
// ghost tile, rotation, and sub-tile cursor position, storing both on the build
// mode controller. Only meaningful in tunnel mode (REQ-BLD-TUNNEL-MODE).
void updateTunnelGhost();
// Indexes every tunnel entry/exit — built or still a construction site — by its
// single-cell tile. Shared by the placement preview and the selection highlight.
TunnelTileMap collectTunnelTiles() const;
// Wraps a tunnel tile index in the lookup functor the TunnelCompletion helpers
// take. The returned functor references `tunnels`, which must outlive it.
static TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
// Draws the green connection highlight for every selected tunnel end that has a
// matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
void drawSelectedTunnelConnections(QPainter& painter);
// Belt drag placement (REQ-BLD-BELT-DRAG).
// Per-path-tile decision, shared by ghost drawing and release-time placement.
enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid };
struct BeltDragResolved
{
BeltTileAction action;
bool affordable; // meaningful only for PlaceNew
std::optional<BuildingId> rotateId; // set only for RotateInPlace
};
// Recomputes m_beltDragPath from m_beltDragAnchor to cursorTile using the
// current ghost orientation.
// Recomputes the drag path from its anchor to cursorTile using the current ghost
// orientation, and stores it on the build mode controller.
void recomputeBeltDragPath(QPoint cursorTile);
// Classifies each path tile against the current sim state, applying cumulative
// affordability to the PlaceNew tiles.
// resolveBeltDragPath (PlacementRules) against this view's simulation.
std::vector<BeltDragResolved> resolveBeltDragPath() const;
// Enqueues placements and rotate-in-place commands for the resolved path.
void applyBeltDragPath();
@@ -280,19 +223,11 @@ private:
void copyConfigFrom(BuildingId id);
void pasteConfigTo(BuildingId id);
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint);
void exitBlueprintMode();
void toggleDeconstructMode();
// Turns the ghost and refreshes everything that depends on its facing: placement
// validity, the tunnel completion match, and an in-progress belt drag's path.
// The mode transitions themselves live on m_buildMode.
void rotateGhost(bool clockwise);
struct ActiveBeam
{
BeamFiredEvent event;
QVector2D targetOffset;
};
// Beam lifetime in game ticks so beams freeze with the simulation when
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
@@ -301,22 +236,6 @@ private:
const GameConfig* m_config;
const VisualsConfig* m_visuals;
// World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a
// white and a dark variant so drawBuildingIcon can auto-contrast against the
// building's fill. Rendered as vector at the view scale each draw so they
// stay crisp. Populated once by loadBuildingIcons().
struct BuildingIconRenderers
{
std::unique_ptr<QSvgRenderer> white;
std::unique_ptr<QSvgRenderer> dark;
};
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
// per target size.
ItemIconCache* m_itemIcons;
// Funnels all player input into the single Simulation::apply chokepoint.
CommandManager m_commandManager;
// A Reset command was enqueued; reset the view after the next drain applies it.
@@ -325,40 +244,32 @@ private:
// live input is ignored (the CommandManager is in replay mode).
std::unique_ptr<ReplayPlayer> m_replayPlayer;
// Draws the world; this widget supplies the interaction state each frame and
// keeps only the screen-anchored chrome for itself.
std::unique_ptr<WorldRenderer> m_renderer;
TickDriver m_tickDriver;
QElapsedTimer m_frameTimer;
std::mt19937 m_rng;
double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed;
// World-X (tiles) at the center of the viewport (see getViewLeftTiles()).
float m_scrollXTiles;
// Horizontal view position (REQ-UI-SCROLL). Owns the scroll position itself;
// this widget only supplies the pan intent and the simulation-derived bounds.
WorldCamera m_camera;
QTimer* m_renderTimer;
std::vector<ActiveBeam> m_activeBeams;
std::optional<BuildingType> m_builderType;
Rotation m_ghostRotation;
QPoint m_ghostTile;
bool m_ghostValid;
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE): while m_builderType is
// TunnelEntry the ghost resolves to an entry or an exit based on the hovered
// position; m_tunnelPartnerTile is the existing tunnel it would complete (drawn
// as the green connection preview), or unset when there is no completion match.
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
std::optional<QPoint> m_tunnelPartnerTile;
// Deferred belt drag placement (REQ-BLD-BELT-DRAG): while dragging, the
// rectilinear anchor->cursor path is recomputed on each move and only applied
// on release. Empty unless a belt drag is in progress.
std::vector<BeltPathTile> m_beltDragPath;
QPoint m_beltDragAnchor;
// Last known cursor position in world (tile) units; used to pick the belt-drag
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG).
QVector2D m_cursorWorldPos;
bool m_dragging;
// The active build mode (builder / blueprint / deconstruct, or none) and the
// ghost, tunnel and belt-drag state belonging to it. Owns the transitions
// between them; this widget supplies the parts that need the simulation.
BuildModeController m_buildMode;
std::optional<Blueprint> m_blueprintMode;
QPoint m_blueprintGhostTile;
// Last known cursor position in world (tile) units; used to pick the belt-drag
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG)
// and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
QVector2D m_cursorWorldPos;
// Temporary cache for the copy-settings gesture (REQ-BLD-COPY-CONFIG); held
// only while Shift is down and cleared on Shift release.
@@ -368,27 +279,29 @@ private:
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
// wall-clock time so the flash plays at a fixed length regardless of game speed
// (and while paused).
struct CopyConfigFlash
{
BuildingId id;
qint64 remainingMs;
};
std::vector<CopyConfigFlash> m_copyConfigFlashes;
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_deconstructMode;
std::optional<BuildingId> m_deconstructHoverBuildingId;
bool m_debugDraw;
std::vector<BuildingId> m_selectedBuildingIds;
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedDebris;
// Owns the selection across all three categories and the rules for moving
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
// events. This widget only resolves what was hit.
SelectionController m_selection;
bool m_boxSelecting;
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;
bool m_scrollLeft;
bool m_scrollRight;
// Interprets this widget's key events into semantic actions and publishes them
// (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds
// focus; everything it produces travels by event, so it can move to a
// window-wide owner later without touching its consumers.
InputMapper m_inputMapper;
// Latest pan direction published by the input mapper (REQ-UI-SCROLL), fed to
// the camera each frame. Cached from the event payload rather than re-read:
// input has no other source of truth (see PanDirectionChangedEvent).
PanDirection m_panDirection = PanDirection::None;
bool m_gameOverShown;
bool m_winShown;
bool m_schematicChoiceShown;

172
src/ui/InputMapper.cpp Normal file
View File

@@ -0,0 +1,172 @@
#include "InputMapper.h"
#include <memory>
#include <optional>
#include <QKeyEvent>
#include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h"
#include "DebugDrawToggleRequestedEvent.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventManager.h"
#include "GhostRotationRequestedEvent.h"
#include "ModeCancelRequestedEvent.h"
#include "PanDirectionChangedEvent.h"
#include "PauseToggleRequestedEvent.h"
#include "SpeedStepRequestedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "TracePrintRequestedEvent.h"
namespace
{
// The building type a number-key build hotkey selects, or nullopt for the digits
// that are unbound (REQ-UI-HOTKEYS). Plain digits pick the transport buildings,
// Shift+digit the production ones.
std::optional<BuildingType> buildHotkeyType(int digit, bool shiftHeld)
{
if (!shiftHeld)
{
switch (digit)
{
case 1: return BuildingType::Belt;
case 2: return BuildingType::Splitter;
case 3: return BuildingType::TunnelEntry; // unified tunnel mode (REQ-BLD-TUNNEL-MODE); 4 unused
}
return std::nullopt;
}
switch (digit)
{
case 1: return BuildingType::Miner;
case 2: return BuildingType::Smelter;
case 3: return BuildingType::Assembler;
case 4: return BuildingType::Shipyard;
case 5: return BuildingType::SalvageBay;
case 6: return BuildingType::ReprocessingPlant;
}
return std::nullopt;
}
} // namespace
bool InputMapper::handleKeyPress(QKeyEvent* event)
{
// Auto-repeat says nothing new about which keys are down, and a held action is
// already held.
if (event->isAutoRepeat()) { return false; }
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
// physical digit independent of keyboard layout and Shift (with Shift held, key()
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
const quint32 virtualKey = event->nativeVirtualKey();
if (virtualKey >= 0x31 && virtualKey <= 0x39)
{
const std::optional<BuildingType> type = buildHotkeyType(
static_cast<int>(virtualKey - 0x30),
(event->modifiers() & Qt::ShiftModifier) != 0);
if (type.has_value())
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildHotkeyPressedEvent>(*type));
return true;
}
}
switch (event->key())
{
case Qt::Key_A:
m_panLeftHeld = true;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = true;
updatePanDirection();
return true;
case Qt::Key_Space:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case Qt::Key_W:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(+1));
return true;
case Qt::Key_S:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(-1));
return true;
case Qt::Key_R:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case Qt::Key_Q:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case Qt::Key_T:
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintPanel owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintRequestedEvent>());
return true;
case Qt::Key_F3:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggleRequestedEvent>());
return true;
case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
case Qt::Key_F4:
EventManager::getInstance()->addEvent(
std::make_shared<TracePrintRequestedEvent>());
return true;
default:
return false;
}
}
bool InputMapper::handleKeyRelease(QKeyEvent* event)
{
if (event->isAutoRepeat()) { return false; }
switch (event->key())
{
case Qt::Key_A:
m_panLeftHeld = false;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = false;
updatePanDirection();
return true;
default:
return false;
}
}
void InputMapper::releaseAll()
{
m_panLeftHeld = false;
m_panRightHeld = false;
updatePanDirection();
}
void InputMapper::updatePanDirection()
{
// Holding both keys cancels out rather than favouring one.
PanDirection direction = PanDirection::None;
if (m_panLeftHeld != m_panRightHeld)
{
direction = m_panLeftHeld ? PanDirection::Left : PanDirection::Right;
}
if (direction == m_panDirection) { return; }
m_panDirection = direction;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PanDirectionChangedEvent>(direction));
}

44
src/ui/InputMapper.h Normal file
View File

@@ -0,0 +1,44 @@
#pragma once
#include "WorldCamera.h"
class QKeyEvent;
// Turns raw key events into the game's semantic actions and publishes them
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
// be rebound independently later; the bindings themselves are still hard-coded
// here for now.
//
// Two output shapes, chosen by the nature of the action rather than by taste:
//
// * Discrete actions — one press, one thing happens — fire an event as they
// always have.
// * Continuous actions, currently just panning, are held state. They also travel
// as events, but level-triggered ones carrying the complete current value (see
// PanDirectionChangedEvent), so a receiver never has to reconstruct state from
// edges.
//
// Holding the state here rather than in the widgets is what makes releaseAll()
// possible: one call clears every held action at once, which is how a lost key-up
// (focus moving to a modal mid-pan) stops being a stuck input.
class InputMapper
{
public:
// Both return true when the key was consumed; the caller passes anything else
// on to its base class so unrelated shortcuts keep working.
bool handleKeyPress(QKeyEvent* event);
bool handleKeyRelease(QKeyEvent* event);
// Drops all held-key state, publishing the resulting change. Call when the
// receiving widget can no longer expect key-up events.
void releaseAll();
private:
// Recomputes the pan direction from the held keys and publishes it if it
// changed. Holding both keys cancels out.
void updatePanDirection();
bool m_panLeftHeld = false;
bool m_panRightHeld = false;
PanDirection m_panDirection = PanDirection::None;
};

101
src/ui/WorldPrimitives.cpp Normal file
View File

@@ -0,0 +1,101 @@
#include "WorldPrimitives.h"
#include <algorithm>
#include <cmath>
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#include <QRectF>
#include <QVector2D>
namespace
{
// Ship triangle proportions, as fractions of a tile: the nose reaches further
// than the tail corners spread, so the facing direction reads at a glance.
const float kShipForwardTileFactor = 0.45f;
const float kShipSideTileFactor = 0.25f;
const float kDebrisRadiusTileFactor = 0.2f;
// Health bar height as a fraction of a tile.
const qreal kHealthBarHeightTileFactor = 0.12;
const QColor kHealthBarTrack (60, 60, 60);
const QColor kHealthBarEnemy (200, 60, 60);
const QColor kHealthBarPlayer (60, 200, 60);
const QColor kDebrisFill (128, 110, 90);
const QColor kDebrisOutline(50, 40, 30);
// Sensor circles are drawn faint so a crowded field stays readable.
const int kSensorRangeAlpha = 77;
}
float getShipForwardExtentPx(const WorldCoordinates& coordinates)
{
return coordinates.getTilePx() * kShipForwardTileFactor;
}
float getDebrisRadiusPx(const WorldCoordinates& coordinates)
{
return coordinates.getTilePx() * kDebrisRadiusTileFactor;
}
void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float facing_radians,
const QColor& fill, const QColor& outline)
{
const QVector2D direction(std::cos(facing_radians), std::sin(facing_radians));
const QVector2D perpendicular(-direction.y(), direction.x());
const float forward = getShipForwardExtentPx(coordinates);
const float side = coordinates.getTilePx() * kShipSideTileFactor;
QPolygonF triangle;
triangle
<< QPointF(center.x() + static_cast<qreal>(direction.x() * forward),
center.y() + static_cast<qreal>(direction.y() * forward))
<< QPointF(center.x() + static_cast<qreal>(perpendicular.x() * side - direction.x() * side),
center.y() + static_cast<qreal>(perpendicular.y() * side - direction.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perpendicular.x() * side - direction.x() * side),
center.y() + static_cast<qreal>(-perpendicular.y() * side - direction.y() * side));
painter.setPen(QPen(outline, 1));
painter.setBrush(fill);
painter.drawPolygon(triangle);
}
void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates,
qreal left, qreal top, qreal width, float fraction, bool isEnemy)
{
const qreal height = static_cast<qreal>(coordinates.getTilePx())
* kHealthBarHeightTileFactor;
const float clamped = std::max(0.0f, fraction);
painter.fillRect(QRectF(left, top, width, height), kHealthBarTrack);
painter.fillRect(QRectF(left, top, width * static_cast<qreal>(clamped), height),
isEnemy ? kHealthBarEnemy : kHealthBarPlayer);
}
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center)
{
const qreal radius = static_cast<qreal>(getDebrisRadiusPx(coordinates));
painter.setBrush(kDebrisFill);
painter.setPen(QPen(kDebrisOutline, 1));
painter.drawEllipse(center, radius, radius);
}
void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float range_tiles, const QColor& shipOutline)
{
const qreal radius = static_cast<qreal>(range_tiles)
* static_cast<qreal>(coordinates.getTilePx());
QColor circleColor = shipOutline;
circleColor.setAlpha(kSensorRangeAlpha);
painter.setPen(QPen(circleColor, 1));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
}

50
src/ui/WorldPrimitives.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <QColor>
#include <QPointF>
#include "WorldCoordinates.h"
class QPainter;
// The handful of world-space shapes the game view and the balancing tool's arena
// view draw identically: a ship, its health bar, a piece of debris, a sensor range.
//
// Shared because the arena exists to eyeball combat, which only works while a ship
// there looks like a ship in the game — if these drift, the balancing tool quietly
// stops representing what it is measuring. Deliberately kept to shapes that are
// genuinely the same in both: the two views differ on selection highlights, beams
// and target lines, and those stay with each view.
//
// Free functions taking explicit values, with no state and no simulation: each
// view keeps its own iteration and layer order. Sizes derive from the tile size so
// everything scales with the view (REQ-GW-TILE-SIZE).
// Distance from a ship's centre to its nose, in pixels. The selection ring and the
// health bar are positioned from this, so callers need it whether or not they are
// the ones drawing the body.
float getShipForwardExtentPx(const WorldCoordinates& coordinates);
// Radius of a piece of debris, in pixels. Shared so the selection ring around
// debris cannot drift from the debris it is meant to circle.
float getDebrisRadiusPx(const WorldCoordinates& coordinates);
// Draws a ship as a triangle at `center` (widget space), pointing along
// `facing_radians`.
void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float facing_radians,
const QColor& fill, const QColor& outline);
// Draws a health bar: a dark track with `fraction` of it filled, red for an enemy
// and green for the player. A negative fraction is clamped to empty.
void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates,
qreal left, qreal top, qreal width, float fraction, bool isEnemy);
// Draws a piece of debris as a small brown circle at `center` (widget space).
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center);
// Draws a ship's sensor range as a faint circle in the ship's own outline colour,
// so overlapping ranges stay distinguishable.
void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center, float range_tiles, const QColor& shipOutline);

1169
src/ui/WorldRenderer.cpp Normal file

File diff suppressed because it is too large Load Diff

175
src/ui/WorldRenderer.h Normal file
View File

@@ -0,0 +1,175 @@
#pragma once
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <QColor>
#include <QPoint>
#include <QPointF>
#include <QRectF>
#include <QVector2D>
#include "BeamFiredEvent.h"
#include "BuildModeController.h"
#include "BuildingConfig.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
#include "SelectionController.h"
#include "VisualsConfig.h"
#include "WorldCoordinates.h"
class ItemIconCache;
class Simulation;
class QPainter;
class QSvgRenderer;
// A beam still being drawn. Lifetime is counted in game ticks so beams freeze with
// the simulation when it is paused or slowed (REQ-SHP-FIRING-BEAM); the view owns
// the ageing, the renderer only draws what it is given.
struct ActiveBeam
{
BeamFiredEvent event;
QVector2D targetOffset;
};
// Brief outline flash shown on a building when settings are copied from it or
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
// wall-clock time so the flash plays at a fixed length regardless of game speed
// (and while paused).
struct CopyConfigFlash
{
BuildingId id;
qint64 remainingMs;
};
// Everything the renderer draws that the simulation does not know about: what the
// player has selected, which build mode is active, and the other transient bits of
// interaction state the view owns. Assembled fresh each frame and passed by
// reference, so the renderer holds no copy that could go stale.
struct WorldRenderFrame
{
const SelectionController& selection;
const BuildModeController& buildMode;
const std::vector<ActiveBeam>& beams;
const std::optional<BuildingConfig>& copiedConfig;
const std::vector<CopyConfigFlash>& copyConfigFlashes;
bool isBoxSelecting;
QPoint boxStartTile;
QPoint boxCurrentTile;
bool isDebugDrawEnabled;
};
// Draws the game world: terrain, buildings, items, ships, effects and the build
// overlays, in back-to-front order (see the Layer Order section of
// docs/architecture.md).
//
// Reads the simulation and never writes it, and knows nothing about input — the
// view resolves clicks and owns the interaction state, and hands the parts the
// renderer needs over in a WorldRenderFrame.
//
// Everything here is positioned in tiles. Screen-anchored chrome — the pause and
// deconstruct vignettes, the replay overlay, the debug stats panel — stays with
// the view, which is why nothing in this class draws translatable text.
class WorldRenderer
{
public:
// `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
// owned, must outlive this renderer. `configDir` is used once, to load the
// per-building world icons.
WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
ItemIconCache* itemIcons, const std::string& configDir);
~WorldRenderer();
void render(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
private:
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawCopyConfigFeedback(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawPortItems(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawStations(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawShips(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawSelectedTunnelConnections(QPainter& painter,
const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame);
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
// each direction (a half-tile). Uses the item's icon when one exists
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
// visuals.toml. Shared by drawBeltItems and drawPortItems.
void drawWorldItem(QPainter& painter, const std::string& itemId,
QPointF center, float halfPx);
void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates,
QPoint tile, Rotation direction, const QColor& color,
bool centered);
void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates,
BuildingType type, QPoint anchorTile, Rotation rotation,
bool valid, bool showPortTargetGlyphs);
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
// <configDir>/../icons/buildings once at construction. Only the building
// types with a world icon are loaded (production buildings, HQ, stations);
// belts, splitters, and tunnels are deliberately excluded so their
// orientation stays readable. The SVG's chip background is stripped; the
// glyph is pre-rendered in both white and dark ink for auto-contrast.
void loadBuildingIcons(const std::string& configDir);
// Draws a building's world icon glyph centered in box, choosing the white or
// dark pre-rendered variant by fill luminance so it stays legible. Returns
// false if the type has no world icon (caller falls back to the text glyph).
bool drawBuildingIcon(QPainter& painter, const WorldCoordinates& coordinates,
BuildingType type, const QRectF& box,
const QColor& fill) const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(const WorldCoordinates& coordinates,
BuildingId id) const;
std::optional<QVector2D> entityPosition(entt::entity entity) const;
// Non-const only because EntityAdmin's component accessors are; the renderer
// reads the simulation and never writes it.
Simulation& m_sim;
const VisualsConfig& m_visuals;
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
// per target size.
ItemIconCache* m_itemIcons;
// World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a
// white and a dark variant so drawBuildingIcon can auto-contrast against the
// building's fill. Rendered as vector at the view scale each draw so they
// stay crisp. Populated once by loadBuildingIcons().
struct BuildingIconRenderers
{
std::unique_ptr<QSvgRenderer> white;
std::unique_ptr<QSvgRenderer> dark;
};
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
};