share the world shapes both views draw identically

Groundwork for the WorldRenderer extraction: lift the shapes GameWorldView and
ArenaView already draw the same way, before writing a renderer around them.

Four of them turned out to be identical down to the constants - the ship
triangle, the health bar, the debris marker and the sensor-range circle. The
health bar was written three times: once in GameWorldView::drawHpBar and inlined
twice in ArenaView, for stations and for ships.

The argument for sharing is not de-duplication - it is about fifty lines. It is
that the arena exists to eyeball combat, so it only does its job while a ship
there looks like a ship in the game. Retuning the ship shape and having the
balancing tool silently keep the old one is a quiet way to make the tool lie.

Deliberately narrow. The two views differ on selection highlights, beams, target
lines, and all of the factory rendering, and those stay where they are; the
arena has already diverged on some of that and nobody minded, which is a reason
to keep the shared set to shapes that are genuinely the same rather than to
aspire to more. Free functions over explicit values, no state and no simulation,
so each view keeps its own iteration and layer order.

The balancing target does not link the ui library, so it compiles
WorldPrimitives into itself - the mechanism already used for VisualsLoader and
ShipStatsPanel.

Two shared getters come with it: the ship's forward extent, which the selection
ring and health bar are positioned from, and the debris radius, which the debris
selection ring previously tracked via a comment saying "matching drawDebris".

Behaviour is unchanged. WorldRenderer is next and will be built on these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-05 17:56:50 +02:00
parent 119d13ba1f
commit 286116d2b1
8 changed files with 198 additions and 102 deletions

View File

@@ -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<X>(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`:

View File

@@ -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<qreal>(r), static_cast<qreal>(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<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),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(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<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](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<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-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<qreal>(fwd) * 2.0;
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));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
const qreal barW = static_cast<qreal>(forward) * 2.0;
const qreal barX = center.x() - static_cast<qreal>(forward);
const qreal barY = center.y() + static_cast<qreal>(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<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](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<qreal>(sensor.value_tiles)
* static_cast<qreal>(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);
});
}

View File

@@ -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
)

View File

@@ -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

View File

@@ -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<qreal>(coordinates.getTilePx() * 0.2f) + 3.0;
static_cast<qreal>(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<qreal>(r), static_cast<qreal>(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<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](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<qreal>(dir.x() * fwd),
center.y() + static_cast<qreal>(dir.y() * fwd))
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
center.y() + static_cast<qreal>(-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<qreal>(fwd) + 2.0;
const qreal r = static_cast<qreal>(forward) + 2.0;
painter.drawEllipse(center, r, r);
}
if (h.maxHp > 0.0f)
{
const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
drawHpBar(painter, coordinates, barX, barY, barW,
const qreal barW = static_cast<qreal>(forward) * 2.0;
const qreal barX = center.x() - static_cast<qreal>(forward);
const qreal barY = center.y() + static_cast<qreal>(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<qreal>(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<qreal>(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<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, SensorRangeComponent>(
[&](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<qreal>(sensor.value_tiles)
* static_cast<qreal>(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);
});
}

View File

@@ -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);

101
src/ui/WorldPrimitives.cpp Normal file
View File

@@ -0,0 +1,101 @@
#include "WorldPrimitives.h"
#include <algorithm>
#include <cmath>
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#include <QRectF>
#include <QVector2D>
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<qreal>(direction.x() * forward),
center.y() + static_cast<qreal>(direction.y() * forward))
<< QPointF(center.x() + static_cast<qreal>(perpendicular.x() * side - direction.x() * side),
center.y() + static_cast<qreal>(perpendicular.y() * side - direction.y() * side))
<< QPointF(center.x() + static_cast<qreal>(-perpendicular.x() * side - direction.x() * side),
center.y() + static_cast<qreal>(-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<qreal>(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<qreal>(clamped), height),
isEnemy ? kHealthBarEnemy : kHealthBarPlayer);
}
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
QPointF center)
{
const qreal radius = static_cast<qreal>(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<qreal>(range_tiles)
* static_cast<qreal>(coordinates.getTilePx());
QColor circleColor = shipOutline;
circleColor.setAlpha(kSensorRangeAlpha);
painter.setPen(QPen(circleColor, 1));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
}

50
src/ui/WorldPrimitives.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <QColor>
#include <QPointF>
#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);