Compare commits
13 Commits
4f7fdb8a4c
...
f39fe9a118
| Author | SHA1 | Date | |
|---|---|---|---|
| f39fe9a118 | |||
| e5dcb9de5f | |||
| dc83add5c6 | |||
| 3a1951559d | |||
| 26d7448492 | |||
| 202f583067 | |||
| d5ab44b9bf | |||
| bb1ffab8fc | |||
| e1445fe508 | |||
| caa810f66d | |||
| f6df95abb2 | |||
| fa9dbd62ad | |||
| 2af09d9eb1 |
@@ -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`:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
261
src/lib/core/BuildModeController.cpp
Normal file
261
src/lib/core/BuildModeController.cpp
Normal 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);
|
||||
}
|
||||
123
src/lib/core/BuildModeController.h
Normal file
123
src/lib/core/BuildModeController.h
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
165
src/lib/core/SelectionController.cpp
Normal file
165
src/lib/core/SelectionController.cpp
Normal 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));
|
||||
}
|
||||
75
src/lib/core/SelectionController.h
Normal file
75
src/lib/core/SelectionController.h
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
72
src/lib/core/WorldCamera.cpp
Normal file
72
src/lib/core/WorldCamera.cpp
Normal 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(®ions)
|
||||
, 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);
|
||||
}
|
||||
72
src/lib/core/WorldCamera.h
Normal file
72
src/lib/core/WorldCamera.h
Normal 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;
|
||||
};
|
||||
118
src/lib/core/WorldCoordinates.cpp
Normal file
118
src/lib/core/WorldCoordinates.cpp
Normal 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);
|
||||
}
|
||||
63
src/lib/core/WorldCoordinates.h
Normal file
63
src/lib/core/WorldCoordinates.h
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
12
src/lib/eventsystem/event/DebugDrawToggleRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/DebugDrawToggleRequestedEvent.h
Normal 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;
|
||||
};
|
||||
13
src/lib/eventsystem/event/GhostRotationRequestedEvent.h
Normal file
13
src/lib/eventsystem/event/GhostRotationRequestedEvent.h
Normal 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;
|
||||
};
|
||||
13
src/lib/eventsystem/event/ModeCancelRequestedEvent.h
Normal file
13
src/lib/eventsystem/event/ModeCancelRequestedEvent.h
Normal 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;
|
||||
};
|
||||
22
src/lib/eventsystem/event/PanDirectionChangedEvent.h
Normal file
22
src/lib/eventsystem/event/PanDirectionChangedEvent.h
Normal 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;
|
||||
};
|
||||
12
src/lib/eventsystem/event/PauseToggleRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/PauseToggleRequestedEvent.h
Normal 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;
|
||||
};
|
||||
14
src/lib/eventsystem/event/SpeedStepRequestedEvent.h
Normal file
14
src/lib/eventsystem/event/SpeedStepRequestedEvent.h
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
301
src/test/BuildModeControllerTest.cpp
Normal file
301
src/test/BuildModeControllerTest.cpp
Normal 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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
282
src/test/SelectionControllerTest.cpp
Normal file
282
src/test/SelectionControllerTest.cpp
Normal 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)});
|
||||
}
|
||||
243
src/test/WorldCameraTest.cpp
Normal file
243
src/test/WorldCameraTest.cpp
Normal 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));
|
||||
}
|
||||
203
src/test/WorldCoordinatesTest.cpp
Normal file
203
src/test/WorldCoordinatesTest.cpp
Normal 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));
|
||||
}
|
||||
@@ -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
@@ -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
172
src/ui/InputMapper.cpp
Normal 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
44
src/ui/InputMapper.h
Normal 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
101
src/ui/WorldPrimitives.cpp
Normal 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
50
src/ui/WorldPrimitives.h
Normal 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
1169
src/ui/WorldRenderer.cpp
Normal file
File diff suppressed because it is too large
Load Diff
175
src/ui/WorldRenderer.h
Normal file
175
src/ui/WorldRenderer.h
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user