From fa9dbd62ad3c4d5fc9f8d1b9e1555c10bbd10358 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Wed, 5 Aug 2026 19:42:37 +0200 Subject: [PATCH] use WorldCoordinates in ArenaView too --- docs/architecture.md | 4 +- src/balancing/ArenaView.cpp | 124 ++++++++++++------------------ src/balancing/ArenaView.h | 27 +++---- src/lib/core/WorldCoordinates.cpp | 60 ++++++++++++--- src/lib/core/WorldCoordinates.h | 42 ++++++---- src/test/WorldCoordinatesTest.cpp | 68 ++++++++++++++-- src/ui/GameWorldView.cpp | 3 +- 7 files changed, 208 insertions(+), 120 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2ef932e..4aa0fce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -358,8 +358,8 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly ### 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). -- 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. -- `GameWorldView::getCoordinates()` builds one per frame in `paintGL` and per event in the mouse handlers, and passes it down: every world-space `draw` 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. +- 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` 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 diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index ffdf88d..8129ca4 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -176,55 +176,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(height()) / static_cast(totalHeight); - const float pxPerTileW = static_cast(width()) / static_cast(totalWidth); - return std::min(pxPerTileH, pxPerTileW); -} - -QPointF ArenaView::worldToWidget(QVector2D worldPos) const -{ - return QPointF( - static_cast(worldPos.x() * getTilePx()), - static_cast(worldPos.y() * getTilePx())); -} - -QPointF ArenaView::tileToWidget(QPoint tile) const -{ - return worldToWidget(QVector2D(static_cast(tile.x()), - static_cast(tile.y()))); -} - -QRectF ArenaView::tileRect(QPoint tile) const -{ - const QPointF tl = tileToWidget(tile); - return QRectF(tl.x(), tl.y(), - static_cast(getTilePx()), static_cast(getTilePx())); + return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles); } std::optional ArenaView::entityPosition(entt::entity entity) const @@ -236,19 +217,11 @@ std::optional ArenaView::entityPosition(entt::entity entity) const return m_sim->getAdmin().get(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(widgetPt.x()) / px, - static_cast(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 +260,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 +273,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 +290,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(getTilePx()), - b.footprint.height() * static_cast(getTilePx())); + b.footprint.width() * static_cast(coordinates.getTilePx()), + b.footprint.height() * static_cast(coordinates.getTilePx())); painter.setPen(QPen(bv.outline, 1)); 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())) { - const QPointF center = worldToWidget(debris.position); + const QPointF center = coordinates.worldToWidget(debris.position); painter.setBrush(QColor(128, 110, 90)); painter.setPen(QPen(QColor(50, 40, 30), 1)); 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( [&](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); 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(getTilePx()), - sb.footprint.height() * static_cast(getTilePx())); + sb.footprint.width() * static_cast(coordinates.getTilePx()), + sb.footprint.height() * static_cast(coordinates.getTilePx())); painter.setPen(QPen(bv.outline, 1)); painter.setBrush(Qt::NoBrush); @@ -381,7 +354,7 @@ 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(getTilePx()) * 0.12; + const qreal barH = static_cast(coordinates.getTilePx()) * 0.12; const qreal barY = bboxRect.bottom() + 1.0; const qreal barW = bboxRect.width(); 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( @@ -411,12 +384,12 @@ 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 QPointF center = coordinates.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; + const float fwd = coordinates.getTilePx() * 0.45f; + const float side = coordinates.getTilePx() * 0.25f; QPolygonF tri; tri << QPointF(center.x() + static_cast(dir.x() * fwd), @@ -434,7 +407,7 @@ void ArenaView::drawShips(QPainter& painter) { const float fraction = std::max(0.0f, h.hp / h.maxHp); const qreal barW = static_cast(fwd) * 2.0; - const qreal barH = static_cast(getTilePx()) * 0.12; + const qreal barH = static_cast(coordinates.getTilePx()) * 0.12; const qreal barX = center.x() - static_cast(fwd); const qreal barY = center.y() + static_cast(fwd) + 1.0; 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) { - const qreal radius = static_cast(getTilePx()) * 0.55; + const qreal radius = static_cast(coordinates.getTilePx()) * 0.55; painter.setPen(QPen(QColor(255, 255, 0), 2)); painter.setBrush(Qt::NoBrush); 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); m_sim->getAdmin().forEach( @@ -463,9 +437,9 @@ 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 QPointF center = coordinates.worldToWidget(pos.value); const qreal radiusPx = static_cast(sensor.value_tiles) - * static_cast(getTilePx()); + * static_cast(coordinates.getTilePx()); QColor circleColor = it->second.outline; circleColor.setAlpha(77); 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 // 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; 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().forEachbeams.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)); } } diff --git a/src/balancing/ArenaView.h b/src/balancing/ArenaView.h index cc94374..82c7b46 100644 --- a/src/balancing/ArenaView.h +++ b/src/balancing/ArenaView.h @@ -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 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 entityPosition(entt::entity entity) const; - QVector2D widgetToWorld(QPoint widgetPt) const; struct ActiveBeam { diff --git a/src/lib/core/WorldCoordinates.cpp b/src/lib/core/WorldCoordinates.cpp index fdd8a3f..6978ad9 100644 --- a/src/lib/core/WorldCoordinates.cpp +++ b/src/lib/core/WorldCoordinates.cpp @@ -1,21 +1,61 @@ #include "WorldCoordinates.h" +#include #include -WorldCoordinates::WorldCoordinates(QSize widgetSize_px, int worldHeight_tiles, - float viewCenterX_tiles) - : m_tilePx(1.0f) - , m_viewportWidthTiles(0.0f) - , m_viewLeftTiles(0.0f) - , m_worldHeightTiles(worldHeight_tiles) +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) { - m_tilePx = static_cast(widgetSize_px.height()) - / static_cast(worldHeight_tiles); + tilePx = static_cast(widgetSize_px.height()) + / static_cast(worldHeight_tiles); } - m_viewportWidthTiles = static_cast(widgetSize_px.width()) / m_tilePx; - m_viewLeftTiles = viewCenterX_tiles - m_viewportWidthTiles / 2.0f; + tilePx = sanitizeTilePx(tilePx); + + const float viewportWidthTiles = static_cast(widgetSize_px.width()) / tilePx; + return WorldCoordinates(tilePx, static_cast(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(widgetSize_px.height()) / static_cast(worldHeight_tiles), + static_cast(widgetSize_px.width()) / static_cast(worldWidth_tiles)); + } + tilePx = sanitizeTilePx(tilePx); + + return WorldCoordinates(tilePx, static_cast(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 diff --git a/src/lib/core/WorldCoordinates.h b/src/lib/core/WorldCoordinates.h index bccc4aa..50f35b6 100644 --- a/src/lib/core/WorldCoordinates.h +++ b/src/lib/core/WorldCoordinates.h @@ -8,28 +8,37 @@ #include // 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 -// height exactly fills the viewport height; there is no zoom (REQ-UI-NO-ZOOM), -// so the only free parameter is the horizontal scroll position. +// (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, the world -// height, and the view center, 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. +// 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: - // `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). - WorldCoordinates(QSize widgetSize_px, int worldHeight_tiles, - float viewCenterX_tiles); + // 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); - // Side length of one tile in pixels. Degenerate world heights yield 1.0 so - // callers never divide by zero. + // 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, derived from the view - // center the constructor was given. + // World X (tiles) at the left edge of the viewport. float getViewLeftTiles() const; QPointF worldToWidget(QVector2D worldPos) const; @@ -44,6 +53,9 @@ public: 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; diff --git a/src/test/WorldCoordinatesTest.cpp b/src/test/WorldCoordinatesTest.cpp index 2b3eeba..333b4e3 100644 --- a/src/test/WorldCoordinatesTest.cpp +++ b/src/test/WorldCoordinatesTest.cpp @@ -10,7 +10,7 @@ // 40-tile-wide view, so every expectation below is a whole number. 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 // fills the view's height. 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]") { // Guards the division in every conversion; a zero or negative height would // otherwise produce infinities. - REQUIRE(WorldCoordinates(QSize(800, 400), 0, 0.0f).getTilePx() == Approx(1.0f)); - REQUIRE(WorldCoordinates(QSize(800, 400), -5, 0.0f).getTilePx() == Approx(1.0f)); + 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(QSize(400, 400), 20, 0.0f).getViewportWidthTiles() + REQUIRE(WorldCoordinates::scrolling(QSize(400, 400), 20, 0.0f).getViewportWidthTiles() == 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.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)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 85bc4bc..acf78b8 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -525,7 +525,8 @@ void GameWorldView::paintGL() 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