use WorldCoordinates in ArenaView too
ArenaView carried its own copy of the same five transforms. It differs from the game view in exactly two respects: the arena is a fixed world shown whole, so the tile size is the tighter of the two axis fits rather than the height fit, and there is no scrolling, so the left edge is always 0. That is small enough to absorb into WorldCoordinates as a second named factory. The raw constructor becomes private and both call sites go through scrolling(...) or fitToWorld(...), which also reads better than three positional ints at the call site. ArenaView is threaded the same way GameWorldView was: paintGL builds one snapshot, every draw takes a const WorldCoordinates&, and mousePressEvent takes its own. ArenaView::widgetToWorld guarded against a near-zero tile size; that guard now lives in WorldCoordinates as a tilePx fallback to 1.0, alongside the existing degenerate-world-size fallback, so both factories are total and no conversion can divide by zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
@@ -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<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.
|
||||
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
|
||||
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
|
||||
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
|
||||
|
||||
### Culling
|
||||
|
||||
@@ -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<float>(height()) / static_cast<float>(totalHeight);
|
||||
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
|
||||
return std::min(pxPerTileH, pxPerTileW);
|
||||
}
|
||||
|
||||
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
|
||||
{
|
||||
return QPointF(
|
||||
static_cast<qreal>(worldPos.x() * getTilePx()),
|
||||
static_cast<qreal>(worldPos.y() * getTilePx()));
|
||||
}
|
||||
|
||||
QPointF ArenaView::tileToWidget(QPoint tile) const
|
||||
{
|
||||
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
||||
static_cast<float>(tile.y())));
|
||||
}
|
||||
|
||||
QRectF ArenaView::tileRect(QPoint tile) const
|
||||
{
|
||||
const QPointF tl = tileToWidget(tile);
|
||||
return QRectF(tl.x(), tl.y(),
|
||||
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
|
||||
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
|
||||
}
|
||||
|
||||
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||
@@ -236,19 +217,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
||||
}
|
||||
|
||||
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
|
||||
{
|
||||
const float px = getTilePx();
|
||||
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
|
||||
return QVector2D(static_cast<float>(widgetPt.x()) / px,
|
||||
static_cast<float>(widgetPt.y()) / px);
|
||||
}
|
||||
|
||||
void ArenaView::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
{
|
||||
const QVector2D worldPos = widgetToWorld(event->pos());
|
||||
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
|
||||
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||
|
||||
if (hit != entt::null)
|
||||
@@ -287,7 +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<qreal>(getTilePx()),
|
||||
b.footprint.height() * static_cast<qreal>(getTilePx()));
|
||||
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||
|
||||
painter.setPen(QPen(bv.outline, 1));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
@@ -337,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<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[&](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<qreal>(getTilePx()),
|
||||
sb.footprint.height() * static_cast<qreal>(getTilePx()));
|
||||
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||
|
||||
painter.setPen(QPen(bv.outline, 1));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
@@ -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<qreal>(getTilePx()) * 0.12;
|
||||
const qreal barH = static_cast<qreal>(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<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||||
FactionComponent, HealthComponent>(
|
||||
@@ -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<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 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 barY = center.y() + static_cast<qreal>(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<qreal>(getTilePx()) * 0.55;
|
||||
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
|
||||
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawEllipse(center, radius, radius);
|
||||
@@ -452,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<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
||||
@@ -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<qreal>(sensor.value_tiles)
|
||||
* static_cast<qreal>(getTilePx());
|
||||
* static_cast<qreal>(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().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)
|
||||
{
|
||||
@@ -552,7 +528,7 @@ void ArenaView::drawBeams(QPainter& painter)
|
||||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||||
}
|
||||
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
||||
painter.drawLine(worldToWidget(*shooterPos),
|
||||
worldToWidget(*targetPos + beam.targetOffset));
|
||||
painter.drawLine(coordinates.worldToWidget(*shooterPos),
|
||||
coordinates.worldToWidget(*targetPos + beam.targetOffset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
#include "VisualsConfig.h"
|
||||
#include "WorldCoordinates.h"
|
||||
|
||||
class ArenaSimulation;
|
||||
class QPainter;
|
||||
@@ -47,22 +48,22 @@ private slots:
|
||||
private:
|
||||
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
||||
|
||||
void drawTiles(QPainter& painter);
|
||||
void drawBuildings(QPainter& painter);
|
||||
void drawStations(QPainter& painter);
|
||||
void drawDebris(QPainter& painter);
|
||||
void drawShips(QPainter& painter);
|
||||
void drawDebugSensorRanges(QPainter& painter);
|
||||
void drawDebugTargetLines(QPainter& painter);
|
||||
void drawBeams(QPainter& painter);
|
||||
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
|
||||
float getTilePx() const;
|
||||
QPointF worldToWidget(QVector2D worldPos) const;
|
||||
QPointF tileToWidget(QPoint tile) const;
|
||||
QRectF tileRect(QPoint tile) const;
|
||||
// The world <-> widget transform for the current viewport size. The arena
|
||||
// shows the whole world at once and never scrolls, so this fits the arena's
|
||||
// full extent into the widget; like GameWorldView's, it is a per-frame
|
||||
// snapshot rather than cached state.
|
||||
WorldCoordinates getCoordinates() const;
|
||||
|
||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||
QVector2D widgetToWorld(QPoint widgetPt) const;
|
||||
|
||||
struct ActiveBeam
|
||||
{
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
#include "WorldCoordinates.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<float>(widgetSize_px.height())
|
||||
/ static_cast<float>(worldHeight_tiles);
|
||||
tilePx = static_cast<float>(widgetSize_px.height())
|
||||
/ static_cast<float>(worldHeight_tiles);
|
||||
}
|
||||
m_viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / m_tilePx;
|
||||
m_viewLeftTiles = viewCenterX_tiles - m_viewportWidthTiles / 2.0f;
|
||||
tilePx = sanitizeTilePx(tilePx);
|
||||
|
||||
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
|
||||
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||
viewCenterX_tiles - viewportWidthTiles / 2.0f,
|
||||
worldHeight_tiles);
|
||||
}
|
||||
|
||||
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
|
||||
int worldWidth_tiles,
|
||||
int worldHeight_tiles)
|
||||
{
|
||||
float tilePx = 1.0f;
|
||||
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
|
||||
{
|
||||
// The tighter of the two fits, so the whole world stays on screen.
|
||||
tilePx = std::min(
|
||||
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
|
||||
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
|
||||
}
|
||||
tilePx = sanitizeTilePx(tilePx);
|
||||
|
||||
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||
0.0f, worldHeight_tiles);
|
||||
}
|
||||
|
||||
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
|
||||
float viewLeft_tiles, int worldHeight_tiles)
|
||||
: m_tilePx(tilePx)
|
||||
, m_viewportWidthTiles(viewportWidth_px / tilePx)
|
||||
, m_viewLeftTiles(viewLeft_tiles)
|
||||
, m_worldHeightTiles(worldHeight_tiles)
|
||||
{
|
||||
}
|
||||
|
||||
float WorldCoordinates::getTilePx() const
|
||||
|
||||
@@ -8,28 +8,37 @@
|
||||
#include <QVector2D>
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user