diff --git a/docs/architecture.md b/docs/architecture.md index 6b611a0..2c5611f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -371,6 +371,8 @@ The renderer iterates only entities and tiles whose world X lies within the visi Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw(painter, entity)` functions of the UI and are not expected to change frequently. +The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same. + Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it. Sketch of `visuals.toml`: diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index 8129ca4..41d9913 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -30,6 +30,7 @@ #include "ShipIdentityComponent.h" #include "StationBodyComponent.h" #include "DebrisComponent.h" +#include "WorldPrimitives.h" namespace { @@ -312,14 +313,10 @@ void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordin void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates) { - const float r = coordinates.getTilePx() * 0.2f; for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) { - const QPointF center = coordinates.worldToWidget(debris.position); - painter.setBrush(QColor(128, 110, 90)); - painter.setPen(QPen(QColor(50, 40, 30), 1)); - painter.drawEllipse(center, - static_cast(r), static_cast(r)); + drawDebrisMarker(painter, coordinates, + coordinates.worldToWidget(debris.position)); } } @@ -353,14 +350,9 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina if (h.maxHp > 0.0f) { - const float fraction = std::max(0.0f, h.hp / h.maxHp); - const qreal barH = static_cast(coordinates.getTilePx()) * 0.12; - const qreal barY = bboxRect.bottom() + 1.0; - const qreal barW = bboxRect.width(); - painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH), - QColor(60, 60, 60)); - painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast(fraction), barH), - f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); + drawHealthBar(painter, coordinates, bboxRect.left(), + bboxRect.bottom() + 1.0, bboxRect.width(), + h.hp / h.maxHp, f.isEnemy); } if (m_selectedEntity.has_value() && *m_selectedEntity == e) @@ -374,6 +366,8 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates) { + const float forward = getShipForwardExtentPx(coordinates); + m_sim->getAdmin().forEach( [&](entt::entity e, const ShipIdentityComponent& si, @@ -385,34 +379,16 @@ void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates if (it == m_visuals->ships.end()) { return; } 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 = coordinates.getTilePx() * 0.45f; - const float side = coordinates.getTilePx() * 0.25f; - - QPolygonF tri; - tri << QPointF(center.x() + static_cast(dir.x() * fwd), - center.y() + static_cast(dir.y() * fwd)) - << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), - center.y() + static_cast(perp.y() * side - dir.y() * side)) - << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), - center.y() + static_cast(-perp.y() * side - dir.y() * side)); - - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(it->second.fill); - painter.drawPolygon(tri); + drawShipBody(painter, coordinates, center, facing.radians, + it->second.fill, it->second.outline); if (h.maxHp > 0.0f) { - const float fraction = std::max(0.0f, h.hp / h.maxHp); - const qreal barW = static_cast(fwd) * 2.0; - const qreal barH = static_cast(coordinates.getTilePx()) * 0.12; - const qreal barX = center.x() - static_cast(fwd); - const qreal barY = center.y() + static_cast(fwd) + 1.0; - painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60)); - painter.fillRect(QRectF(barX, barY, barW * static_cast(fraction), barH), - fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); + const qreal barW = static_cast(forward) * 2.0; + const qreal barX = center.x() - static_cast(forward); + const qreal barY = center.y() + static_cast(forward) + 1.0; + drawHealthBar(painter, coordinates, barX, barY, barW, + h.hp / h.maxHp, fac.isEnemy); } if (m_selectedEntity.has_value() && *m_selectedEntity == e) @@ -428,7 +404,6 @@ void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates void ArenaView::drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates) { - painter.setBrush(Qt::NoBrush); m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const SensorRangeComponent& sensor) @@ -437,13 +412,9 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter, m_visuals->ships.find(si.schematicId); if (it == m_visuals->ships.end()) { return; } - const QPointF center = coordinates.worldToWidget(pos.value); - const qreal radiusPx = static_cast(sensor.value_tiles) - * static_cast(coordinates.getTilePx()); - QColor circleColor = it->second.outline; - circleColor.setAlpha(77); - painter.setPen(QPen(circleColor, 1)); - painter.drawEllipse(center, radiusPx, radiusPx); + drawSensorRange(painter, coordinates, + coordinates.worldToWidget(pos.value), + sensor.value_tiles, it->second.outline); }); } diff --git a/src/balancing/CMakeLists.txt b/src/balancing/CMakeLists.txt index 54f99a7..fd0ad65 100644 --- a/src/balancing/CMakeLists.txt +++ b/src/balancing/CMakeLists.txt @@ -9,6 +9,10 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h + # Shared world-space shapes so the arena keeps looking like the game + # (see WorldPrimitives.h). The balancing target does not link the ui library, + # so the few ui files it needs are compiled into it, as above. + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.h PARENT_SCOPE ) @@ -23,5 +27,6 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp PARENT_SCOPE ) diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 3a88f38..08bb948 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -7,6 +7,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h + ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h @@ -30,6 +31,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 755e82c..3f05903 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -66,6 +66,7 @@ #include "StationBodyComponent.h" #include "DebrisComponent.h" #include "SurfaceMask.h" +#include "WorldPrimitives.h" #include "Tick.h" #include "TunnelCompletion.h" #include "EscapeMenuRequestedEvent.h" @@ -1331,7 +1332,7 @@ void GameWorldView::drawBuildings(QPainter& painter, const WorldCoordinates& coo { if (h.maxHp > 0.0f) { - drawHpBar(painter, coordinates, + drawHealthBar(painter, coordinates, bboxRect.left(), bboxRect.bottom() + 1.0, bboxRect.width(), h.hp / h.maxHp, f.isEnemy); } @@ -1384,11 +1385,11 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter, } // A ring around each selected piece of debris, sitting just outside the debris's - // rendered circle (radius getTilePx()*0.2, matching drawDebris) (REQ-UI-DEBRIS-CLICK-SELECT). + // own rendered circle (REQ-UI-DEBRIS-CLICK-SELECT). if (!m_selection.getSelectedDebris().empty()) { const qreal outlineRadius = - static_cast(coordinates.getTilePx() * 0.2f) + 3.0; + static_cast(getDebrisRadiusPx(coordinates)) + 3.0; for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) { if (!m_selection.isDebrisSelected(debris.entity)) { continue; } @@ -1534,14 +1535,10 @@ void GameWorldView::drawBeltItems(QPainter& painter, const WorldCoordinates& coo void GameWorldView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates) { - const float r = coordinates.getTilePx() * 0.2f; for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) { - const QPointF center = coordinates.worldToWidget(debris.position); - painter.setBrush(QColor(128, 110, 90)); - painter.setPen(QPen(QColor(50, 40, 30), 1)); - painter.drawEllipse(center, - static_cast(r), static_cast(r)); + drawDebrisMarker(painter, coordinates, + coordinates.worldToWidget(debris.position)); } } @@ -1589,7 +1586,7 @@ void GameWorldView::drawStations(QPainter& painter, const WorldCoordinates& coor // HP bar below footprint. if (h.maxHp > 0.0f) { - drawHpBar(painter, coordinates, + drawHealthBar(painter, coordinates, bboxRect.left(), bboxRect.bottom() + 1.0, bboxRect.width(), h.hp / h.maxHp, f.isEnemy); } @@ -1598,6 +1595,8 @@ void GameWorldView::drawStations(QPainter& painter, const WorldCoordinates& coor void GameWorldView::drawShips(QPainter& painter, const WorldCoordinates& coordinates) { + const float forward = getShipForwardExtentPx(coordinates); + m_sim->getAdmin().forEach( [&](entt::entity e, const ShipIdentityComponent& si, @@ -1609,58 +1608,31 @@ void GameWorldView::drawShips(QPainter& painter, const WorldCoordinates& coordin if (it == m_visuals->ships.end()) { return; } 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 = coordinates.getTilePx() * 0.45f; - const float side = coordinates.getTilePx() * 0.25f; - - QPolygonF tri; - tri << QPointF(center.x() + static_cast(dir.x() * fwd), - center.y() + static_cast(dir.y() * fwd)) - << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), - center.y() + static_cast(perp.y() * side - dir.y() * side)) - << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), - center.y() + static_cast(-perp.y() * side - dir.y() * side)); - - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(it->second.fill); - painter.drawPolygon(tri); + drawShipBody(painter, coordinates, center, facing.radians, + it->second.fill, it->second.outline); if (m_selection.isActorSelected(e)) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); - const qreal r = static_cast(fwd) + 2.0; + const qreal r = static_cast(forward) + 2.0; painter.drawEllipse(center, r, r); } if (h.maxHp > 0.0f) { - 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, coordinates, barX, barY, barW, - h.hp / h.maxHp, fac.isEnemy); + const qreal barW = static_cast(forward) * 2.0; + const qreal barX = center.x() - static_cast(forward); + const qreal barY = center.y() + static_cast(forward) + 1.0; + drawHealthBar(painter, coordinates, barX, barY, barW, + h.hp / h.maxHp, fac.isEnemy); } }); } -void GameWorldView::drawHpBar(QPainter& painter, const WorldCoordinates& coordinates, - qreal left, qreal top, qreal width, - float fraction, bool isEnemy) -{ - 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, const WorldCoordinates& coordinates) { - painter.setBrush(Qt::NoBrush); m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& si, @@ -1671,13 +1643,9 @@ void GameWorldView::drawDebugSensorRanges(QPainter& painter, m_visuals->ships.find(si.schematicId); if (it == m_visuals->ships.end()) { return; } - const QPointF center = coordinates.worldToWidget(pos.value); - const qreal radiusPx = static_cast(sensor.value_tiles) - * static_cast(coordinates.getTilePx()); - QColor circleColor = it->second.outline; - circleColor.setAlpha(77); - painter.setPen(QPen(circleColor, 1)); - painter.drawEllipse(center, radiusPx, radiusPx); + drawSensorRange(painter, coordinates, + coordinates.worldToWidget(pos.value), + sensor.value_tiles, it->second.outline); }); } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 79b00d2..79486e0 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -176,9 +176,6 @@ private: QPointF center, float halfPx); 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, const WorldCoordinates& coordinates); void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates); void drawDebugOverlay(QPainter& painter); diff --git a/src/ui/WorldPrimitives.cpp b/src/ui/WorldPrimitives.cpp new file mode 100644 index 0000000..9019300 --- /dev/null +++ b/src/ui/WorldPrimitives.cpp @@ -0,0 +1,101 @@ +#include "WorldPrimitives.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace +{ + // Ship triangle proportions, as fractions of a tile: the nose reaches further + // than the tail corners spread, so the facing direction reads at a glance. + const float kShipForwardTileFactor = 0.45f; + const float kShipSideTileFactor = 0.25f; + + const float kDebrisRadiusTileFactor = 0.2f; + + // Health bar height as a fraction of a tile. + const qreal kHealthBarHeightTileFactor = 0.12; + + const QColor kHealthBarTrack (60, 60, 60); + const QColor kHealthBarEnemy (200, 60, 60); + const QColor kHealthBarPlayer (60, 200, 60); + + const QColor kDebrisFill (128, 110, 90); + const QColor kDebrisOutline(50, 40, 30); + + // Sensor circles are drawn faint so a crowded field stays readable. + const int kSensorRangeAlpha = 77; +} + +float getShipForwardExtentPx(const WorldCoordinates& coordinates) +{ + return coordinates.getTilePx() * kShipForwardTileFactor; +} + +float getDebrisRadiusPx(const WorldCoordinates& coordinates) +{ + return coordinates.getTilePx() * kDebrisRadiusTileFactor; +} + +void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates, + QPointF center, float facing_radians, + const QColor& fill, const QColor& outline) +{ + const QVector2D direction(std::cos(facing_radians), std::sin(facing_radians)); + const QVector2D perpendicular(-direction.y(), direction.x()); + + const float forward = getShipForwardExtentPx(coordinates); + const float side = coordinates.getTilePx() * kShipSideTileFactor; + + QPolygonF triangle; + triangle + << QPointF(center.x() + static_cast(direction.x() * forward), + center.y() + static_cast(direction.y() * forward)) + << QPointF(center.x() + static_cast(perpendicular.x() * side - direction.x() * side), + center.y() + static_cast(perpendicular.y() * side - direction.y() * side)) + << QPointF(center.x() + static_cast(-perpendicular.x() * side - direction.x() * side), + center.y() + static_cast(-perpendicular.y() * side - direction.y() * side)); + + painter.setPen(QPen(outline, 1)); + painter.setBrush(fill); + painter.drawPolygon(triangle); +} + +void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates, + qreal left, qreal top, qreal width, float fraction, bool isEnemy) +{ + const qreal height = static_cast(coordinates.getTilePx()) + * kHealthBarHeightTileFactor; + const float clamped = std::max(0.0f, fraction); + + painter.fillRect(QRectF(left, top, width, height), kHealthBarTrack); + painter.fillRect(QRectF(left, top, width * static_cast(clamped), height), + isEnemy ? kHealthBarEnemy : kHealthBarPlayer); +} + +void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates, + QPointF center) +{ + const qreal radius = static_cast(getDebrisRadiusPx(coordinates)); + painter.setBrush(kDebrisFill); + painter.setPen(QPen(kDebrisOutline, 1)); + painter.drawEllipse(center, radius, radius); +} + +void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates, + QPointF center, float range_tiles, const QColor& shipOutline) +{ + const qreal radius = static_cast(range_tiles) + * static_cast(coordinates.getTilePx()); + QColor circleColor = shipOutline; + circleColor.setAlpha(kSensorRangeAlpha); + + painter.setPen(QPen(circleColor, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(center, radius, radius); +} diff --git a/src/ui/WorldPrimitives.h b/src/ui/WorldPrimitives.h new file mode 100644 index 0000000..e786889 --- /dev/null +++ b/src/ui/WorldPrimitives.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include "WorldCoordinates.h" + +class QPainter; + +// The handful of world-space shapes the game view and the balancing tool's arena +// view draw identically: a ship, its health bar, a piece of debris, a sensor range. +// +// Shared because the arena exists to eyeball combat, which only works while a ship +// there looks like a ship in the game — if these drift, the balancing tool quietly +// stops representing what it is measuring. Deliberately kept to shapes that are +// genuinely the same in both: the two views differ on selection highlights, beams +// and target lines, and those stay with each view. +// +// Free functions taking explicit values, with no state and no simulation: each +// view keeps its own iteration and layer order. Sizes derive from the tile size so +// everything scales with the view (REQ-GW-TILE-SIZE). + +// Distance from a ship's centre to its nose, in pixels. The selection ring and the +// health bar are positioned from this, so callers need it whether or not they are +// the ones drawing the body. +float getShipForwardExtentPx(const WorldCoordinates& coordinates); + +// Radius of a piece of debris, in pixels. Shared so the selection ring around +// debris cannot drift from the debris it is meant to circle. +float getDebrisRadiusPx(const WorldCoordinates& coordinates); + +// Draws a ship as a triangle at `center` (widget space), pointing along +// `facing_radians`. +void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates, + QPointF center, float facing_radians, + const QColor& fill, const QColor& outline); + +// Draws a health bar: a dark track with `fraction` of it filled, red for an enemy +// and green for the player. A negative fraction is clamped to empty. +void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates, + qreal left, qreal top, qreal width, float fraction, bool isEnemy); + +// Draws a piece of debris as a small brown circle at `center` (widget space). +void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates, + QPointF center); + +// Draws a ship's sensor range as a faint circle in the ship's own outline colour, +// so overlapping ranges stay distinguishable. +void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates, + QPointF center, float range_tiles, const QColor& shipOutline);