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
|
### Coordinates and Scrolling
|
||||||
|
|
||||||
- `GameWorldView` holds a continuous `scrollXTiles` (float), the world X at the *center* of the viewport. A / D input pans this smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED).
|
- `GameWorldView` holds a continuous `scrollXTiles` (float), the world X at the *center* of the viewport. A / D input pans this smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED).
|
||||||
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value built from the viewport size, the world height, and the scroll center; `tilePx` is derived so the world height fills the viewport (REQ-GW-TILE-SIZE) rather than being fixed. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
|
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
|
||||||
- `GameWorldView::getCoordinates()` builds one per frame in `paintGL` and per event in the mouse handlers, and passes it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
|
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
|
||||||
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
|
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
|
||||||
|
|
||||||
### Culling
|
### Culling
|
||||||
|
|||||||
@@ -176,55 +176,36 @@ void ArenaView::paintGL()
|
|||||||
QPainter painter(this);
|
QPainter painter(this);
|
||||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||||
|
|
||||||
drawTiles(painter);
|
// One transform snapshot for the whole frame; every draw below reads the
|
||||||
drawBuildings(painter);
|
// viewport through it.
|
||||||
drawStations(painter);
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
drawDebris(painter);
|
|
||||||
|
drawTiles(painter, coordinates);
|
||||||
|
drawBuildings(painter, coordinates);
|
||||||
|
drawStations(painter, coordinates);
|
||||||
|
drawDebris(painter, coordinates);
|
||||||
if (m_debugDraw)
|
if (m_debugDraw)
|
||||||
{
|
{
|
||||||
drawDebugSensorRanges(painter);
|
drawDebugSensorRanges(painter, coordinates);
|
||||||
drawDebugTargetLines(painter);
|
drawDebugTargetLines(painter, coordinates);
|
||||||
}
|
}
|
||||||
drawShips(painter);
|
drawShips(painter, coordinates);
|
||||||
drawBeams(painter);
|
drawBeams(painter, coordinates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Coordinate helpers
|
// Coordinate helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
float ArenaView::getTilePx() const
|
WorldCoordinates ArenaView::getCoordinates() const
|
||||||
{
|
{
|
||||||
|
// The arena is a fixed, fully visible world — unlike the game view it has no
|
||||||
|
// scrolling, so the tile size comes from fitting the whole arena in the widget.
|
||||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||||
const int totalWidth = ac.playerBufferWidth_tiles
|
const int totalWidth = ac.playerBufferWidth_tiles
|
||||||
+ ac.contestZoneWidth_tiles
|
+ ac.contestZoneWidth_tiles
|
||||||
+ ac.enemyBufferWidth_tiles;
|
+ ac.enemyBufferWidth_tiles;
|
||||||
const int totalHeight = ac.heightTiles;
|
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
|
||||||
if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; }
|
|
||||||
|
|
||||||
const float pxPerTileH = static_cast<float>(height()) / static_cast<float>(totalHeight);
|
|
||||||
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
|
|
||||||
return std::min(pxPerTileH, pxPerTileW);
|
|
||||||
}
|
|
||||||
|
|
||||||
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
|
|
||||||
{
|
|
||||||
return QPointF(
|
|
||||||
static_cast<qreal>(worldPos.x() * getTilePx()),
|
|
||||||
static_cast<qreal>(worldPos.y() * getTilePx()));
|
|
||||||
}
|
|
||||||
|
|
||||||
QPointF ArenaView::tileToWidget(QPoint tile) const
|
|
||||||
{
|
|
||||||
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
|
||||||
static_cast<float>(tile.y())));
|
|
||||||
}
|
|
||||||
|
|
||||||
QRectF ArenaView::tileRect(QPoint tile) const
|
|
||||||
{
|
|
||||||
const QPointF tl = tileToWidget(tile);
|
|
||||||
return QRectF(tl.x(), tl.y(),
|
|
||||||
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||||
@@ -236,19 +217,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
|||||||
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
||||||
}
|
}
|
||||||
|
|
||||||
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
|
|
||||||
{
|
|
||||||
const float px = getTilePx();
|
|
||||||
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
|
|
||||||
return QVector2D(static_cast<float>(widgetPt.x()) / px,
|
|
||||||
static_cast<float>(widgetPt.y()) / px);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ArenaView::mousePressEvent(QMouseEvent* event)
|
void ArenaView::mousePressEvent(QMouseEvent* event)
|
||||||
{
|
{
|
||||||
if (event->button() == Qt::LeftButton)
|
if (event->button() == Qt::LeftButton)
|
||||||
{
|
{
|
||||||
const QVector2D worldPos = widgetToWorld(event->pos());
|
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
|
||||||
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||||
|
|
||||||
if (hit != entt::null)
|
if (hit != entt::null)
|
||||||
@@ -287,7 +260,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
|
|||||||
// Rendering
|
// Rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void ArenaView::drawTiles(QPainter& painter)
|
void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||||
const int totalWidth = ac.playerBufferWidth_tiles
|
const int totalWidth = ac.playerBufferWidth_tiles
|
||||||
@@ -300,12 +273,12 @@ void ArenaView::drawTiles(QPainter& painter)
|
|||||||
{
|
{
|
||||||
for (int y = 0; y < totalHeight; ++y)
|
for (int y = 0; y < totalHeight; ++y)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill);
|
painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawBuildings(QPainter& painter)
|
void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||||
{
|
{
|
||||||
@@ -317,13 +290,13 @@ void ArenaView::drawBuildings(QPainter& painter)
|
|||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
for (const QPoint& cell : b.bodyCells)
|
for (const QPoint& cell : b.bodyCells)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(cell), bv.fill);
|
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPointF tl = tileToWidget(b.anchor);
|
const QPointF tl = coordinates.tileToWidget(b.anchor);
|
||||||
const QRectF bboxRect(tl.x(), tl.y(),
|
const QRectF bboxRect(tl.x(), tl.y(),
|
||||||
b.footprint.width() * static_cast<qreal>(getTilePx()),
|
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||||
b.footprint.height() * static_cast<qreal>(getTilePx()));
|
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||||
|
|
||||||
painter.setPen(QPen(bv.outline, 1));
|
painter.setPen(QPen(bv.outline, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -337,12 +310,12 @@ void ArenaView::drawBuildings(QPainter& painter)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebris(QPainter& painter)
|
void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
const float r = getTilePx() * 0.2f;
|
const float r = coordinates.getTilePx() * 0.2f;
|
||||||
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
||||||
{
|
{
|
||||||
const QPointF center = worldToWidget(debris.position);
|
const QPointF center = coordinates.worldToWidget(debris.position);
|
||||||
painter.setBrush(QColor(128, 110, 90));
|
painter.setBrush(QColor(128, 110, 90));
|
||||||
painter.setPen(QPen(QColor(50, 40, 30), 1));
|
painter.setPen(QPen(QColor(50, 40, 30), 1));
|
||||||
painter.drawEllipse(center,
|
painter.drawEllipse(center,
|
||||||
@@ -350,7 +323,7 @@ void ArenaView::drawDebris(QPainter& painter)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawStations(QPainter& painter)
|
void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||||
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
||||||
@@ -366,13 +339,13 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
for (const QPoint& cell : sb.bodyCells)
|
for (const QPoint& cell : sb.bodyCells)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(cell), bv.fill);
|
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPointF tl = tileToWidget(sb.anchor);
|
const QPointF tl = coordinates.tileToWidget(sb.anchor);
|
||||||
const QRectF bboxRect(tl.x(), tl.y(),
|
const QRectF bboxRect(tl.x(), tl.y(),
|
||||||
sb.footprint.width() * static_cast<qreal>(getTilePx()),
|
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||||
sb.footprint.height() * static_cast<qreal>(getTilePx()));
|
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||||
|
|
||||||
painter.setPen(QPen(bv.outline, 1));
|
painter.setPen(QPen(bv.outline, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -381,7 +354,7 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
if (h.maxHp > 0.0f)
|
if (h.maxHp > 0.0f)
|
||||||
{
|
{
|
||||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
||||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
const qreal barH = static_cast<qreal>(coordinates.getTilePx()) * 0.12;
|
||||||
const qreal barY = bboxRect.bottom() + 1.0;
|
const qreal barY = bboxRect.bottom() + 1.0;
|
||||||
const qreal barW = bboxRect.width();
|
const qreal barW = bboxRect.width();
|
||||||
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
|
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
|
||||||
@@ -399,7 +372,7 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawShips(QPainter& painter)
|
void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||||||
FactionComponent, HealthComponent>(
|
FactionComponent, HealthComponent>(
|
||||||
@@ -411,12 +384,12 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
m_visuals->ships.find(si.schematicId);
|
m_visuals->ships.find(si.schematicId);
|
||||||
if (it == m_visuals->ships.end()) { return; }
|
if (it == m_visuals->ships.end()) { return; }
|
||||||
|
|
||||||
const QPointF center = worldToWidget(pos.value);
|
const QPointF center = coordinates.worldToWidget(pos.value);
|
||||||
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
|
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
|
||||||
const QVector2D perp(-dir.y(), dir.x());
|
const QVector2D perp(-dir.y(), dir.x());
|
||||||
|
|
||||||
const float fwd = getTilePx() * 0.45f;
|
const float fwd = coordinates.getTilePx() * 0.45f;
|
||||||
const float side = getTilePx() * 0.25f;
|
const float side = coordinates.getTilePx() * 0.25f;
|
||||||
|
|
||||||
QPolygonF tri;
|
QPolygonF tri;
|
||||||
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
|
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
|
||||||
@@ -434,7 +407,7 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
{
|
{
|
||||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
||||||
const qreal barW = static_cast<qreal>(fwd) * 2.0;
|
const qreal barW = static_cast<qreal>(fwd) * 2.0;
|
||||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
const qreal barH = static_cast<qreal>(coordinates.getTilePx()) * 0.12;
|
||||||
const qreal barX = center.x() - static_cast<qreal>(fwd);
|
const qreal barX = center.x() - static_cast<qreal>(fwd);
|
||||||
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
|
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
|
||||||
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
|
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
|
||||||
@@ -444,7 +417,7 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
|
|
||||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||||
{
|
{
|
||||||
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
|
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
|
||||||
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
painter.drawEllipse(center, radius, radius);
|
painter.drawEllipse(center, radius, radius);
|
||||||
@@ -452,7 +425,8 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
void ArenaView::drawDebugSensorRanges(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
||||||
@@ -463,9 +437,9 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
|||||||
m_visuals->ships.find(si.schematicId);
|
m_visuals->ships.find(si.schematicId);
|
||||||
if (it == m_visuals->ships.end()) { return; }
|
if (it == m_visuals->ships.end()) { return; }
|
||||||
|
|
||||||
const QPointF center = worldToWidget(pos.value);
|
const QPointF center = coordinates.worldToWidget(pos.value);
|
||||||
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
||||||
* static_cast<qreal>(getTilePx());
|
* static_cast<qreal>(coordinates.getTilePx());
|
||||||
QColor circleColor = it->second.outline;
|
QColor circleColor = it->second.outline;
|
||||||
circleColor.setAlpha(77);
|
circleColor.setAlpha(77);
|
||||||
painter.setPen(QPen(circleColor, 1));
|
painter.setPen(QPen(circleColor, 1));
|
||||||
@@ -473,7 +447,8 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebugTargetLines(QPainter& painter)
|
void ArenaView::drawDebugTargetLines(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
||||||
// team to match the per-side HQ/station colors used elsewhere in the arena
|
// team to match the per-side HQ/station colors used elsewhere in the arena
|
||||||
@@ -491,7 +466,8 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
|
|||||||
QColor lineColor = it->second.fill;
|
QColor lineColor = it->second.fill;
|
||||||
lineColor.setAlpha(128);
|
lineColor.setAlpha(128);
|
||||||
painter.setPen(QPen(lineColor, 1));
|
painter.setPen(QPen(lineColor, 1));
|
||||||
painter.drawLine(worldToWidget(from), worldToWidget(to));
|
painter.drawLine(coordinates.worldToWidget(from),
|
||||||
|
coordinates.worldToWidget(to));
|
||||||
};
|
};
|
||||||
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
||||||
@@ -536,7 +512,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawBeams(QPainter& painter)
|
void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
for (const ActiveBeam& beam : m_activeBeams)
|
for (const ActiveBeam& beam : m_activeBeams)
|
||||||
{
|
{
|
||||||
@@ -552,7 +528,7 @@ void ArenaView::drawBeams(QPainter& painter)
|
|||||||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||||||
}
|
}
|
||||||
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
||||||
painter.drawLine(worldToWidget(*shooterPos),
|
painter.drawLine(coordinates.worldToWidget(*shooterPos),
|
||||||
worldToWidget(*targetPos + beam.targetOffset));
|
coordinates.worldToWidget(*targetPos + beam.targetOffset));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "TickDriver.h"
|
#include "TickDriver.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
class ArenaSimulation;
|
class ArenaSimulation;
|
||||||
class QPainter;
|
class QPainter;
|
||||||
@@ -47,22 +48,22 @@ private slots:
|
|||||||
private:
|
private:
|
||||||
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
||||||
|
|
||||||
void drawTiles(QPainter& painter);
|
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawBuildings(QPainter& painter);
|
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawStations(QPainter& painter);
|
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebris(QPainter& painter);
|
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawShips(QPainter& painter);
|
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebugSensorRanges(QPainter& painter);
|
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebugTargetLines(QPainter& painter);
|
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawBeams(QPainter& painter);
|
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
|
|
||||||
float getTilePx() const;
|
// The world <-> widget transform for the current viewport size. The arena
|
||||||
QPointF worldToWidget(QVector2D worldPos) const;
|
// shows the whole world at once and never scrolls, so this fits the arena's
|
||||||
QPointF tileToWidget(QPoint tile) const;
|
// full extent into the widget; like GameWorldView's, it is a per-frame
|
||||||
QRectF tileRect(QPoint tile) const;
|
// snapshot rather than cached state.
|
||||||
|
WorldCoordinates getCoordinates() const;
|
||||||
|
|
||||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||||
QVector2D widgetToWorld(QPoint widgetPt) const;
|
|
||||||
|
|
||||||
struct ActiveBeam
|
struct ActiveBeam
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,21 +1,61 @@
|
|||||||
#include "WorldCoordinates.h"
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
WorldCoordinates::WorldCoordinates(QSize widgetSize_px, int worldHeight_tiles,
|
namespace
|
||||||
float viewCenterX_tiles)
|
|
||||||
: m_tilePx(1.0f)
|
|
||||||
, m_viewportWidthTiles(0.0f)
|
|
||||||
, m_viewLeftTiles(0.0f)
|
|
||||||
, m_worldHeightTiles(worldHeight_tiles)
|
|
||||||
{
|
{
|
||||||
|
// A zero-height widget, a not-yet-shown widget, or a degenerate world size
|
||||||
|
// would otherwise make every conversion divide by zero.
|
||||||
|
float sanitizeTilePx(float tilePx)
|
||||||
|
{
|
||||||
|
return tilePx > 0.0f ? tilePx : 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates WorldCoordinates::scrolling(QSize widgetSize_px,
|
||||||
|
int worldHeight_tiles,
|
||||||
|
float viewCenterX_tiles)
|
||||||
|
{
|
||||||
|
float tilePx = 1.0f;
|
||||||
if (worldHeight_tiles > 0)
|
if (worldHeight_tiles > 0)
|
||||||
{
|
{
|
||||||
m_tilePx = static_cast<float>(widgetSize_px.height())
|
tilePx = static_cast<float>(widgetSize_px.height())
|
||||||
/ static_cast<float>(worldHeight_tiles);
|
/ static_cast<float>(worldHeight_tiles);
|
||||||
}
|
}
|
||||||
m_viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / m_tilePx;
|
tilePx = sanitizeTilePx(tilePx);
|
||||||
m_viewLeftTiles = viewCenterX_tiles - m_viewportWidthTiles / 2.0f;
|
|
||||||
|
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
|
||||||
|
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||||
|
viewCenterX_tiles - viewportWidthTiles / 2.0f,
|
||||||
|
worldHeight_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
|
||||||
|
int worldWidth_tiles,
|
||||||
|
int worldHeight_tiles)
|
||||||
|
{
|
||||||
|
float tilePx = 1.0f;
|
||||||
|
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
|
||||||
|
{
|
||||||
|
// The tighter of the two fits, so the whole world stays on screen.
|
||||||
|
tilePx = std::min(
|
||||||
|
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
|
||||||
|
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
|
||||||
|
}
|
||||||
|
tilePx = sanitizeTilePx(tilePx);
|
||||||
|
|
||||||
|
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||||
|
0.0f, worldHeight_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
|
||||||
|
float viewLeft_tiles, int worldHeight_tiles)
|
||||||
|
: m_tilePx(tilePx)
|
||||||
|
, m_viewportWidthTiles(viewportWidth_px / tilePx)
|
||||||
|
, m_viewLeftTiles(viewLeft_tiles)
|
||||||
|
, m_worldHeightTiles(worldHeight_tiles)
|
||||||
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
float WorldCoordinates::getTilePx() const
|
float WorldCoordinates::getTilePx() const
|
||||||
|
|||||||
@@ -8,28 +8,37 @@
|
|||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
// Immutable snapshot of the world <-> widget transform for one viewport state
|
// Immutable snapshot of the world <-> widget transform for one viewport state
|
||||||
// (REQ-GW-COORDS, REQ-GW-TILE-SIZE). Tiles are square and sized so the world
|
// (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
|
||||||
// height exactly fills the viewport height; there is no zoom (REQ-UI-NO-ZOOM),
|
// the tile size and the left edge are derived, and everything downstream of that
|
||||||
// so the only free parameter is the horizontal scroll position.
|
// is shared.
|
||||||
//
|
//
|
||||||
// The transform is a value: it is constructed from the viewport size, the world
|
// The transform is a value: it is constructed from the viewport size and the view
|
||||||
// height, and the view center, and never observes them again. A caller therefore
|
// state, and never observes them again. A caller therefore builds one per frame
|
||||||
// builds one per frame (or per event) rather than holding one across a resize or
|
// (or per event) rather than holding one across a resize or a scroll, which would
|
||||||
// a scroll, which would silently go stale.
|
// silently go stale.
|
||||||
class WorldCoordinates
|
class WorldCoordinates
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
// `viewCenterX_tiles` is the world X at the center of the viewport, matching
|
// The scrolling game world: the tile size is whatever makes the world height
|
||||||
// how the scroll position is stored and clamped (REQ-GW-SCROLL-LIMIT).
|
// exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
|
||||||
WorldCoordinates(QSize widgetSize_px, int worldHeight_tiles,
|
// REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
|
||||||
float viewCenterX_tiles);
|
// world X at the center of the viewport, matching how the scroll position is
|
||||||
|
// stored and clamped (REQ-GW-SCROLL-LIMIT).
|
||||||
|
static WorldCoordinates scrolling(QSize widgetSize_px, int worldHeight_tiles,
|
||||||
|
float viewCenterX_tiles);
|
||||||
|
|
||||||
// Side length of one tile in pixels. Degenerate world heights yield 1.0 so
|
// A whole world shown at once with no scrolling, as the balancing tool's arena
|
||||||
// callers never divide by zero.
|
// does: the tile size is whichever axis is the tighter fit, so nothing is cut
|
||||||
|
// off, and the world origin sits at the widget's top-left. A viewport wider
|
||||||
|
// than the fitted world leaves empty space to the right rather than centering.
|
||||||
|
static WorldCoordinates fitToWorld(QSize widgetSize_px, int worldWidth_tiles,
|
||||||
|
int worldHeight_tiles);
|
||||||
|
|
||||||
|
// Side length of one tile in pixels. Always positive: a degenerate world or
|
||||||
|
// viewport size falls back to 1.0 so no conversion below divides by zero.
|
||||||
float getTilePx() const;
|
float getTilePx() const;
|
||||||
float getViewportWidthTiles() const;
|
float getViewportWidthTiles() const;
|
||||||
// World X (tiles) at the left edge of the viewport, derived from the view
|
// World X (tiles) at the left edge of the viewport.
|
||||||
// center the constructor was given.
|
|
||||||
float getViewLeftTiles() const;
|
float getViewLeftTiles() const;
|
||||||
|
|
||||||
QPointF worldToWidget(QVector2D worldPos) const;
|
QPointF worldToWidget(QVector2D worldPos) const;
|
||||||
@@ -44,6 +53,9 @@ public:
|
|||||||
QRect getViewportRect() const;
|
QRect getViewportRect() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
|
||||||
|
int worldHeight_tiles);
|
||||||
|
|
||||||
float m_tilePx;
|
float m_tilePx;
|
||||||
float m_viewportWidthTiles;
|
float m_viewportWidthTiles;
|
||||||
float m_viewLeftTiles;
|
float m_viewLeftTiles;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// 40-tile-wide view, so every expectation below is a whole number.
|
// 40-tile-wide view, so every expectation below is a whole number.
|
||||||
static WorldCoordinates makeCoordinates(float viewCenterX_tiles)
|
static WorldCoordinates makeCoordinates(float viewCenterX_tiles)
|
||||||
{
|
{
|
||||||
return WorldCoordinates(QSize(800, 400), 20, viewCenterX_tiles);
|
return WorldCoordinates::scrolling(QSize(800, 400), 20, viewCenterX_tiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -22,21 +22,34 @@ TEST_CASE("Tile size makes the world height fill the viewport height", "[coords]
|
|||||||
// REQ-GW-TILE-SIZE: tiles are square and sized so the world height exactly
|
// REQ-GW-TILE-SIZE: tiles are square and sized so the world height exactly
|
||||||
// fills the view's height.
|
// fills the view's height.
|
||||||
REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f));
|
REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f));
|
||||||
REQUIRE(WorldCoordinates(QSize(800, 600), 20, 0.0f).getTilePx() == Approx(30.0f));
|
REQUIRE(WorldCoordinates::scrolling(QSize(800, 600), 20, 0.0f).getTilePx()
|
||||||
|
== Approx(30.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("A degenerate world height falls back to a unit tile", "[coords]")
|
TEST_CASE("A degenerate world height falls back to a unit tile", "[coords]")
|
||||||
{
|
{
|
||||||
// Guards the division in every conversion; a zero or negative height would
|
// Guards the division in every conversion; a zero or negative height would
|
||||||
// otherwise produce infinities.
|
// otherwise produce infinities.
|
||||||
REQUIRE(WorldCoordinates(QSize(800, 400), 0, 0.0f).getTilePx() == Approx(1.0f));
|
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), 0, 0.0f).getTilePx()
|
||||||
REQUIRE(WorldCoordinates(QSize(800, 400), -5, 0.0f).getTilePx() == Approx(1.0f));
|
== Approx(1.0f));
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), -5, 0.0f).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A zero-size viewport falls back to a unit tile", "[coords]")
|
||||||
|
{
|
||||||
|
// A widget that has not been shown yet still has to answer conversions —
|
||||||
|
// the arena hit-tests through the same transform.
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(0, 0), 20, 0.0f).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
|
REQUIRE(WorldCoordinates::fitToWorld(QSize(0, 0), 40, 20).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Viewport width in tiles follows the widget width", "[coords]")
|
TEST_CASE("Viewport width in tiles follows the widget width", "[coords]")
|
||||||
{
|
{
|
||||||
REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f));
|
REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f));
|
||||||
REQUIRE(WorldCoordinates(QSize(400, 400), 20, 0.0f).getViewportWidthTiles()
|
REQUIRE(WorldCoordinates::scrolling(QSize(400, 400), 20, 0.0f).getViewportWidthTiles()
|
||||||
== Approx(20.0f));
|
== Approx(20.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,3 +156,48 @@ TEST_CASE("A fractional scroll position widens the viewport rect outward", "[coo
|
|||||||
REQUIRE(rect.left() == -21);
|
REQUIRE(rect.left() == -21);
|
||||||
REQUIRE(rect.right() == 21);
|
REQUIRE(rect.right() == 21);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fitted (non-scrolling) worlds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world takes the tighter of the two axis fits", "[coords]")
|
||||||
|
{
|
||||||
|
// Height-limited: 400/20 = 20 px per tile beats 800/30 = 26.67.
|
||||||
|
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 30, 20).getTilePx()
|
||||||
|
== Approx(20.0f));
|
||||||
|
// Width-limited: 800/80 = 10 px per tile beats 400/20 = 20.
|
||||||
|
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20).getTilePx()
|
||||||
|
== Approx(10.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world keeps its whole width on screen", "[coords]")
|
||||||
|
{
|
||||||
|
// The point of taking the tighter fit: the far edge must land inside the
|
||||||
|
// viewport, never past it.
|
||||||
|
const WorldCoordinates coordinates =
|
||||||
|
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(80.0f, 0.0f)).x() <= 800.0);
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 20.0f)).y() <= 400.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world puts the origin at the widget's top-left", "[coords]")
|
||||||
|
{
|
||||||
|
// No scrolling, so there is no view center to subtract.
|
||||||
|
const WorldCoordinates coordinates =
|
||||||
|
WorldCoordinates::fitToWorld(QSize(800, 400), 40, 20);
|
||||||
|
REQUIRE(coordinates.getViewLeftTiles() == Approx(0.0f));
|
||||||
|
REQUIRE(coordinates.tileToWidget(QPoint(0, 0)) == QPointF(0.0, 0.0));
|
||||||
|
REQUIRE(coordinates.tileToWidget(QPoint(3, 2)) == QPointF(60.0, 40.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world round-trips widget points back to world positions",
|
||||||
|
"[coords]")
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates =
|
||||||
|
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
|
||||||
|
const QVector2D world = coordinates.widgetToWorld(QPoint(120, 55));
|
||||||
|
const QPointF back = coordinates.worldToWidget(world);
|
||||||
|
REQUIRE(back.x() == Approx(120.0));
|
||||||
|
REQUIRE(back.y() == Approx(55.0));
|
||||||
|
}
|
||||||
|
|||||||
@@ -525,7 +525,8 @@ void GameWorldView::paintGL()
|
|||||||
|
|
||||||
WorldCoordinates GameWorldView::getCoordinates() const
|
WorldCoordinates GameWorldView::getCoordinates() const
|
||||||
{
|
{
|
||||||
return WorldCoordinates(size(), m_config->world.heightTiles, m_scrollXTiles);
|
return WorldCoordinates::scrolling(size(), m_config->world.heightTiles,
|
||||||
|
m_scrollXTiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
float GameWorldView::getAsteroidLeftEdge() const
|
float GameWorldView::getAsteroidLeftEdge() const
|
||||||
|
|||||||
Reference in New Issue
Block a user