diff --git a/docs/architecture.md b/docs/architecture.md index f541bd0..2ef932e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,9 +357,10 @@ 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. +- `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. +- 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/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index a0d83f8..e7fea30 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -14,6 +14,7 @@ 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 PARENT_SCOPE ) @@ -25,6 +26,7 @@ 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 PARENT_SCOPE ) diff --git a/src/lib/core/WorldCoordinates.cpp b/src/lib/core/WorldCoordinates.cpp new file mode 100644 index 0000000..fdd8a3f --- /dev/null +++ b/src/lib/core/WorldCoordinates.cpp @@ -0,0 +1,78 @@ +#include "WorldCoordinates.h" + +#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) +{ + if (worldHeight_tiles > 0) + { + m_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; +} + +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((worldPos.x() - m_viewLeftTiles) * m_tilePx), + static_cast(worldPos.y() * m_tilePx)); +} + +QPointF WorldCoordinates::tileToWidget(QPoint tile) const +{ + return worldToWidget(QVector2D(static_cast(tile.x()), + static_cast(tile.y()))); +} + +QPoint WorldCoordinates::widgetToTile(QPoint widgetPoint) const +{ + const QVector2D world = widgetToWorld(widgetPoint); + return QPoint(static_cast(std::floor(world.x())), + static_cast(std::floor(world.y()))); +} + +QVector2D WorldCoordinates::widgetToWorld(QPoint widgetPoint) const +{ + return QVector2D( + static_cast(widgetPoint.x()) / m_tilePx + m_viewLeftTiles, + static_cast(widgetPoint.y()) / m_tilePx); +} + +QRectF WorldCoordinates::tileRect(QPoint tile) const +{ + const QPointF topLeft = tileToWidget(tile); + return QRectF(topLeft.x(), topLeft.y(), + static_cast(m_tilePx), static_cast(m_tilePx)); +} + +QRect WorldCoordinates::getViewportRect() const +{ + const int left = static_cast(std::floor(m_viewLeftTiles)) - 1; + const int top = 0; + const int right = static_cast( + std::ceil(m_viewLeftTiles + m_viewportWidthTiles)) + 1; + const int bottom = m_worldHeightTiles; + return QRect(left, top, right - left, bottom - top); +} diff --git a/src/lib/core/WorldCoordinates.h b/src/lib/core/WorldCoordinates.h new file mode 100644 index 0000000..bccc4aa --- /dev/null +++ b/src/lib/core/WorldCoordinates.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include +#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. +// +// 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. +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); + + // Side length of one tile in pixels. Degenerate world heights yield 1.0 so + // callers never divide 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. + 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: + float m_tilePx; + float m_viewportWidthTiles; + float m_viewLeftTiles; + int m_worldHeightTiles; +}; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 4887c04..e812447 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -12,6 +12,7 @@ add_files( SurfaceMaskTest.cpp BeltDragPathTest.cpp TunnelCompletionTest.cpp + WorldCoordinatesTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/test/WorldCoordinatesTest.cpp b/src/test/WorldCoordinatesTest.cpp new file mode 100644 index 0000000..2b3eeba --- /dev/null +++ b/src/test/WorldCoordinatesTest.cpp @@ -0,0 +1,145 @@ +#include "catch.hpp" + +#include +#include +#include + +#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(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(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)); +} + +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() + == 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); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index ace7188..85bc4bc 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -379,7 +379,8 @@ void GameWorldView::onFrame() // so refresh the box-select rectangle even though no mouse move fires. if (m_boxSelecting && m_scrollXTiles != scrollBefore) { - m_boxCurrentTile = widgetToTile(mapFromGlobal(QCursor::pos())); + m_boxCurrentTile = + getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos())); } } @@ -488,26 +489,30 @@ void GameWorldView::paintGL() QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); - drawTiles(painter); - drawBuildings(painter); + // One transform snapshot for the whole frame; every world-space draw below + // reads the viewport through it. + const WorldCoordinates coordinates = getCoordinates(); + + drawTiles(painter, coordinates); + drawBuildings(painter, coordinates); // Port items are drawn over the buildings but clipped to a thin margin at each // machine's edges (see drawPortItems), so items appear to emerge from / sink // into the port and stay visible while crossing directly between two touching // buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE). - drawPortItems(painter); - drawCopyConfigFeedback(painter); - drawStations(painter); - drawBeltItems(painter); - drawDebris(painter); + drawPortItems(painter, coordinates); + drawCopyConfigFeedback(painter, coordinates); + drawStations(painter, coordinates); + drawBeltItems(painter, coordinates); + drawDebris(painter, coordinates); if (m_debugDraw) { - drawDebugSensorRanges(painter); - drawDebugTargetLines(painter); + drawDebugSensorRanges(painter, coordinates); + drawDebugTargetLines(painter, coordinates); drawDebugOverlay(painter); } - drawShips(painter); - drawBeams(painter); - drawOverlays(painter); + drawShips(painter, coordinates); + drawBeams(painter, coordinates); + drawOverlays(painter, coordinates); drawScreenSpace(painter); drawPauseBorder(painter); drawDeconstructBorder(painter); @@ -518,63 +523,9 @@ void GameWorldView::paintGL() // Coordinate helpers // --------------------------------------------------------------------------- -float GameWorldView::getTilePx() const +WorldCoordinates GameWorldView::getCoordinates() const { - if (m_config->world.heightTiles <= 0) { return 1.0f; } - return static_cast(height()) / static_cast(m_config->world.heightTiles); -} - -float GameWorldView::getViewportWidthTiles() const -{ - return static_cast(width()) / getTilePx(); -} - -float GameWorldView::getViewLeftTiles() const -{ - return m_scrollXTiles - getViewportWidthTiles() / 2.0f; -} - -QPointF GameWorldView::worldToWidget(QVector2D worldPos) const -{ - return QPointF( - static_cast((worldPos.x() - getViewLeftTiles()) * getTilePx()), - static_cast(worldPos.y() * getTilePx())); -} - -QPointF GameWorldView::tileToWidget(QPoint tile) const -{ - return worldToWidget(QVector2D(static_cast(tile.x()), - static_cast(tile.y()))); -} - -QPoint GameWorldView::widgetToTile(QPoint widgetPt) const -{ - const float wx = static_cast(widgetPt.x()) / getTilePx() + getViewLeftTiles(); - const float wy = static_cast(widgetPt.y()) / getTilePx(); - return QPoint(static_cast(std::floor(wx)), static_cast(std::floor(wy))); -} - -QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const -{ - const float wx = static_cast(widgetPt.x()) / getTilePx() + getViewLeftTiles(); - const float wy = static_cast(widgetPt.y()) / getTilePx(); - return QVector2D(wx, wy); -} - -QRectF GameWorldView::tileRect(QPoint tile) const -{ - const QPointF tl = tileToWidget(tile); - return QRectF(tl.x(), tl.y(), - static_cast(getTilePx()), static_cast(getTilePx())); -} - -QRect GameWorldView::getViewportRect() const -{ - const int left = static_cast(std::floor(getViewLeftTiles())) - 1; - const int top = 0; - const int right = static_cast(std::ceil(getViewLeftTiles() + getViewportWidthTiles())) + 1; - const int bottom = m_config->world.heightTiles; - return QRect(left, top, right - left, bottom - top); + return WorldCoordinates(size(), m_config->world.heightTiles, m_scrollXTiles); } float GameWorldView::getAsteroidLeftEdge() const @@ -1178,12 +1129,13 @@ void GameWorldView::applyBeltDragPath() // Port glyph helper // --------------------------------------------------------------------------- -void GameWorldView::drawPortGlyph(QPainter& painter, QPoint tile, - Rotation direction, const QColor& color, - bool centered) +void GameWorldView::drawPortGlyph(QPainter& painter, + const WorldCoordinates& coordinates, QPoint tile, + Rotation direction, const QColor& color, + bool centered) { - const float px = getTilePx(); - const QRectF tr = tileRect(tile); + const float px = coordinates.getTilePx(); + const QRectF tr = coordinates.tileRect(tile); const QPointF center(tr.x() + static_cast(px) * 0.5, tr.y() + static_cast(px) * 0.5); @@ -1220,10 +1172,11 @@ void GameWorldView::drawPortGlyph(QPainter& painter, QPoint tile, // Rendering // --------------------------------------------------------------------------- -void GameWorldView::drawTiles(QPainter& painter) +void GameWorldView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates) { - const int leftTile = static_cast(std::floor(getViewLeftTiles())) - 1; - const int rightTile = leftTile + static_cast(std::ceil(getViewportWidthTiles())) + 2; + const int leftTile = static_cast(std::floor(coordinates.getViewLeftTiles())) - 1; + const int rightTile = leftTile + + static_cast(std::ceil(coordinates.getViewportWidthTiles())) + 2; const int bottomTile = m_config->world.heightTiles; // Asteroid columns left of the buildable edge are not yet unlocked by @@ -1239,7 +1192,7 @@ void GameWorldView::drawTiles(QPainter& painter) const bool locked = (x < buildableLeftX); for (int y = 0; y < bottomTile; ++y) { - const QRectF rect = tileRect(QPoint(x, y)); + const QRectF rect = coordinates.tileRect(QPoint(x, y)); painter.fillRect(rect, fill); if (locked) { @@ -1273,7 +1226,9 @@ void GameWorldView::loadBuildingIcons(const std::string& configDir) } } -bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type, +bool GameWorldView::drawBuildingIcon(QPainter& painter, + const WorldCoordinates& coordinates, + BuildingType type, const QRectF& box, const QColor& fill) const { const std::map::const_iterator it = @@ -1282,7 +1237,7 @@ bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type, // Every icon is the same fixed on-screen size, centered on the footprint, // regardless of footprint size (REQ-UI-WORLD-ICON). - const qreal side = static_cast(getTilePx()) * kWorldIconTileFactor; + const qreal side = static_cast(coordinates.getTilePx()) * kWorldIconTileFactor; const QRectF target(box.center().x() - side / 2.0, box.center().y() - side / 2.0, side, side); QSvgRenderer* renderer = isLightFill(fill) ? it->second.dark.get() @@ -1297,7 +1252,7 @@ bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type, return true; } -void GameWorldView::drawBuildings(QPainter& painter) +void GameWorldView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates) { for (const Building& b : getAllBuildings(m_sim->getFactoryState())) { @@ -1309,13 +1264,13 @@ void GameWorldView::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); @@ -1323,7 +1278,8 @@ void GameWorldView::drawBuildings(QPainter& painter) // Icon glyph over the fill (REQ-UI-WORLD-ICON); falls back to the text // glyph for building types without a world icon (e.g. tunnels). - if (!drawBuildingIcon(painter, b.type, bboxRect, bv.fill) && !bv.glyph.isEmpty()) + if (!drawBuildingIcon(painter, coordinates, b.type, bboxRect, bv.fill) + && !bv.glyph.isEmpty()) { painter.setPen(bv.outline); painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); @@ -1331,7 +1287,8 @@ void GameWorldView::drawBuildings(QPainter& painter) for (const Port& port : b.outputPorts) { - drawPortGlyph(painter, outputBodyTile(port.tile, port.direction), + drawPortGlyph(painter, coordinates, + outputBodyTile(port.tile, port.direction), port.direction, bv.outline, /*centered*/ false); } @@ -1343,7 +1300,7 @@ void GameWorldView::drawBuildings(QPainter& painter) if (const std::optional status = getProductionStatus(m_sim->getConfig(), b)) { - const float px = getTilePx(); + const float px = coordinates.getTilePx(); const float r = px * 0.18f; const float inset = r + px * 0.12f; // Default orientation (East) puts the light at the top-right corner; @@ -1375,13 +1332,13 @@ void GameWorldView::drawBuildings(QPainter& painter) for (const QPoint& cell : s.bodyCells) { - painter.fillRect(tileRect(cell), bv.fill); + painter.fillRect(coordinates.tileRect(cell), bv.fill); } - const QPointF tl = tileToWidget(s.anchor); + const QPointF tl = coordinates.tileToWidget(s.anchor); const QRectF bboxRect(tl.x(), tl.y(), - s.footprint.width() * static_cast(getTilePx()), - s.footprint.height() * static_cast(getTilePx())); + s.footprint.width() * static_cast(coordinates.getTilePx()), + s.footprint.height() * static_cast(coordinates.getTilePx())); painter.setPen(QPen(bv.outline, 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); @@ -1406,7 +1363,7 @@ void GameWorldView::drawBuildings(QPainter& painter) // Identity symbol with the progress percentage below it // (REQ-UI-CONSTRUCTION-PROGRESS): the world icon centered on the // footprint where the type has one, otherwise the text glyph. - if (drawBuildingIcon(painter, s.type, bboxRect, bv.fill)) + if (drawBuildingIcon(painter, coordinates, s.type, bboxRect, bv.fill)) { painter.drawText(bboxRect, Qt::AlignHCenter | Qt::AlignBottom, pctText); } @@ -1432,8 +1389,8 @@ void GameWorldView::drawBuildings(QPainter& painter) { const QPoint absBody = s.anchor + outputBodyTile(port.tile, port.direction); - drawPortGlyph(painter, absBody, port.direction, bv.outline, - /*centered*/ false); + drawPortGlyph(painter, coordinates, absBody, port.direction, + bv.outline, /*centered*/ false); } } } @@ -1446,17 +1403,18 @@ void GameWorldView::drawBuildings(QPainter& painter) for (const Building& b : getAllBuildings(m_sim->getFactoryState())) { if (b.type != BuildingType::Hq) { continue; } - 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())); m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, const FactionComponent& f, const HealthComponent& h) { if (h.maxHp > 0.0f) { - drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0, + drawHpBar(painter, coordinates, + bboxRect.left(), bboxRect.bottom() + 1.0, bboxRect.width(), h.hp / h.maxHp, f.isEnemy); } }); @@ -1466,10 +1424,11 @@ void GameWorldView::drawBuildings(QPainter& painter) // site fill, so a selected building surrounded by neighbours keeps its outline: // the highlight sits 1px outside the footprint (into adjacent tiles), and drawing // it inline would let later-drawn neighbours overpaint it with their body fill. - drawSelectionHighlights(painter); + drawSelectionHighlights(painter, coordinates); } -std::optional GameWorldView::footprintWidgetRect(BuildingId id) const +std::optional GameWorldView::footprintWidgetRect( + const WorldCoordinates& coordinates, BuildingId id) const { std::optional anchor; std::optional footprint; @@ -1486,20 +1445,21 @@ std::optional GameWorldView::footprintWidgetRect(BuildingId id) const } if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; } - const QPointF tl = tileToWidget(*anchor); + const QPointF tl = coordinates.tileToWidget(*anchor); return QRectF(tl.x(), tl.y(), - footprint->width() * static_cast(getTilePx()), - footprint->height() * static_cast(getTilePx())); + footprint->width() * static_cast(coordinates.getTilePx()), + footprint->height() * static_cast(coordinates.getTilePx())); } -void GameWorldView::drawSelectionHighlights(QPainter& painter) +void GameWorldView::drawSelectionHighlights(QPainter& painter, + const WorldCoordinates& coordinates) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); for (BuildingId selId : m_selectedBuildingIds) { - const std::optional rect = footprintWidgetRect(selId); + const std::optional rect = footprintWidgetRect(coordinates, selId); if (!rect.has_value()) { continue; } // Outline sits 1px outside the footprint (into adjacent tiles). painter.drawRect(rect->adjusted(-1, -1, 1, 1)); @@ -1509,17 +1469,20 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter) // rendered circle (radius getTilePx()*0.2, matching drawDebris) (REQ-UI-DEBRIS-CLICK-SELECT). if (!m_selectedDebris.empty()) { - const qreal outlineRadius = static_cast(getTilePx() * 0.2f) + 3.0; + const qreal outlineRadius = + static_cast(coordinates.getTilePx() * 0.2f) + 3.0; for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) { if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), debris.entity) == m_selectedDebris.end()) { continue; } - painter.drawEllipse(worldToWidget(debris.position), outlineRadius, outlineRadius); + painter.drawEllipse(coordinates.worldToWidget(debris.position), + outlineRadius, outlineRadius); } } } -void GameWorldView::drawCopyConfigFeedback(QPainter& painter) +void GameWorldView::drawCopyConfigFeedback(QPainter& painter, + const WorldCoordinates& coordinates) { const QColor color = m_visuals->overlays.copyConfig; @@ -1534,13 +1497,13 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter) for (const Building& b : getAllBuildings(m_sim->getFactoryState())) { if (b.type != type) { continue; } - const std::optional rect = footprintWidgetRect(b.id); + const std::optional rect = footprintWidgetRect(coordinates, b.id); if (rect.has_value()) { painter.drawRect(*rect); } } for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState())) { if (s.type != type) { continue; } - const std::optional rect = footprintWidgetRect(s.id); + const std::optional rect = footprintWidgetRect(coordinates, s.id); if (rect.has_value()) { painter.drawRect(*rect); } } } @@ -1551,14 +1514,14 @@ void GameWorldView::drawCopyConfigFeedback(QPainter& painter) painter.setBrush(Qt::NoBrush); for (const CopyConfigFlash& flash : m_copyConfigFlashes) { - const std::optional rect = footprintWidgetRect(flash.id); + const std::optional rect = footprintWidgetRect(coordinates, flash.id); if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); } } } -void GameWorldView::drawPortItems(QPainter& painter) +void GameWorldView::drawPortItems(QPainter& painter, const WorldCoordinates& coordinates) { - const float halfPx = getTilePx() * 0.5f * 0.5f; + const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f; // Port items are drawn over the buildings (drawBuildings runs first) but clipped // to a thin margin at each machine's edges: the clip region is the whole view @@ -1569,7 +1532,7 @@ void GameWorldView::drawPortItems(QPainter& painter) // (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so // items on belts stay fully visible. constexpr double kPortMarginTiles = 0.2; - const double margin = kPortMarginTiles * static_cast(getTilePx()); + const double margin = kPortMarginTiles * static_cast(coordinates.getTilePx()); QRegion clip(rect()); for (const Building& b : getAllBuildings(m_sim->getFactoryState())) @@ -1588,7 +1551,8 @@ void GameWorldView::drawPortItems(QPainter& painter) const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin; const double r = cells.count(cell + QPoint( 1, 0)) ? 0.0 : margin; const double d = cells.count(cell + QPoint( 0, 1)) ? 0.0 : margin; - clip = clip.subtracted(QRegion(tileRect(cell).adjusted(l, t, -r, -d).toRect())); + clip = clip.subtracted( + QRegion(coordinates.tileRect(cell).adjusted(l, t, -r, -d).toRect())); } } @@ -1597,7 +1561,7 @@ void GameWorldView::drawPortItems(QPainter& painter) const std::function drawItem = [&](const ItemType& type, QPointF worldPos) { - const QPointF center = worldToWidget( + const QPointF center = coordinates.worldToWidget( QVector2D(static_cast(worldPos.x()), static_cast(worldPos.y()))); drawWorldItem(painter, type.id, center, halfPx); @@ -1637,26 +1601,26 @@ void GameWorldView::drawWorldItem(QPainter& painter, const std::string& itemId, painter.drawRect(itemRect); } -void GameWorldView::drawBeltItems(QPainter& painter) +void GameWorldView::drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates) { - const float halfPx = getTilePx() * 0.5f * 0.5f; - const QRect vr = getViewportRect(); + const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f; + const QRect vr = coordinates.getViewportRect(); m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi) { - const QPointF center = worldToWidget( + const QPointF center = coordinates.worldToWidget( QVector2D(static_cast(vi.worldPos.x()), static_cast(vi.worldPos.y()))); drawWorldItem(painter, vi.type.id, center, halfPx); }); } -void GameWorldView::drawDebris(QPainter& painter) +void GameWorldView::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, @@ -1664,7 +1628,7 @@ void GameWorldView::drawDebris(QPainter& painter) } } -void GameWorldView::drawStations(QPainter& painter) +void GameWorldView::drawStations(QPainter& painter, const WorldCoordinates& coordinates) { m_sim->getAdmin().forEach( [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, @@ -1681,13 +1645,14 @@ void GameWorldView::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(QPoint(sb.anchor.x(), sb.anchor.y())); + const QPointF tl = + coordinates.tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y())); 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); @@ -1695,7 +1660,7 @@ void GameWorldView::drawStations(QPainter& painter) // Station icon over the fill (REQ-UI-WORLD-ICON); the same symbol is // colored blue/red by the player/enemy fill it is drawn over. - drawBuildingIcon(painter, visType, bboxRect, bv.fill); + drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill); if (isEntitySelected(e)) { @@ -1707,13 +1672,14 @@ void GameWorldView::drawStations(QPainter& painter) // HP bar below footprint. if (h.maxHp > 0.0f) { - drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0, + drawHpBar(painter, coordinates, + bboxRect.left(), bboxRect.bottom() + 1.0, bboxRect.width(), h.hp / h.maxHp, f.isEnemy); } }); } -void GameWorldView::drawShips(QPainter& painter) +void GameWorldView::drawShips(QPainter& painter, const WorldCoordinates& coordinates) { m_sim->getAdmin().forEach( @@ -1725,12 +1691,12 @@ void GameWorldView::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), @@ -1757,22 +1723,25 @@ void GameWorldView::drawShips(QPainter& painter) const qreal barW = static_cast(fwd) * 2.0; const qreal barX = center.x() - static_cast(fwd); const qreal barY = center.y() + static_cast(fwd) + 1.0; - drawHpBar(painter, barX, barY, barW, h.hp / h.maxHp, fac.isEnemy); + drawHpBar(painter, coordinates, barX, barY, barW, + h.hp / h.maxHp, fac.isEnemy); } }); } -void GameWorldView::drawHpBar(QPainter& painter, qreal left, qreal top, qreal width, +void GameWorldView::drawHpBar(QPainter& painter, const WorldCoordinates& coordinates, + qreal left, qreal top, qreal width, float fraction, bool isEnemy) { - const qreal barH = static_cast(getTilePx()) * 0.12; + const qreal barH = static_cast(coordinates.getTilePx()) * 0.12; const float clamped = std::max(0.0f, fraction); painter.fillRect(QRectF(left, top, width, barH), QColor(60, 60, 60)); painter.fillRect(QRectF(left, top, width * static_cast(clamped), barH), isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); } -void GameWorldView::drawDebugSensorRanges(QPainter& painter) +void GameWorldView::drawDebugSensorRanges(QPainter& painter, + const WorldCoordinates& coordinates) { painter.setBrush(Qt::NoBrush); m_sim->getAdmin().forEachships.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)); @@ -1795,7 +1764,8 @@ void GameWorldView::drawDebugSensorRanges(QPainter& painter) }); } -void GameWorldView::drawDebugTargetLines(QPainter& painter) +void GameWorldView::drawDebugTargetLines(QPainter& painter, + const WorldCoordinates& coordinates) { // Draw a thin translucent line from a ship to a target, colored by the ship's // own schematic fill. Shared by the attack, repair and salvage target lines. @@ -1810,7 +1780,8 @@ void GameWorldView::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( @@ -1896,7 +1867,7 @@ void GameWorldView::drawDebugOverlay(QPainter& painter) } } -void GameWorldView::drawBeams(QPainter& painter) +void GameWorldView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates) { const QPainter::RenderHints savedHints = painter.renderHints(); painter.setRenderHint(QPainter::Antialiasing, true); @@ -1915,8 +1886,8 @@ void GameWorldView::drawBeams(QPainter& painter) case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break; } - const QPointF s = worldToWidget(*shooterPos); - const QPointF t = worldToWidget(*targetPos + beam.targetOffset); + const QPointF s = coordinates.worldToWidget(*shooterPos); + const QPointF t = coordinates.worldToWidget(*targetPos + beam.targetOffset); // Unit direction/perpendicular of the beam in widget space. A degenerate // zero-length beam (shooter and target coincide) has no direction to @@ -1965,7 +1936,8 @@ void GameWorldView::drawBeams(QPainter& painter) painter.setRenderHints(savedHints); } -void GameWorldView::drawSelectedTunnelConnections(QPainter& painter) +void GameWorldView::drawSelectedTunnelConnections(QPainter& painter, + const WorldCoordinates& coordinates) { if (m_selectedBuildingIds.empty()) { return; } @@ -2015,14 +1987,14 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter) const QColor green = m_visuals->overlays.tunnelPreview; for (const QPoint& tile : highlightTiles) { - painter.fillRect(tileRect(tile), green); + painter.fillRect(coordinates.tileRect(tile), green); } } -void GameWorldView::drawOverlays(QPainter& painter) +void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coordinates) { // Green connection highlight for any selected tunnel end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). - drawSelectedTunnelConnections(painter); + drawSelectedTunnelConnections(painter, coordinates); // Builder-mode ghost if (m_builderType.has_value()) @@ -2041,8 +2013,8 @@ void GameWorldView::drawOverlays(QPainter& painter) continue; } const BeltPathTile& entry = m_beltDragPath[index]; - drawBuildingGhost(painter, BuildingType::Belt, entry.tile, - entry.rotation, + drawBuildingGhost(painter, coordinates, BuildingType::Belt, + entry.tile, entry.rotation, /*valid*/ item.action != BeltTileAction::Invalid, /*showPortTargetGlyphs*/ true); } @@ -2056,7 +2028,7 @@ void GameWorldView::drawOverlays(QPainter& painter) if (inTunnelMode() && m_ghostValid && m_tunnelPartnerTile.has_value()) { const QColor green = m_visuals->overlays.tunnelPreview; - painter.fillRect(tileRect(*m_tunnelPartnerTile), green); + painter.fillRect(coordinates.tileRect(*m_tunnelPartnerTile), green); // Partner and ghost tile are colinear along the tunnel run; tint the // tiles strictly between them. @@ -2065,12 +2037,12 @@ void GameWorldView::drawOverlays(QPainter& painter) (delta.y() > 0) - (delta.y() < 0)); for (QPoint t = m_ghostTile + step; t != *m_tunnelPartnerTile; t += step) { - painter.fillRect(tileRect(t), green); + painter.fillRect(coordinates.tileRect(t), green); } } - drawBuildingGhost(painter, effectiveBuilderType(), m_ghostTile, - m_ghostRotation, m_ghostValid, + drawBuildingGhost(painter, coordinates, effectiveBuilderType(), + m_ghostTile, m_ghostRotation, m_ghostValid, /*showPortTargetGlyphs*/ true); } } @@ -2085,8 +2057,8 @@ void GameWorldView::drawOverlays(QPainter& painter) if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = m_blueprintGhostTile + bb.offset; const bool valid = isValidPlacement(bb.type, anchor, bb.rotation); - drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid, - /*showPortTargetGlyphs*/ false); + drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation, + valid, /*showPortTargetGlyphs*/ false); } } @@ -2097,7 +2069,8 @@ void GameWorldView::drawOverlays(QPainter& painter) if (!b.queuedForDeconstruction) { continue; } for (const QPoint& cell : b.bodyCells) { - painter.fillRect(tileRect(cell), m_visuals->overlays.deconstructTint); + painter.fillRect(coordinates.tileRect(cell), + m_visuals->overlays.deconstructTint); } } @@ -2117,7 +2090,8 @@ void GameWorldView::drawOverlays(QPainter& painter) { for (const QPoint& cell : *cells) { - painter.fillRect(tileRect(cell), m_visuals->overlays.deconstructTint); + painter.fillRect(coordinates.tileRect(cell), + m_visuals->overlays.deconstructTint); } } } @@ -2129,7 +2103,8 @@ void GameWorldView::drawOverlays(QPainter& painter) { for (const QPoint& cell : b->bodyCells) { - painter.fillRect(tileRect(cell), m_visuals->overlays.deconstructTint); + painter.fillRect(coordinates.tileRect(cell), + m_visuals->overlays.deconstructTint); } } } @@ -2141,14 +2116,17 @@ void GameWorldView::drawOverlays(QPainter& painter) std::min(m_boxStartTile.y(), m_boxCurrentTile.y())); const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1, std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1); - const QRectF selRect(tileToWidget(tl), tileToWidget(br)); + const QRectF selRect(coordinates.tileToWidget(tl), + coordinates.tileToWidget(br)); painter.setPen(QPen(m_visuals->overlays.selectionRect, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(selRect); } } -void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, +void GameWorldView::drawBuildingGhost(QPainter& painter, + const WorldCoordinates& coordinates, + BuildingType type, QPoint anchorTile, Rotation rotation, bool valid, bool showPortTargetGlyphs) { @@ -2179,17 +2157,17 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, QPoint maxCell = parsed.bodyCells.front(); for (const QPoint& cell : parsed.bodyCells) { - painter.fillRect(tileRect(anchorTile + cell), fillColor); + painter.fillRect(coordinates.tileRect(anchorTile + cell), fillColor); minCell.setX(std::min(minCell.x(), cell.x())); minCell.setY(std::min(minCell.y(), cell.y())); maxCell.setX(std::max(maxCell.x(), cell.x())); maxCell.setY(std::max(maxCell.y(), cell.y())); } - const QPointF tl = tileToWidget(anchorTile + minCell); + const QPointF tl = coordinates.tileToWidget(anchorTile + minCell); const QRectF bboxRect(tl.x(), tl.y(), - (maxCell.x() - minCell.x() + 1) * static_cast(getTilePx()), - (maxCell.y() - minCell.y() + 1) * static_cast(getTilePx())); + (maxCell.x() - minCell.x() + 1) * static_cast(coordinates.getTilePx()), + (maxCell.y() - minCell.y() + 1) * static_cast(coordinates.getTilePx())); painter.setPen(QPen(lineColor, 1)); painter.setBrush(Qt::NoBrush); @@ -2197,7 +2175,8 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, // Icon glyph over the ghost fill (REQ-UI-WORLD-ICON); an invalid ghost's fill // is the red invalid color, which auto-contrasts to a white icon. - if (!drawBuildingIcon(painter, type, bboxRect, fillColor) && !bv.glyph.isEmpty()) + if (!drawBuildingIcon(painter, coordinates, type, bboxRect, fillColor) + && !bv.glyph.isEmpty()) { painter.setPen(lineColor); painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); @@ -2205,7 +2184,8 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, for (const Port& port : parsed.outputPorts) { - drawPortGlyph(painter, anchorTile + outputBodyTile(port.tile, port.direction), + drawPortGlyph(painter, coordinates, + anchorTile + outputBodyTile(port.tile, port.direction), port.direction, lineColor, /*centered*/ false); } @@ -2221,8 +2201,8 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, { for (const Port& port : parsed.outputPorts) { - drawPortGlyph(painter, anchorTile + port.tile, port.direction, - lineColor, /*centered*/ true); + drawPortGlyph(painter, coordinates, anchorTile + port.tile, + port.direction, lineColor, /*centered*/ true); } } @@ -2483,6 +2463,8 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event) void GameWorldView::mousePressEvent(QMouseEvent* event) { + const WorldCoordinates coordinates = getCoordinates(); + if (event->button() != Qt::LeftButton) { if (event->button() == Qt::RightButton) @@ -2507,7 +2489,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) { // Shift + right-click copies a building's settings, but only in the // default selection mode (REQ-BLD-COPY-CONFIG). - const QPoint tile = widgetToTile(event->pos()); + const QPoint tile = coordinates.widgetToTile(event->pos()); std::optional id = buildingAtTile(tile); if (!id.has_value()) { id = siteAtTile(tile); } if (id.has_value()) { copyConfigFrom(*id); } @@ -2516,7 +2498,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) return; } - const QPoint tile = widgetToTile(event->pos()); + const QPoint tile = coordinates.widgetToTile(event->pos()); if (m_builderType.has_value()) { @@ -2527,7 +2509,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) // is placed until release (REQ-BLD-BELT-DRAG). m_dragging = true; m_beltDragAnchor = tile; - m_cursorWorldPos = widgetToWorld(event->pos()); + m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); recomputeBeltDragPath(tile); } else @@ -2564,7 +2546,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) } const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0; - const QVector2D worldPos = widgetToWorld(event->pos()); + const QVector2D worldPos = coordinates.widgetToWorld(event->pos()); // Point hit-test precedence: buildings win over actors, which win over debris // (REQ-UI-SELECTION-CATEGORIES). @@ -2690,8 +2672,9 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) void GameWorldView::mouseMoveEvent(QMouseEvent* event) { - const QPoint tile = widgetToTile(event->pos()); - m_cursorWorldPos = widgetToWorld(event->pos()); + const WorldCoordinates coordinates = getCoordinates(); + const QPoint tile = coordinates.widgetToTile(event->pos()); + m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); if (m_builderType.has_value()) { diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index a211668..45523f3 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -49,6 +49,7 @@ #include "TickDriver.h" #include "TunnelCompletion.h" #include "VisualsConfig.h" +#include "WorldCoordinates.h" struct Command; struct ParsedReplay; @@ -129,28 +130,32 @@ 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); + // World-space drawing takes the frame's transform (see getCoordinates()) rather + // than reaching for the scroll position and viewport size itself; the + // screen-space draws below need neither. + void drawTiles(QPainter& painter, const WorldCoordinates& coordinates); + void drawPortItems(QPainter& painter, const WorldCoordinates& coordinates); + void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates); + void drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates); + void drawCopyConfigFeedback(QPainter& painter, const WorldCoordinates& coordinates); + void drawStations(QPainter& painter, const WorldCoordinates& coordinates); + void drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates); // 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, + void drawDebris(QPainter& painter, const WorldCoordinates& coordinates); + void drawShips(QPainter& painter, const WorldCoordinates& coordinates); + void drawHpBar(QPainter& painter, const WorldCoordinates& coordinates, + qreal left, qreal top, qreal width, float fraction, bool isEnemy); - void drawDebugSensorRanges(QPainter& painter); - void drawDebugTargetLines(QPainter& painter); + void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates); + void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates); void drawDebugOverlay(QPainter& painter); - void drawBeams(QPainter& painter); - void drawOverlays(QPainter& painter); + void drawBeams(QPainter& painter, const WorldCoordinates& coordinates); + void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates); void drawScreenSpace(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 @@ -166,20 +171,16 @@ 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; + // 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; + // 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 footprintWidgetRect(BuildingId id) const; + std::optional footprintWidgetRect(const WorldCoordinates& coordinates, + BuildingId id) const; float getAsteroidLeftEdge() const; float getEnemyStationRightEdge() const; @@ -193,13 +194,13 @@ private: // Ids of all buildings and construction sites whose footprint intersects // the tile box spanned by the two (unordered) corner tiles. std::vector buildingsInBox(QPoint cornerA, QPoint cornerB) const; - QVector2D widgetToWorld(QPoint widgetPt) const; - void drawPortGlyph(QPainter& painter, QPoint tile, - Rotation direction, const QColor& color, + void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates, + QPoint tile, Rotation direction, const QColor& color, bool centered); - void drawBuildingGhost(QPainter& painter, BuildingType type, + void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates, + BuildingType type, QPoint anchorTile, Rotation rotation, bool valid, bool showPortTargetGlyphs); @@ -213,7 +214,8 @@ private: // 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, + bool drawBuildingIcon(QPainter& painter, const WorldCoordinates& coordinates, + BuildingType type, const QRectF& box, const QColor& fill) const; void placeBlueprintAtTile(QPoint center); @@ -254,7 +256,8 @@ private: 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); + void drawSelectedTunnelConnections(QPainter& painter, + const WorldCoordinates& coordinates); // Belt drag placement (REQ-BLD-BELT-DRAG). // Per-path-tile decision, shared by ghost drawing and release-time placement.