use WorldCoordinates in ArenaView too

This commit is contained in:
2026-08-05 19:42:37 +02:00
parent 2af09d9eb1
commit fa9dbd62ad
7 changed files with 208 additions and 120 deletions

View File

@@ -358,8 +358,8 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
### Coordinates and Scrolling ### Coordinates and Scrolling
- `GameWorldView` holds a continuous `scrollXTiles` (float), the world X at the *center* of the viewport. A / D input pans this smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). - `GameWorldView` holds a continuous `scrollXTiles` (float), the world X at the *center* of the viewport. A / D input pans this smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED).
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value built from the viewport size, the world height, and the scroll center; `tilePx` is derived so the world height fills the viewport (REQ-GW-TILE-SIZE) rather than being fixed. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not. - 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()` builds one per frame in `paintGL` and per event in the mouse handlers, and passes 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. - `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. - 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 ### Culling

View File

@@ -176,55 +176,36 @@ void ArenaView::paintGL()
QPainter painter(this); QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false); painter.setRenderHint(QPainter::Antialiasing, false);
drawTiles(painter); // One transform snapshot for the whole frame; every draw below reads the
drawBuildings(painter); // viewport through it.
drawStations(painter); const WorldCoordinates coordinates = getCoordinates();
drawDebris(painter);
drawTiles(painter, coordinates);
drawBuildings(painter, coordinates);
drawStations(painter, coordinates);
drawDebris(painter, coordinates);
if (m_debugDraw) if (m_debugDraw)
{ {
drawDebugSensorRanges(painter); drawDebugSensorRanges(painter, coordinates);
drawDebugTargetLines(painter); drawDebugTargetLines(painter, coordinates);
} }
drawShips(painter); drawShips(painter, coordinates);
drawBeams(painter); drawBeams(painter, coordinates);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Coordinate helpers // 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 ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles + ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles; + ac.enemyBufferWidth_tiles;
const int totalHeight = ac.heightTiles; return WorldCoordinates::fitToWorld(size(), totalWidth, 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()));
} }
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
@@ -236,19 +217,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
return m_sim->getAdmin().get<PositionComponent>(entity).value; 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) void ArenaView::mousePressEvent(QMouseEvent* event)
{ {
if (event->button() == Qt::LeftButton) 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); entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (hit != entt::null) if (hit != entt::null)
@@ -287,7 +260,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
// Rendering // Rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void ArenaView::drawTiles(QPainter& painter) void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
{ {
const ArenaConfig& ac = m_sim->getArenaConfig(); const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles const int totalWidth = ac.playerBufferWidth_tiles
@@ -300,12 +273,12 @@ void ArenaView::drawTiles(QPainter& painter)
{ {
for (int y = 0; y < totalHeight; ++y) 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())) for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
{ {
@@ -317,13 +290,13 @@ void ArenaView::drawBuildings(QPainter& painter)
painter.setPen(Qt::NoPen); painter.setPen(Qt::NoPen);
for (const QPoint& cell : b.bodyCells) 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(), const QRectF bboxRect(tl.x(), tl.y(),
b.footprint.width() * static_cast<qreal>(getTilePx()), b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
b.footprint.height() * static_cast<qreal>(getTilePx())); b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
painter.setPen(QPen(bv.outline, 1)); painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
@@ -337,12 +310,12 @@ void ArenaView::drawBuildings(QPainter& painter)
} }
} }
void ArenaView::drawDebris(QPainter& painter) void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
{ {
const float r = getTilePx() * 0.2f; const float r = coordinates.getTilePx() * 0.2f;
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
{ {
const QPointF center = worldToWidget(debris.position); const QPointF center = coordinates.worldToWidget(debris.position);
painter.setBrush(QColor(128, 110, 90)); painter.setBrush(QColor(128, 110, 90));
painter.setPen(QPen(QColor(50, 40, 30), 1)); painter.setPen(QPen(QColor(50, 40, 30), 1));
painter.drawEllipse(center, painter.drawEllipse(center,
@@ -350,7 +323,7 @@ void ArenaView::drawDebris(QPainter& painter)
} }
} }
void ArenaView::drawStations(QPainter& painter) void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
{ {
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>( m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h) [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
@@ -366,13 +339,13 @@ void ArenaView::drawStations(QPainter& painter)
painter.setPen(Qt::NoPen); painter.setPen(Qt::NoPen);
for (const QPoint& cell : sb.bodyCells) 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(), const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(getTilePx()), sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
sb.footprint.height() * static_cast<qreal>(getTilePx())); sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
painter.setPen(QPen(bv.outline, 1)); painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
@@ -381,7 +354,7 @@ void ArenaView::drawStations(QPainter& painter)
if (h.maxHp > 0.0f) if (h.maxHp > 0.0f)
{ {
const float fraction = std::max(0.0f, h.hp / h.maxHp); const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12; const qreal barH = static_cast<qreal>(coordinates.getTilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0; const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width(); const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH), painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
@@ -399,7 +372,7 @@ void ArenaView::drawStations(QPainter& painter)
}); });
} }
void ArenaView::drawShips(QPainter& painter) void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
{ {
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent, m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>( FactionComponent, HealthComponent>(
@@ -411,12 +384,12 @@ void ArenaView::drawShips(QPainter& painter)
m_visuals->ships.find(si.schematicId); m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; } if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value); const QPointF center = coordinates.worldToWidget(pos.value);
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians)); const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
const QVector2D perp(-dir.y(), dir.x()); const QVector2D perp(-dir.y(), dir.x());
const float fwd = getTilePx() * 0.45f; const float fwd = coordinates.getTilePx() * 0.45f;
const float side = getTilePx() * 0.25f; const float side = coordinates.getTilePx() * 0.25f;
QPolygonF tri; QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd), tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
@@ -434,7 +407,7 @@ void ArenaView::drawShips(QPainter& painter)
{ {
const float fraction = std::max(0.0f, h.hp / h.maxHp); const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0; const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12; const qreal barH = static_cast<qreal>(coordinates.getTilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd); const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0; 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, barH), QColor(60, 60, 60));
@@ -444,7 +417,7 @@ void ArenaView::drawShips(QPainter& painter)
if (m_selectedEntity.has_value() && *m_selectedEntity == e) 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.setPen(QPen(QColor(255, 255, 0), 2));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius); painter.drawEllipse(center, radius, radius);
@@ -452,7 +425,8 @@ void ArenaView::drawShips(QPainter& painter)
}); });
} }
void ArenaView::drawDebugSensorRanges(QPainter& painter) void ArenaView::drawDebugSensorRanges(QPainter& painter,
const WorldCoordinates& coordinates)
{ {
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>( m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
@@ -463,9 +437,9 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
m_visuals->ships.find(si.schematicId); m_visuals->ships.find(si.schematicId);
if (it == m_visuals->ships.end()) { return; } if (it == m_visuals->ships.end()) { return; }
const QPointF center = worldToWidget(pos.value); const QPointF center = coordinates.worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles) const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(getTilePx()); * static_cast<qreal>(coordinates.getTilePx());
QColor circleColor = it->second.outline; QColor circleColor = it->second.outline;
circleColor.setAlpha(77); circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1)); painter.setPen(QPen(circleColor, 1));
@@ -473,7 +447,8 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
}); });
} }
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 // 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 // team to match the per-side HQ/station colors used elsewhere in the arena
@@ -491,7 +466,8 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
QColor lineColor = it->second.fill; QColor lineColor = it->second.fill;
lineColor.setAlpha(128); lineColor.setAlpha(128);
painter.setPen(QPen(lineColor, 1)); 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, m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
@@ -536,7 +512,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) for (const ActiveBeam& beam : m_activeBeams)
{ {
@@ -552,7 +528,7 @@ void ArenaView::drawBeams(QPainter& painter)
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break; case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
} }
painter.setPen(QPen(color, m_visuals->beams.widthPx)); painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos), painter.drawLine(coordinates.worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset)); coordinates.worldToWidget(*targetPos + beam.targetOffset));
} }
} }

View File

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

View File

@@ -1,21 +1,61 @@
#include "WorldCoordinates.h" #include "WorldCoordinates.h"
#include <algorithm>
#include <cmath> #include <cmath>
WorldCoordinates::WorldCoordinates(QSize widgetSize_px, int worldHeight_tiles, namespace
float viewCenterX_tiles)
: m_tilePx(1.0f)
, m_viewportWidthTiles(0.0f)
, m_viewLeftTiles(0.0f)
, m_worldHeightTiles(worldHeight_tiles)
{ {
// 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) if (worldHeight_tiles > 0)
{ {
m_tilePx = static_cast<float>(widgetSize_px.height()) tilePx = static_cast<float>(widgetSize_px.height())
/ static_cast<float>(worldHeight_tiles); / static_cast<float>(worldHeight_tiles);
} }
m_viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / m_tilePx; tilePx = sanitizeTilePx(tilePx);
m_viewLeftTiles = viewCenterX_tiles - m_viewportWidthTiles / 2.0f;
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 float WorldCoordinates::getTilePx() const

View File

@@ -8,28 +8,37 @@
#include <QVector2D> #include <QVector2D>
// Immutable snapshot of the world <-> widget transform for one viewport state // Immutable snapshot of the world <-> widget transform for one viewport state
// (REQ-GW-COORDS, REQ-GW-TILE-SIZE). Tiles are square and sized so the world // (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
// height exactly fills the viewport height; there is no zoom (REQ-UI-NO-ZOOM), // the tile size and the left edge are derived, and everything downstream of that
// so the only free parameter is the horizontal scroll position. // is shared.
// //
// The transform is a value: it is constructed from the viewport size, the world // The transform is a value: it is constructed from the viewport size and the view
// height, and the view center, and never observes them again. A caller therefore // state, and never observes them again. A caller therefore builds one per frame
// builds one per frame (or per event) rather than holding one across a resize or // (or per event) rather than holding one across a resize or a scroll, which would
// a scroll, which would silently go stale. // silently go stale.
class WorldCoordinates class WorldCoordinates
{ {
public: public:
// `viewCenterX_tiles` is the world X at the center of the viewport, matching // The scrolling game world: the tile size is whatever makes the world height
// how the scroll position is stored and clamped (REQ-GW-SCROLL-LIMIT). // exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
WorldCoordinates(QSize widgetSize_px, int worldHeight_tiles, // REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
float viewCenterX_tiles); // 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);
// Side length of one tile in pixels. Degenerate world heights yield 1.0 so // A whole world shown at once with no scrolling, as the balancing tool's arena
// callers never divide by zero. // 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 getTilePx() const;
float getViewportWidthTiles() const; float getViewportWidthTiles() const;
// World X (tiles) at the left edge of the viewport, derived from the view // World X (tiles) at the left edge of the viewport.
// center the constructor was given.
float getViewLeftTiles() const; float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const; QPointF worldToWidget(QVector2D worldPos) const;
@@ -44,6 +53,9 @@ public:
QRect getViewportRect() const; QRect getViewportRect() const;
private: private:
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
int worldHeight_tiles);
float m_tilePx; float m_tilePx;
float m_viewportWidthTiles; float m_viewportWidthTiles;
float m_viewLeftTiles; float m_viewLeftTiles;

View File

@@ -10,7 +10,7 @@
// 40-tile-wide view, so every expectation below is a whole number. // 40-tile-wide view, so every expectation below is a whole number.
static WorldCoordinates makeCoordinates(float viewCenterX_tiles) static WorldCoordinates makeCoordinates(float viewCenterX_tiles)
{ {
return WorldCoordinates(QSize(800, 400), 20, viewCenterX_tiles); return WorldCoordinates::scrolling(QSize(800, 400), 20, viewCenterX_tiles);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -22,21 +22,34 @@ 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 // REQ-GW-TILE-SIZE: tiles are square and sized so the world height exactly
// fills the view's height. // fills the view's height.
REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f)); REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f));
REQUIRE(WorldCoordinates(QSize(800, 600), 20, 0.0f).getTilePx() == Approx(30.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]") 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 // Guards the division in every conversion; a zero or negative height would
// otherwise produce infinities. // otherwise produce infinities.
REQUIRE(WorldCoordinates(QSize(800, 400), 0, 0.0f).getTilePx() == Approx(1.0f)); REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), 0, 0.0f).getTilePx()
REQUIRE(WorldCoordinates(QSize(800, 400), -5, 0.0f).getTilePx() == Approx(1.0f)); == 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]") TEST_CASE("Viewport width in tiles follows the widget width", "[coords]")
{ {
REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f)); REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f));
REQUIRE(WorldCoordinates(QSize(400, 400), 20, 0.0f).getViewportWidthTiles() REQUIRE(WorldCoordinates::scrolling(QSize(400, 400), 20, 0.0f).getViewportWidthTiles()
== Approx(20.0f)); == Approx(20.0f));
} }
@@ -143,3 +156,48 @@ TEST_CASE("A fractional scroll position widens the viewport rect outward", "[coo
REQUIRE(rect.left() == -21); REQUIRE(rect.left() == -21);
REQUIRE(rect.right() == 21); REQUIRE(rect.right() == 21);
} }
// ---------------------------------------------------------------------------
// Fitted (non-scrolling) worlds
// ---------------------------------------------------------------------------
TEST_CASE("A fitted world takes the tighter of the two axis fits", "[coords]")
{
// Height-limited: 400/20 = 20 px per tile beats 800/30 = 26.67.
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 30, 20).getTilePx()
== Approx(20.0f));
// Width-limited: 800/80 = 10 px per tile beats 400/20 = 20.
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20).getTilePx()
== Approx(10.0f));
}
TEST_CASE("A fitted world keeps its whole width on screen", "[coords]")
{
// The point of taking the tighter fit: the far edge must land inside the
// viewport, never past it.
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
REQUIRE(coordinates.worldToWidget(QVector2D(80.0f, 0.0f)).x() <= 800.0);
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 20.0f)).y() <= 400.0);
}
TEST_CASE("A fitted world puts the origin at the widget's top-left", "[coords]")
{
// No scrolling, so there is no view center to subtract.
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 40, 20);
REQUIRE(coordinates.getViewLeftTiles() == Approx(0.0f));
REQUIRE(coordinates.tileToWidget(QPoint(0, 0)) == QPointF(0.0, 0.0));
REQUIRE(coordinates.tileToWidget(QPoint(3, 2)) == QPointF(60.0, 40.0));
}
TEST_CASE("A fitted world round-trips widget points back to world positions",
"[coords]")
{
const WorldCoordinates coordinates =
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
const QVector2D world = coordinates.widgetToWorld(QPoint(120, 55));
const QPointF back = coordinates.worldToWidget(world);
REQUIRE(back.x() == Approx(120.0));
REQUIRE(back.y() == Approx(55.0));
}

View File

@@ -525,7 +525,8 @@ void GameWorldView::paintGL()
WorldCoordinates GameWorldView::getCoordinates() const WorldCoordinates GameWorldView::getCoordinates() const
{ {
return WorldCoordinates(size(), m_config->world.heightTiles, m_scrollXTiles); return WorldCoordinates::scrolling(size(), m_config->world.heightTiles,
m_scrollXTiles);
} }
float GameWorldView::getAsteroidLeftEdge() const float GameWorldView::getAsteroidLeftEdge() const