2568 lines
93 KiB
C++
2568 lines
93 KiB
C++
#include "GameWorldView.h"
|
||
|
||
#include <algorithm>
|
||
#include <cctype>
|
||
#include <climits>
|
||
#include <cmath>
|
||
#include <functional>
|
||
#include <map>
|
||
#include <memory>
|
||
#include <string>
|
||
|
||
#include <QColor>
|
||
#include <QCoreApplication>
|
||
#include <QCursor>
|
||
#include <QDir>
|
||
#include <QFont>
|
||
#include <QKeyEvent>
|
||
#include <QLinearGradient>
|
||
#include <QMessageBox>
|
||
#include <QMouseEvent>
|
||
#include <QPainter>
|
||
#include <QPainterPath>
|
||
#include <QPen>
|
||
#include <QPolygonF>
|
||
#include <QRadialGradient>
|
||
#include <QRegion>
|
||
#include <QStringList>
|
||
#include <QTimer>
|
||
|
||
#include "AttackBehavior.h"
|
||
#include "BeltSystem.h"
|
||
#include "Building.h"
|
||
#include "BuildingSystem.h"
|
||
#include "Command.h"
|
||
#include "ReplayPlayer.h"
|
||
#include "ReplayReader.h"
|
||
#include "ReplayRecorder.h"
|
||
#include "DemolishModeChangedEvent.h"
|
||
#include "EntityHitTest.h"
|
||
#include "EntitySelectedEvent.h"
|
||
#include "EventManager.h"
|
||
#include "FacingComponent.h"
|
||
#include "FactionComponent.h"
|
||
#include "GameOverEvent.h"
|
||
#include "HealthComponent.h"
|
||
#include "HqProxyComponent.h"
|
||
#include "PositionComponent.h"
|
||
#include "RepairBehavior.h"
|
||
#include "SalvageScrapBehavior.h"
|
||
#include "ScrapSelectionChangedEvent.h"
|
||
#include "ScrapSystem.h"
|
||
#include "SelectionChangedEvent.h"
|
||
#include "SensorRangeComponent.h"
|
||
#include "ShipIdentityComponent.h"
|
||
#include "ShipSystem.h"
|
||
#include "Simulation.h"
|
||
#include "StationBodyComponent.h"
|
||
#include "ScrapDataComponent.h"
|
||
#include "SurfaceMask.h"
|
||
#include "Tick.h"
|
||
#include "EscapeMenuRequestedEvent.h"
|
||
#include "TracePrintRequestedEvent.h"
|
||
#include "BuildHotkeyPressedEvent.h"
|
||
#include "TemporaryBlueprintRequestedEvent.h"
|
||
#include "BossWaveUpdatedEvent.h"
|
||
#include "BuilderModeExitedEvent.h"
|
||
#include "BlueprintModeExitedEvent.h"
|
||
#include "BuildingBlocksChangedEvent.h"
|
||
#include "ExpansionCostChangedEvent.h"
|
||
#include "GameSpeedChangedEvent.h"
|
||
#include "SchematicChoicesAvailableEvent.h"
|
||
#include "PlayerCommandsAppliedEvent.h"
|
||
#include "TickAdvancedEvent.h"
|
||
|
||
namespace
|
||
{
|
||
|
||
// Keep only the filter entries whose item type is currently unlocked
|
||
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
|
||
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
|
||
const Simulation& sim)
|
||
{
|
||
std::vector<ItemType> result;
|
||
for (const ItemType& item : filter)
|
||
{
|
||
if (sim.isItemUnlocked(item.id)) { result.push_back(item); }
|
||
}
|
||
return result;
|
||
}
|
||
|
||
Rotation rotateClockwise(Rotation r)
|
||
{
|
||
switch (r)
|
||
{
|
||
case Rotation::North: return Rotation::East;
|
||
case Rotation::East: return Rotation::South;
|
||
case Rotation::South: return Rotation::West;
|
||
case Rotation::West: return Rotation::North;
|
||
}
|
||
return Rotation::East;
|
||
}
|
||
|
||
Rotation rotateCounterClockwise(Rotation r)
|
||
{
|
||
switch (r)
|
||
{
|
||
case Rotation::North: return Rotation::West;
|
||
case Rotation::East: return Rotation::North;
|
||
case Rotation::South: return Rotation::East;
|
||
case Rotation::West: return Rotation::South;
|
||
}
|
||
return Rotation::East;
|
||
}
|
||
|
||
QPoint portBodyTile(QPoint portTile, Rotation direction)
|
||
{
|
||
switch (direction)
|
||
{
|
||
case Rotation::East: return portTile + QPoint(-1, 0);
|
||
case Rotation::West: return portTile + QPoint( 1, 0);
|
||
case Rotation::North: return portTile + QPoint( 0, 1);
|
||
case Rotation::South: return portTile + QPoint( 0, -1);
|
||
}
|
||
return portTile;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
|
||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||
const VisualsConfig* visuals, const std::string& configDir,
|
||
const ParsedReplay* replay, QWidget* parent)
|
||
: QOpenGLWidget(parent)
|
||
, m_sim(sim)
|
||
, m_config(config)
|
||
, m_visuals(visuals)
|
||
, m_commandManager(*sim)
|
||
, m_gameSpeedMultiplier(1.0)
|
||
, m_prevNonZeroSpeed(1.0)
|
||
, m_scrollXTiles(0.0f)
|
||
, m_ghostRotation(Rotation::East)
|
||
, m_ghostValid(false)
|
||
, m_dragging(false)
|
||
, m_demolishMode(false)
|
||
, m_demolishHoverBuildingId(kInvalidBuildingId)
|
||
, m_debugDraw(false)
|
||
, m_rng(std::random_device{}())
|
||
, m_boxSelecting(false)
|
||
, m_scrollLeft(false)
|
||
, m_scrollRight(false)
|
||
, m_gameOverShown(false)
|
||
, m_schematicChoiceShown(false)
|
||
{
|
||
setFocusPolicy(Qt::StrongFocus);
|
||
setMouseTracking(true);
|
||
|
||
m_renderTimer = new QTimer(this);
|
||
m_renderTimer->setInterval(16);
|
||
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
|
||
m_renderTimer->start();
|
||
m_frameTimer.start();
|
||
|
||
registerForEvents();
|
||
|
||
if (replay)
|
||
{
|
||
// View-only playback: ignore live input and drive ticks from the recorded
|
||
// stream. No recorder (we are not creating a new run).
|
||
m_commandManager.setReplayMode(true);
|
||
m_replayPlayer = std::make_unique<ReplayPlayer>(*sim, replay->entries);
|
||
m_replayPlayer->start(); // process tick-0 entries before the first tick
|
||
}
|
||
else
|
||
{
|
||
// Record every run to disk, into a "replays" folder next to the executable.
|
||
// Uses the exe's real directory rather than traversing up from the config
|
||
// dir: with a symlinked build layout, relative traversal would resolve into
|
||
// the wrong tree. Attaching the recorder opens the first file and writes the
|
||
// header for the initial run.
|
||
const QString replayDir =
|
||
QDir(QCoreApplication::applicationDirPath()).filePath("replays");
|
||
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
|
||
configDir, replayDir.toStdString()));
|
||
}
|
||
}
|
||
|
||
GameWorldView::~GameWorldView()
|
||
{
|
||
unregisterForEvents();
|
||
}
|
||
|
||
void GameWorldView::initializeGL()
|
||
{
|
||
// QPainter handles all rendering; no custom GL state needed.
|
||
}
|
||
|
||
void GameWorldView::onFrame()
|
||
{
|
||
const qint64 elapsed = m_frameTimer.restart();
|
||
|
||
if (m_replayPlayer)
|
||
{
|
||
// Playback: apply recorded commands at their ticks and verify checksums.
|
||
// Manual speed/pause still works; playback only moves forward.
|
||
const int ticks = m_tickDriver.advance(
|
||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||
for (int i = 0; i < ticks; ++i)
|
||
{
|
||
// Stop at the recorded stream's end, or at the run's terminal state:
|
||
// the real game froze when it was won or lost, so the replay does too
|
||
// (the recording may run a few ticks past that point).
|
||
if (m_replayPlayer->isFinished()
|
||
|| m_sim->isWon() || m_sim->isGameOver()) { break; }
|
||
m_sim->tick();
|
||
m_replayPlayer->advanceTo(m_sim->getCurrentTick());
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Drain queued player commands once per frame, before the tick batch. This
|
||
// runs even at 0x so a paused player sees placed construction sites
|
||
// immediately, while staying deterministic (see docs/replay_design.md).
|
||
const bool commandsApplied = m_commandManager.hasPending();
|
||
m_commandManager.drain();
|
||
|
||
// A drained Reset reinitialized the simulation; reset the view to match.
|
||
if (m_viewResetPending)
|
||
{
|
||
m_viewResetPending = false;
|
||
resetForNewGame();
|
||
// The reset command may have drained after a long-open modal (e.g. the
|
||
// Game Over dialog), so `elapsed` above still holds the whole time that
|
||
// dialog was open. Feeding it into the tick driver would fast-forward the
|
||
// brand-new run by that duration. resetForNewGame() has rebased the time
|
||
// source; skip this frame's tick advance so the stale delta is discarded.
|
||
return;
|
||
}
|
||
|
||
// Notify presentation widgets that queued commands were applied, so a
|
||
// paused player still sees the effect (e.g. a shipyard's layout preview
|
||
// after picking a schematic) even though no tick advances. UI-only: this
|
||
// does not touch the command queue or simulation, so replay recording
|
||
// and determinism are unaffected.
|
||
if (commandsApplied)
|
||
{
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<PlayerCommandsAppliedEvent>());
|
||
}
|
||
|
||
const int ticks = m_tickDriver.advance(
|
||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||
for (int i = 0; i < ticks; ++i)
|
||
{
|
||
m_sim->tick();
|
||
// Periodic checksum (every 30 ticks) for replay desync detection.
|
||
m_commandManager.recordTickCheckpoint();
|
||
}
|
||
}
|
||
|
||
// Emit fire events via EventManager
|
||
{
|
||
const std::vector<BeamFiredEvent> fires = m_sim->drainBeamFiredEvents();
|
||
for (const BeamFiredEvent& fe : fires)
|
||
{
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<BeamFiredEvent>(fe));
|
||
}
|
||
}
|
||
|
||
// Expire old beams. Lifetime is measured in game ticks so beams stay
|
||
// visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
|
||
{
|
||
const Tick now = m_sim->getCurrentTick();
|
||
std::vector<ActiveBeam> live;
|
||
for (const ActiveBeam& b : m_activeBeams)
|
||
{
|
||
if (now - b.event.emittedAt < kBeamLifetimeTicks)
|
||
{
|
||
live.push_back(b);
|
||
}
|
||
}
|
||
m_activeBeams = std::move(live);
|
||
}
|
||
|
||
// Drop selected scrap piles that were collected or despawned this frame, so the
|
||
// panel stops counting them and the selection empties out (REQ-UI-SCRAP-CLICK-SELECT).
|
||
pruneDespawnedScrap();
|
||
|
||
// Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the
|
||
// flash plays for a fixed real duration regardless of game speed, including
|
||
// while the game is paused (REQ-BLD-COPY-CONFIG-FEEDBACK).
|
||
if (!m_copyConfigFlashes.empty())
|
||
{
|
||
std::vector<CopyConfigFlash> live;
|
||
for (CopyConfigFlash flash : m_copyConfigFlashes)
|
||
{
|
||
flash.remainingMs -= elapsed;
|
||
if (flash.remainingMs > 0) { live.push_back(flash); }
|
||
}
|
||
m_copyConfigFlashes = std::move(live);
|
||
}
|
||
|
||
// Apply held scroll
|
||
{
|
||
// Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
|
||
const float viewCenterX = m_scrollXTiles;
|
||
const float delta = panSpeedTilesPerSecondAt(viewCenterX)
|
||
* static_cast<float>(elapsed) / 1000.0f;
|
||
const float scrollBefore = m_scrollXTiles;
|
||
if (m_scrollLeft) { m_scrollXTiles -= delta; }
|
||
if (m_scrollRight) { m_scrollXTiles += delta; }
|
||
clampScroll();
|
||
|
||
// While the view scrolls, the tile under a stationary cursor changes,
|
||
// so refresh the box-select rectangle even though no mouse move fires.
|
||
if (m_boxSelecting && m_scrollXTiles != scrollBefore)
|
||
{
|
||
m_boxCurrentTile = widgetToTile(mapFromGlobal(QCursor::pos()));
|
||
}
|
||
}
|
||
|
||
// Fire events for any state that changed since the last frame
|
||
{
|
||
const Tick newTick = m_sim->getCurrentTick();
|
||
const int newBlocks = m_sim->getBuildingBlocksStock();
|
||
const int newExpCost = m_sim->getCurrentExpansionCost();
|
||
const int newBoss = m_sim->getBossWaveCounter();
|
||
const Tick newCountdown = m_sim->getBossCountdownTicks();
|
||
|
||
if (newTick != m_lastTick)
|
||
{
|
||
m_lastTick = newTick;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<TickAdvancedEvent>(newTick));
|
||
}
|
||
if (newBlocks != m_lastBlocks)
|
||
{
|
||
m_lastBlocks = newBlocks;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<BuildingBlocksChangedEvent>(newBlocks));
|
||
}
|
||
if (newExpCost != m_lastExpansionCost)
|
||
{
|
||
m_lastExpansionCost = newExpCost;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<ExpansionCostChangedEvent>(newExpCost));
|
||
}
|
||
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
|
||
{
|
||
m_lastBossCounter = newBoss;
|
||
m_lastBossCountdown = newCountdown;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<BossWaveUpdatedEvent>(newBoss, newCountdown));
|
||
}
|
||
}
|
||
|
||
// Sim-state polls are input sources for the live game; in replay these are
|
||
// gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
|
||
// and game-over becomes the passive "replay ended" state below).
|
||
if (!m_replayPlayer)
|
||
{
|
||
// Schematic choice available
|
||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||
{
|
||
m_schematicChoiceShown = true;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||
}
|
||
if (!m_sim->hasSchematicChoicesPending())
|
||
{
|
||
m_schematicChoiceShown = false;
|
||
}
|
||
|
||
// Win check
|
||
if (m_sim->isWon() && !m_winShown)
|
||
{
|
||
m_winShown = true;
|
||
m_gameSpeedMultiplier = 0.0;
|
||
EventManager::getInstance()->sendEventImmediately(std::make_shared<WinEvent>());
|
||
}
|
||
|
||
// Game over check
|
||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||
{
|
||
m_gameOverShown = true;
|
||
m_gameSpeedMultiplier = 0.0;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<GameOverEvent>());
|
||
}
|
||
}
|
||
|
||
// Artifact count is a passive reflection of sim state (not a live-input source
|
||
// like the schematic/game-over polls above), so it updates during replay too —
|
||
// the recorded ApplySchematicChoice drives m_sim->getArtifactCount() forward and
|
||
// the header bar must track it. Fires on the first frame (count 0 vs the -1
|
||
// sentinel), which also populates the win-count max (replacing the "0/?" label).
|
||
const int currentArtifactCount = m_sim->getArtifactCount();
|
||
if (currentArtifactCount != m_lastArtifactCount)
|
||
{
|
||
m_lastArtifactCount = currentArtifactCount;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<ArtifactCountChangedEvent>(
|
||
currentArtifactCount, m_sim->getConfig().world.artifacts.artifactWinCount));
|
||
}
|
||
|
||
update();
|
||
}
|
||
|
||
void GameWorldView::paintGL()
|
||
{
|
||
QPainter painter(this);
|
||
painter.setRenderHint(QPainter::Antialiasing, false);
|
||
|
||
drawTiles(painter);
|
||
drawBuildings(painter);
|
||
// Port items are drawn over the buildings but clipped to a thin margin at each
|
||
// machine's edges (see drawPortItems), so items appear to emerge from / sink
|
||
// into the port and stay visible while crossing directly between two touching
|
||
// buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE).
|
||
drawPortItems(painter);
|
||
drawCopyConfigFeedback(painter);
|
||
drawStations(painter);
|
||
drawBeltItems(painter);
|
||
drawScrap(painter);
|
||
if (m_debugDraw)
|
||
{
|
||
drawDebugSensorRanges(painter);
|
||
drawDebugTargetLines(painter);
|
||
drawDebugOverlay(painter);
|
||
}
|
||
drawShips(painter);
|
||
drawBeams(painter);
|
||
drawOverlays(painter);
|
||
drawScreenSpace(painter);
|
||
drawPauseBorder(painter);
|
||
drawReplayOverlay(painter);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Coordinate helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
float GameWorldView::getTilePx() const
|
||
{
|
||
if (m_config->world.heightTiles <= 0) { return 1.0f; }
|
||
return static_cast<float>(height()) / static_cast<float>(m_config->world.heightTiles);
|
||
}
|
||
|
||
float GameWorldView::getViewportWidthTiles() const
|
||
{
|
||
return static_cast<float>(width()) / getTilePx();
|
||
}
|
||
|
||
float GameWorldView::getViewLeftTiles() const
|
||
{
|
||
return m_scrollXTiles - getViewportWidthTiles() / 2.0f;
|
||
}
|
||
|
||
QPointF GameWorldView::worldToWidget(QVector2D worldPos) const
|
||
{
|
||
return QPointF(
|
||
static_cast<qreal>((worldPos.x() - getViewLeftTiles()) * getTilePx()),
|
||
static_cast<qreal>(worldPos.y() * getTilePx()));
|
||
}
|
||
|
||
QPointF GameWorldView::tileToWidget(QPoint tile) const
|
||
{
|
||
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
||
static_cast<float>(tile.y())));
|
||
}
|
||
|
||
QPoint GameWorldView::widgetToTile(QPoint widgetPt) const
|
||
{
|
||
const float wx = static_cast<float>(widgetPt.x()) / getTilePx() + getViewLeftTiles();
|
||
const float wy = static_cast<float>(widgetPt.y()) / getTilePx();
|
||
return QPoint(static_cast<int>(std::floor(wx)), static_cast<int>(std::floor(wy)));
|
||
}
|
||
|
||
QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const
|
||
{
|
||
const float wx = static_cast<float>(widgetPt.x()) / getTilePx() + getViewLeftTiles();
|
||
const float wy = static_cast<float>(widgetPt.y()) / getTilePx();
|
||
return QVector2D(wx, wy);
|
||
}
|
||
|
||
QRectF GameWorldView::tileRect(QPoint tile) const
|
||
{
|
||
const QPointF tl = tileToWidget(tile);
|
||
return QRectF(tl.x(), tl.y(),
|
||
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
|
||
}
|
||
|
||
QRect GameWorldView::getViewportRect() const
|
||
{
|
||
const int left = static_cast<int>(std::floor(getViewLeftTiles())) - 1;
|
||
const int top = 0;
|
||
const int right = static_cast<int>(std::ceil(getViewLeftTiles() + getViewportWidthTiles())) + 1;
|
||
const int bottom = m_config->world.heightTiles;
|
||
return QRect(left, top, right - left, bottom - top);
|
||
}
|
||
|
||
float GameWorldView::getAsteroidLeftEdge() const
|
||
{
|
||
float leftX = -static_cast<float>(m_sim->getCurrentAsteroidWidth_tiles());
|
||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||
{
|
||
for (const QPoint& cell : b.bodyCells)
|
||
{
|
||
if (static_cast<float>(cell.x()) < leftX)
|
||
{
|
||
leftX = static_cast<float>(cell.x());
|
||
}
|
||
}
|
||
}
|
||
return leftX;
|
||
}
|
||
|
||
float GameWorldView::getEnemyStationRightEdge() const
|
||
{
|
||
float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles
|
||
+ m_config->world.regions.contestZoneWidth_tiles);
|
||
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent>(
|
||
[&rightX](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
|
||
{
|
||
if (!f.isEnemy) { return; }
|
||
for (const QPoint& cell : sb.bodyCells)
|
||
{
|
||
const float cx = static_cast<float>(cell.x() + 1);
|
||
if (cx > rightX) { rightX = cx; }
|
||
}
|
||
});
|
||
return rightX;
|
||
}
|
||
|
||
namespace
|
||
{
|
||
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
|
||
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
|
||
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
|
||
{
|
||
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
|
||
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
|
||
return valueAt0 + (valueAt1 - valueAt0) * t;
|
||
}
|
||
}
|
||
|
||
float GameWorldView::panSpeedTilesPerSecondAt(float viewCenterXTiles) const
|
||
{
|
||
// Slow near the asteroid/player buffer, fast across the contest zone, with a
|
||
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
|
||
// contest zone spans from the player buffer's right edge to the enemy stations,
|
||
// the latter tracked live so the ramp follows the front line as it is pushed.
|
||
const float slow = static_cast<float>(m_config->world.scroll.panSpeedSlow_tps);
|
||
const float fast = static_cast<float>(m_config->world.scroll.panSpeedFast_tps);
|
||
const float half = static_cast<float>(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f;
|
||
const float leftEdge = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles);
|
||
const float rightEdge = getEnemyStationRightEdge();
|
||
|
||
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
|
||
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
|
||
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
|
||
// below the fast speed where the two ramps cross.
|
||
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
|
||
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
|
||
return std::min(leftRamp, rightRamp);
|
||
}
|
||
|
||
void GameWorldView::clampScroll()
|
||
{
|
||
// m_scrollXTiles is the view center, so the pan limits are the edges themselves:
|
||
// the view can pan left until the buildable/asteroid edge is centered, and right
|
||
// until the enemy stations are centered (revealing a little space beyond them).
|
||
const float leftBound = getAsteroidLeftEdge();
|
||
const float rightBound = getEnemyStationRightEdge();
|
||
m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Placement helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const BuildingDef* GameWorldView::findBuildingDef(BuildingType type) const
|
||
{
|
||
for (const BuildingDef& def : m_config->buildings.buildings)
|
||
{
|
||
if (def.type == type) { return &def; }
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
|
||
Rotation rot) const
|
||
{
|
||
// Terrain and world-bounds validity are owned by the simulation
|
||
// (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy /
|
||
// rotate-in-place check.
|
||
if (!m_sim->getBuildings().isPlacementValid(type, anchor, rot))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const BuildingDef* def = findBuildingDef(type);
|
||
if (!def) { return false; }
|
||
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
|
||
|
||
bool anyOccupied = false;
|
||
for (const QPoint& relCell : parsed.bodyCells)
|
||
{
|
||
if (m_sim->getBuildings().isTileOccupied(anchor + relCell))
|
||
{
|
||
anyOccupied = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (anyOccupied)
|
||
{
|
||
return m_sim->getBuildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
BuildingId GameWorldView::buildingAtTile(QPoint tile) const
|
||
{
|
||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||
{
|
||
for (const QPoint& cell : b.bodyCells)
|
||
{
|
||
if (cell == tile)
|
||
{
|
||
return b.id;
|
||
}
|
||
}
|
||
}
|
||
return kInvalidBuildingId;
|
||
}
|
||
|
||
BuildingId GameWorldView::siteAtTile(QPoint tile) const
|
||
{
|
||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||
{
|
||
for (const QPoint& cell : s.bodyCells)
|
||
{
|
||
if (cell == tile)
|
||
{
|
||
return s.id;
|
||
}
|
||
}
|
||
}
|
||
return kInvalidBuildingId;
|
||
}
|
||
|
||
|
||
std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cornerB) const
|
||
{
|
||
const int x0 = std::min(cornerA.x(), cornerB.x());
|
||
const int y0 = std::min(cornerA.y(), cornerB.y());
|
||
const int x1 = std::max(cornerA.x(), cornerB.x());
|
||
const int y1 = std::max(cornerA.y(), cornerB.y());
|
||
|
||
std::vector<BuildingId> ids;
|
||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||
{
|
||
for (const QPoint& cell : b.bodyCells)
|
||
{
|
||
if (cell.x() >= x0 && cell.x() <= x1
|
||
&& cell.y() >= y0 && cell.y() <= y1)
|
||
{
|
||
ids.push_back(b.id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||
{
|
||
for (const QPoint& cell : s.bodyCells)
|
||
{
|
||
if (cell.x() >= x0 && cell.x() <= x1
|
||
&& cell.y() >= y0 && cell.y() <= y1)
|
||
{
|
||
ids.push_back(s.id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const
|
||
{
|
||
if (!m_sim->getAdmin().isValid(entity) || !m_sim->getAdmin().hasAll<PositionComponent>(entity))
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
||
}
|
||
|
||
void GameWorldView::clearScrapSelection()
|
||
{
|
||
if (m_selectedScrap.empty()) { return; }
|
||
m_selectedScrap.clear();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||
}
|
||
|
||
void GameWorldView::pruneDespawnedScrap()
|
||
{
|
||
if (m_selectedScrap.empty()) { return; }
|
||
|
||
std::vector<entt::entity> live;
|
||
for (const ScrapInfo& info : m_sim->getScraps().getAllScrapInfo())
|
||
{
|
||
if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), info.entity)
|
||
!= m_selectedScrap.end())
|
||
{
|
||
live.push_back(info.entity);
|
||
}
|
||
}
|
||
|
||
if (live.size() != m_selectedScrap.size())
|
||
{
|
||
m_selectedScrap = std::move(live);
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||
}
|
||
}
|
||
|
||
void GameWorldView::stepSpeed(int delta)
|
||
{
|
||
const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
||
const int kCount = 5;
|
||
int current = 2;
|
||
for (int i = 0; i < kCount; ++i)
|
||
{
|
||
if (std::abs(kSpeeds[i] - m_gameSpeedMultiplier) < 0.001)
|
||
{
|
||
current = i;
|
||
break;
|
||
}
|
||
}
|
||
const int next = std::max(0, std::min(kCount - 1, current + delta));
|
||
setGameSpeed(kSpeeds[next]);
|
||
}
|
||
|
||
void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||
{
|
||
const Blueprint& bp = *m_blueprintMode;
|
||
|
||
for (const BlueprintBuilding& bb : bp.buildings)
|
||
{
|
||
if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
|
||
}
|
||
|
||
// Cost only applies to buildings that are genuinely new (not rotate-in-place).
|
||
int totalCost = 0;
|
||
for (const BlueprintBuilding& bb : bp.buildings)
|
||
{
|
||
if (m_sim->getBuildings().findRotateInPlaceTarget(
|
||
bb.type, center + bb.offset, bb.rotation).has_value())
|
||
{
|
||
continue;
|
||
}
|
||
const BuildingDef* def = findBuildingDef(bb.type);
|
||
if (def) { totalCost += def->cost; }
|
||
}
|
||
if (m_sim->getBuildingBlocksStock() < totalCost) { return; }
|
||
|
||
for (const BlueprintBuilding& bb : bp.buildings)
|
||
{
|
||
const QPoint anchor = center + bb.offset;
|
||
const std::optional<BuildingId> rotateTarget =
|
||
m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
||
if (rotateTarget.has_value())
|
||
{
|
||
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
|
||
std::make_shared<RotateInPlaceCommand>();
|
||
rotateCommand->id = *rotateTarget;
|
||
rotateCommand->newRotation = bb.rotation;
|
||
enqueueCommand(rotateCommand);
|
||
continue;
|
||
}
|
||
|
||
// Place-and-configure is one atomic command: commands apply at a deferred
|
||
// tick boundary, so the caller never sees the new BuildingId. Unlock
|
||
// gating stays here (UI-side pre-filter); only fields that should apply
|
||
// are set on the command.
|
||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||
command->type = bb.type;
|
||
command->anchor = anchor;
|
||
command->rotation = bb.rotation;
|
||
|
||
if (!bb.recipeId.empty())
|
||
{
|
||
if (bb.type == BuildingType::Shipyard)
|
||
{
|
||
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
||
{
|
||
command->recipeId = bb.recipeId;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
const bool needsUnlockCheck = bb.type == BuildingType::Miner
|
||
|| bb.type == BuildingType::Assembler;
|
||
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
||
{
|
||
command->recipeId = bb.recipeId;
|
||
}
|
||
}
|
||
}
|
||
|
||
command->shipLayout = bb.shipLayout;
|
||
|
||
if (bb.type == BuildingType::Splitter
|
||
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
|
||
{
|
||
// The splitter is still a construction site, so the filters carry
|
||
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
|
||
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
|
||
command->hasSplitterFilters = true;
|
||
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
|
||
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
|
||
}
|
||
|
||
enqueueCommand(command);
|
||
}
|
||
}
|
||
|
||
void GameWorldView::placeAtTile(QPoint tile)
|
||
{
|
||
if (!m_builderType.has_value())
|
||
{
|
||
return;
|
||
}
|
||
const BuildingType type = *m_builderType;
|
||
|
||
if (!isValidPlacement(type, tile, m_ghostRotation))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::optional<BuildingId> rotateTarget =
|
||
m_sim->getBuildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
|
||
if (rotateTarget.has_value())
|
||
{
|
||
std::shared_ptr<RotateInPlaceCommand> command =
|
||
std::make_shared<RotateInPlaceCommand>();
|
||
command->id = *rotateTarget;
|
||
command->newRotation = m_ghostRotation;
|
||
enqueueCommand(command);
|
||
return;
|
||
}
|
||
|
||
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
|
||
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
|
||
// optimistic UI update matches what the deferred command will do — isValidPlacement
|
||
// (above) already covered terrain/bounds.
|
||
if (type == BuildingType::Belt)
|
||
{
|
||
if (m_beltDragTiles.count(tile) > 0)
|
||
{
|
||
return;
|
||
}
|
||
if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type))
|
||
{
|
||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||
m_beltDragTiles.insert(tile);
|
||
}
|
||
}
|
||
else if (type == BuildingType::Splitter
|
||
|| type == BuildingType::TunnelEntry
|
||
|| type == BuildingType::TunnelExit)
|
||
{
|
||
if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type))
|
||
{
|
||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||
if (type == BuildingType::TunnelEntry)
|
||
{
|
||
m_builderType = BuildingType::TunnelExit;
|
||
}
|
||
else if (type == BuildingType::TunnelExit)
|
||
{
|
||
m_builderType = BuildingType::TunnelEntry;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Port glyph helper
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameWorldView::drawPortGlyph(QPainter& painter, QPoint bodyTile,
|
||
Rotation direction, const QColor& color)
|
||
{
|
||
const float px = getTilePx();
|
||
const QRectF tr = tileRect(bodyTile);
|
||
const QPointF center(tr.x() + static_cast<qreal>(px) * 0.5,
|
||
tr.y() + static_cast<qreal>(px) * 0.5);
|
||
|
||
QPointF offset;
|
||
const char* ch;
|
||
switch (direction)
|
||
{
|
||
case Rotation::East: offset = QPointF(px * 0.25f, 0); ch = ">"; break;
|
||
case Rotation::West: offset = QPointF(-px * 0.25f, 0); ch = "<"; break;
|
||
case Rotation::North: offset = QPointF(0, -px * 0.25f); ch = "^"; break;
|
||
case Rotation::South: offset = QPointF(0, px * 0.25f); ch = "v"; break;
|
||
default: return;
|
||
}
|
||
|
||
const qreal half = static_cast<qreal>(px) * 0.3;
|
||
const QPointF pos = center + offset;
|
||
const QRectF textRect(pos.x() - half, pos.y() - half, half * 2.0, half * 2.0);
|
||
|
||
QFont f = painter.font();
|
||
f.setPixelSize(std::max(6, static_cast<int>(px * 0.4f)));
|
||
painter.setFont(f);
|
||
painter.setPen(color);
|
||
painter.drawText(textRect, Qt::AlignCenter, QString::fromLatin1(ch));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rendering
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameWorldView::drawTiles(QPainter& painter)
|
||
{
|
||
const int leftTile = static_cast<int>(std::floor(getViewLeftTiles())) - 1;
|
||
const int rightTile = leftTile + static_cast<int>(std::ceil(getViewportWidthTiles())) + 2;
|
||
const int bottomTile = m_config->world.heightTiles;
|
||
|
||
// Asteroid columns left of the buildable edge are not yet unlocked by
|
||
// expansion; tint them so the player sees the reachable-but-locked area.
|
||
const int buildableLeftX = -m_sim->getCurrentAsteroidWidth_tiles();
|
||
|
||
painter.setPen(Qt::NoPen);
|
||
for (int x = leftTile; x <= rightTile; ++x)
|
||
{
|
||
const QColor& fill = (x < 0)
|
||
? m_visuals->asteroid.fill
|
||
: m_visuals->space.fill;
|
||
const bool locked = (x < buildableLeftX);
|
||
for (int y = 0; y < bottomTile; ++y)
|
||
{
|
||
const QRectF rect = tileRect(QPoint(x, y));
|
||
painter.fillRect(rect, fill);
|
||
if (locked)
|
||
{
|
||
painter.fillRect(rect, m_visuals->overlays.lockedAsteroid);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameWorldView::drawBuildings(QPainter& painter)
|
||
{
|
||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||
{
|
||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||
m_visuals->buildings.find(b.type);
|
||
if (it == m_visuals->buildings.end()) { continue; }
|
||
const BuildingVisuals& bv = it->second;
|
||
|
||
painter.setPen(Qt::NoPen);
|
||
for (const QPoint& cell : b.bodyCells)
|
||
{
|
||
painter.fillRect(tileRect(cell), bv.fill);
|
||
}
|
||
|
||
const QPointF tl = tileToWidget(b.anchor);
|
||
const QRectF bboxRect(tl.x(), tl.y(),
|
||
b.footprint.width() * static_cast<qreal>(getTilePx()),
|
||
b.footprint.height() * static_cast<qreal>(getTilePx()));
|
||
|
||
painter.setPen(QPen(bv.outline, 1));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(bboxRect);
|
||
|
||
if (!bv.glyph.isEmpty())
|
||
{
|
||
painter.setPen(bv.outline);
|
||
painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
|
||
}
|
||
|
||
for (const Port& port : b.outputPorts)
|
||
{
|
||
drawPortGlyph(painter, portBodyTile(port.tile, port.direction),
|
||
port.direction, bv.outline);
|
||
}
|
||
|
||
// HP bar below the HQ footprint; the HQ's HP lives on its proxy entity.
|
||
if (b.type == BuildingType::Hq)
|
||
{
|
||
m_sim->getAdmin().forEach<HqProxyComponent, FactionComponent, HealthComponent>(
|
||
[&](entt::entity /*e*/, const HqProxyComponent& /*hq*/,
|
||
const FactionComponent& f, const HealthComponent& h)
|
||
{
|
||
if (h.maxHp > 0.0f)
|
||
{
|
||
drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0,
|
||
bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
painter.setOpacity(0.5);
|
||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||
{
|
||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||
m_visuals->buildings.find(s.type);
|
||
if (it == m_visuals->buildings.end()) { continue; }
|
||
const BuildingVisuals& bv = it->second;
|
||
|
||
for (const QPoint& cell : s.bodyCells)
|
||
{
|
||
painter.fillRect(tileRect(cell), bv.fill);
|
||
}
|
||
|
||
const QPointF tl = tileToWidget(s.anchor);
|
||
const QRectF bboxRect(tl.x(), tl.y(),
|
||
s.footprint.width() * static_cast<qreal>(getTilePx()),
|
||
s.footprint.height() * static_cast<qreal>(getTilePx()));
|
||
painter.setPen(QPen(bv.outline, 1, Qt::DashLine));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(bboxRect);
|
||
|
||
const BuildingDef* siteDef = findBuildingDef(s.type);
|
||
if (siteDef)
|
||
{
|
||
// Glyph + progress percentage
|
||
const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds);
|
||
int pct = 0;
|
||
if (s.completesAt > 0 && durationTicks > 0)
|
||
{
|
||
const Tick elapsed = m_sim->getCurrentTick()
|
||
- (s.completesAt - durationTicks);
|
||
pct = static_cast<int>(
|
||
std::max(Tick(0), std::min(durationTicks, elapsed))
|
||
* 100 / durationTicks);
|
||
}
|
||
const QString pctText = QString::number(pct) + "%";
|
||
|
||
painter.setPen(bv.outline);
|
||
if (!bv.glyph.isEmpty())
|
||
{
|
||
const QRectF topHalf(bboxRect.x(), bboxRect.y(),
|
||
bboxRect.width(), bboxRect.height() * 0.5);
|
||
const QRectF botHalf(bboxRect.x(),
|
||
bboxRect.y() + bboxRect.height() * 0.5,
|
||
bboxRect.width(), bboxRect.height() * 0.5);
|
||
painter.drawText(topHalf, Qt::AlignCenter, bv.glyph);
|
||
painter.drawText(botHalf, Qt::AlignCenter, pctText);
|
||
}
|
||
else
|
||
{
|
||
painter.drawText(bboxRect, Qt::AlignCenter, pctText);
|
||
}
|
||
|
||
// Port glyphs
|
||
const ParsedSurfaceMask siteMask =
|
||
parseSurfaceMask(siteDef->surfaceMask, s.rotation);
|
||
for (const Port& port : siteMask.outputPorts)
|
||
{
|
||
const QPoint absBody = s.anchor
|
||
+ portBodyTile(port.tile, port.direction);
|
||
drawPortGlyph(painter, absBody, port.direction, bv.outline);
|
||
}
|
||
}
|
||
}
|
||
painter.setOpacity(1.0);
|
||
|
||
// Selection highlights are drawn last, after every building and construction
|
||
// site fill, so a selected building surrounded by neighbours keeps its outline:
|
||
// the highlight sits 1px outside the footprint (into adjacent tiles), and drawing
|
||
// it inline would let later-drawn neighbours overpaint it with their body fill.
|
||
drawSelectionHighlights(painter);
|
||
}
|
||
|
||
std::optional<QRectF> GameWorldView::footprintWidgetRect(BuildingId id) const
|
||
{
|
||
std::optional<QPoint> anchor;
|
||
std::optional<QSize> footprint;
|
||
|
||
if (const Building* b = m_sim->getBuildings().findBuilding(id))
|
||
{
|
||
anchor = b->anchor;
|
||
footprint = b->footprint;
|
||
}
|
||
else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id))
|
||
{
|
||
anchor = s->anchor;
|
||
footprint = s->footprint;
|
||
}
|
||
if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; }
|
||
|
||
const QPointF tl = tileToWidget(*anchor);
|
||
return QRectF(tl.x(), tl.y(),
|
||
footprint->width() * static_cast<qreal>(getTilePx()),
|
||
footprint->height() * static_cast<qreal>(getTilePx()));
|
||
}
|
||
|
||
void GameWorldView::drawSelectionHighlights(QPainter& painter)
|
||
{
|
||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||
painter.setBrush(Qt::NoBrush);
|
||
|
||
for (BuildingId selId : m_selectedBuildingIds)
|
||
{
|
||
const std::optional<QRectF> rect = footprintWidgetRect(selId);
|
||
if (!rect.has_value()) { continue; }
|
||
// Outline sits 1px outside the footprint (into adjacent tiles).
|
||
painter.drawRect(rect->adjusted(-1, -1, 1, 1));
|
||
}
|
||
|
||
// A ring around each selected scrap pile, sitting just outside the pile's
|
||
// rendered circle (radius getTilePx()*0.2, matching drawScrap) (REQ-UI-SCRAP-CLICK-SELECT).
|
||
if (!m_selectedScrap.empty())
|
||
{
|
||
const qreal outlineRadius = static_cast<qreal>(getTilePx() * 0.2f) + 3.0;
|
||
for (const ScrapInfo& scrap : m_sim->getScraps().getAllScrapInfo())
|
||
{
|
||
if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), scrap.entity)
|
||
== m_selectedScrap.end()) { continue; }
|
||
painter.drawEllipse(worldToWidget(scrap.position), outlineRadius, outlineRadius);
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
|
||
{
|
||
const QColor color = m_visuals->overlays.copyConfig;
|
||
|
||
// Eligible-target tint: while a configuration is cached, every same-type
|
||
// building and site (the source included) is a valid paste target and is
|
||
// washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK).
|
||
if (m_copiedConfig.has_value())
|
||
{
|
||
painter.setPen(Qt::NoPen);
|
||
painter.setBrush(color);
|
||
const BuildingType type = m_copiedConfig->type;
|
||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||
{
|
||
if (b.type != type) { continue; }
|
||
const std::optional<QRectF> rect = footprintWidgetRect(b.id);
|
||
if (rect.has_value()) { painter.drawRect(*rect); }
|
||
}
|
||
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
|
||
{
|
||
if (s.type != type) { continue; }
|
||
const std::optional<QRectF> rect = footprintWidgetRect(s.id);
|
||
if (rect.has_value()) { painter.drawRect(*rect); }
|
||
}
|
||
}
|
||
|
||
// Copy / paste flashes: a brief outline in the same color, drawn like the
|
||
// selection outline (REQ-BLD-COPY-CONFIG-FEEDBACK).
|
||
painter.setPen(QPen(color, 2));
|
||
painter.setBrush(Qt::NoBrush);
|
||
for (const CopyConfigFlash& flash : m_copyConfigFlashes)
|
||
{
|
||
const std::optional<QRectF> rect = footprintWidgetRect(flash.id);
|
||
if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); }
|
||
}
|
||
}
|
||
|
||
void GameWorldView::drawPortItems(QPainter& painter)
|
||
{
|
||
const float halfPx = getTilePx() * 0.5f * 0.5f;
|
||
|
||
// Port items are drawn over the buildings (drawBuildings runs first) but clipped
|
||
// to a thin margin at each machine's edges: the clip region is the whole view
|
||
// minus every machine's interior (its footprint inset by kPortMarginTiles). So a
|
||
// transiting item shows only near the port edge — appearing to emerge from / sink
|
||
// into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying
|
||
// visible in the ~2×margin band at a seam between two touching buildings
|
||
// (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so
|
||
// items on belts stay fully visible.
|
||
constexpr double kPortMarginTiles = 0.2;
|
||
const double margin = kPortMarginTiles * static_cast<double>(getTilePx());
|
||
|
||
QRegion clip(rect());
|
||
for (const Building& b : m_sim->getBuildings().getAllBuildings())
|
||
{
|
||
if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter
|
||
|| b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
|
||
{
|
||
continue;
|
||
}
|
||
const std::set<QPoint, QPointCompare> cells(b.bodyCells.begin(), b.bodyCells.end());
|
||
for (const QPoint& cell : b.bodyCells)
|
||
{
|
||
// Inset an edge only where the neighbouring cell is not part of the same
|
||
// building, so interior cell seams stay filled (handles L-shaped footprints).
|
||
const double l = cells.count(cell + QPoint(-1, 0)) ? 0.0 : margin;
|
||
const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin;
|
||
const double r = cells.count(cell + QPoint( 1, 0)) ? 0.0 : margin;
|
||
const double d = cells.count(cell + QPoint( 0, 1)) ? 0.0 : margin;
|
||
clip = clip.subtracted(QRegion(tileRect(cell).adjusted(l, t, -r, -d).toRect()));
|
||
}
|
||
}
|
||
|
||
// Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square + outline.
|
||
const std::function<void(const ItemType&, QPointF)> drawItem =
|
||
[&](const ItemType& type, QPointF worldPos)
|
||
{
|
||
const std::map<std::string, ItemVisuals>::const_iterator it =
|
||
m_visuals->items.find(type.id);
|
||
if (it == m_visuals->items.end()) { return; }
|
||
|
||
const QPointF center = worldToWidget(
|
||
QVector2D(static_cast<float>(worldPos.x()),
|
||
static_cast<float>(worldPos.y())));
|
||
const QRectF itemRect(center.x() - halfPx, center.y() - halfPx,
|
||
halfPx * 2, halfPx * 2);
|
||
painter.fillRect(itemRect, it->second.fill);
|
||
painter.setPen(QPen(it->second.outline, 1));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(itemRect);
|
||
};
|
||
|
||
painter.save();
|
||
painter.setClipRegion(clip);
|
||
m_sim->getBuildings().forEachEmergingItem(drawItem);
|
||
m_sim->getBuildings().forEachIncomingItem(drawItem);
|
||
painter.restore();
|
||
}
|
||
|
||
void GameWorldView::drawBeltItems(QPainter& painter)
|
||
{
|
||
const float halfPx = getTilePx() * 0.5f * 0.5f;
|
||
const QRect vr = getViewportRect();
|
||
|
||
m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi)
|
||
{
|
||
const std::map<std::string, ItemVisuals>::const_iterator it =
|
||
m_visuals->items.find(vi.type.id);
|
||
if (it == m_visuals->items.end()) { return; }
|
||
|
||
const QPointF center = worldToWidget(
|
||
QVector2D(static_cast<float>(vi.worldPos.x()),
|
||
static_cast<float>(vi.worldPos.y())));
|
||
const QRectF rect(center.x() - halfPx, center.y() - halfPx,
|
||
halfPx * 2, halfPx * 2);
|
||
painter.fillRect(rect, it->second.fill);
|
||
painter.setPen(QPen(it->second.outline, 1));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(rect);
|
||
});
|
||
}
|
||
|
||
void GameWorldView::drawScrap(QPainter& painter)
|
||
{
|
||
const float r = getTilePx() * 0.2f;
|
||
for (const ScrapInfo& scrap : m_sim->getScraps().getAllScrapInfo())
|
||
{
|
||
const QPointF center = worldToWidget(scrap.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));
|
||
}
|
||
}
|
||
|
||
void GameWorldView::drawStations(QPainter& painter)
|
||
{
|
||
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f,
|
||
const HealthComponent& h)
|
||
{
|
||
const BuildingType visType = f.isEnemy
|
||
? BuildingType::EnemyDefenceStation
|
||
: BuildingType::PlayerDefenceStation;
|
||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||
m_visuals->buildings.find(visType);
|
||
if (it == m_visuals->buildings.end()) { return; }
|
||
const BuildingVisuals& bv = it->second;
|
||
|
||
painter.setPen(Qt::NoPen);
|
||
for (const QPoint& cell : sb.bodyCells)
|
||
{
|
||
painter.fillRect(tileRect(cell), bv.fill);
|
||
}
|
||
|
||
const QPointF tl = tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y()));
|
||
const QRectF bboxRect(tl.x(), tl.y(),
|
||
sb.footprint.width() * static_cast<qreal>(getTilePx()),
|
||
sb.footprint.height() * static_cast<qreal>(getTilePx()));
|
||
|
||
painter.setPen(QPen(bv.outline, 1));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(bboxRect);
|
||
|
||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||
{
|
||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2));
|
||
}
|
||
|
||
// HP bar below footprint.
|
||
if (h.maxHp > 0.0f)
|
||
{
|
||
drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0,
|
||
bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
|
||
}
|
||
});
|
||
}
|
||
|
||
void GameWorldView::drawShips(QPainter& painter)
|
||
{
|
||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||
FactionComponent, HealthComponent>(
|
||
[&](entt::entity e, const ShipIdentityComponent& si,
|
||
const PositionComponent& pos, const FacingComponent& facing,
|
||
const FactionComponent& fac, const HealthComponent& h)
|
||
{
|
||
const std::map<std::string, ShipVisuals>::const_iterator it =
|
||
m_visuals->ships.find(si.schematicId);
|
||
if (it == m_visuals->ships.end()) { return; }
|
||
|
||
const QPointF center = 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;
|
||
|
||
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);
|
||
|
||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||
{
|
||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||
painter.setBrush(Qt::NoBrush);
|
||
const qreal r = static_cast<qreal>(fwd) + 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, barX, barY, barW, h.hp / h.maxHp, fac.isEnemy);
|
||
}
|
||
});
|
||
}
|
||
|
||
void GameWorldView::drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
|
||
float fraction, bool isEnemy)
|
||
{
|
||
const qreal barH = static_cast<qreal>(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)
|
||
{
|
||
painter.setBrush(Qt::NoBrush);
|
||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||
FactionComponent, SensorRangeComponent>(
|
||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||
const PositionComponent& pos, const FacingComponent& /*facing*/,
|
||
const FactionComponent& /*fac*/, const SensorRangeComponent& sensor)
|
||
{
|
||
const std::map<std::string, ShipVisuals>::const_iterator it =
|
||
m_visuals->ships.find(si.schematicId);
|
||
if (it == m_visuals->ships.end()) { return; }
|
||
|
||
const QPointF center = worldToWidget(pos.value);
|
||
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
||
* static_cast<qreal>(getTilePx());
|
||
QColor circleColor = it->second.outline;
|
||
circleColor.setAlpha(77);
|
||
painter.setPen(QPen(circleColor, 1));
|
||
painter.drawEllipse(center, radiusPx, radiusPx);
|
||
});
|
||
}
|
||
|
||
void GameWorldView::drawDebugTargetLines(QPainter& painter)
|
||
{
|
||
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
||
// own schematic fill. Shared by the attack, repair and salvage target lines.
|
||
const std::function<void(const std::string&, const QVector2D&, const QVector2D&)>
|
||
drawTargetLine = [&](const std::string& schematicId, const QVector2D& from,
|
||
const QVector2D& to)
|
||
{
|
||
const std::map<std::string, ShipVisuals>::const_iterator it =
|
||
m_visuals->ships.find(schematicId);
|
||
if (it == m_visuals->ships.end()) { return; }
|
||
|
||
QColor lineColor = it->second.fill;
|
||
lineColor.setAlpha(128);
|
||
painter.setPen(QPen(lineColor, 1));
|
||
painter.drawLine(worldToWidget(from), worldToWidget(to));
|
||
};
|
||
|
||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, AttackBehavior>(
|
||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||
const PositionComponent& pos, const AttackBehavior& attack)
|
||
{
|
||
if (!attack.currentTarget.has_value()) { return; }
|
||
|
||
const std::optional<QVector2D> targetPos =
|
||
entityPosition(*attack.currentTarget);
|
||
if (!targetPos.has_value()) { return; }
|
||
|
||
drawTargetLine(si.schematicId, pos.value, *targetPos);
|
||
});
|
||
|
||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, RepairBehavior>(
|
||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||
const PositionComponent& pos, const RepairBehavior& repair)
|
||
{
|
||
if (!repair.currentTarget.has_value()) { return; }
|
||
|
||
const std::optional<QVector2D> targetPos =
|
||
entityPosition(*repair.currentTarget);
|
||
if (!targetPos.has_value()) { return; }
|
||
|
||
drawTargetLine(si.schematicId, pos.value, *targetPos);
|
||
});
|
||
|
||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SalvageScrapBehavior>(
|
||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||
const PositionComponent& pos, const SalvageScrapBehavior& salvage)
|
||
{
|
||
if (!salvage.scrapTarget.has_value()) { return; }
|
||
|
||
drawTargetLine(si.schematicId, pos.value, *salvage.scrapTarget);
|
||
});
|
||
}
|
||
|
||
void GameWorldView::drawDebugOverlay(QPainter& painter)
|
||
{
|
||
painter.resetTransform();
|
||
|
||
const QStringList lines = {
|
||
tr("Accumulated Threat Level: %1")
|
||
.arg(m_sim->getThreatLevel(), 0, 'f', 1),
|
||
tr("Time until Wave: %1s")
|
||
.arg(ticksToSeconds(m_sim->getNormalGapRemainingTicks()), 0, 'f', 1),
|
||
tr("Threat Accumulation Rate: %1 threat/s")
|
||
.arg(m_sim->getThreatAccumulationRate(), 0, 'f', 1),
|
||
tr("Max Factory Production: %1 threat/s")
|
||
.arg(m_sim->getMaxFactoryProductionThreatRate(), 0, 'f', 1),
|
||
tr("Current Factory Production: %1 threat/s")
|
||
.arg(m_sim->getCurrentFactoryProductionThreatRate(), 0, 'f', 1),
|
||
};
|
||
|
||
QFont font = painter.font();
|
||
font.setPointSize(m_visuals->toast.fontSize);
|
||
painter.setFont(font);
|
||
|
||
const QFontMetrics fm = painter.fontMetrics();
|
||
const int lineH = fm.height();
|
||
const int padding = 8;
|
||
const int spacing = 4;
|
||
|
||
int textW = 0;
|
||
for (const QString& line : lines)
|
||
{
|
||
textW = std::max(textW, fm.horizontalAdvance(line));
|
||
}
|
||
const int bgW = textW + padding * 2;
|
||
const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2;
|
||
|
||
const QRect bgRect(padding, padding, bgW, bgH);
|
||
painter.fillRect(bgRect, QColor(0, 0, 0, 160));
|
||
|
||
painter.setPen(Qt::white);
|
||
int y = padding * 2;
|
||
for (const QString& line : lines)
|
||
{
|
||
const QRect textRect(padding * 2, y, textW, lineH);
|
||
painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line);
|
||
y += lineH + spacing;
|
||
}
|
||
}
|
||
|
||
void GameWorldView::drawBeams(QPainter& painter)
|
||
{
|
||
const QPainter::RenderHints savedHints = painter.renderHints();
|
||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||
|
||
for (const ActiveBeam& beam : m_activeBeams)
|
||
{
|
||
const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
|
||
const std::optional<QVector2D> targetPos = entityPosition(beam.event.target);
|
||
if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }
|
||
|
||
QColor color = m_visuals->beams.weaponColor;
|
||
switch (beam.event.kind)
|
||
{
|
||
case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break;
|
||
case BeamKind::Repair: color = m_visuals->beams.repairColor; break;
|
||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||
}
|
||
|
||
const QPointF s = worldToWidget(*shooterPos);
|
||
const QPointF t = worldToWidget(*targetPos + beam.targetOffset);
|
||
|
||
// Unit direction/perpendicular of the beam in widget space. A degenerate
|
||
// zero-length beam (shooter and target coincide) has no direction to
|
||
// taper along, so skip it.
|
||
const QVector2D delta(static_cast<float>(t.x() - s.x()),
|
||
static_cast<float>(t.y() - s.y()));
|
||
const float lengthPx = delta.length();
|
||
if (lengthPx < 0.001f) { continue; }
|
||
const QVector2D dir = delta / lengthPx;
|
||
const QVector2D perp(-dir.y(), dir.x());
|
||
|
||
// Directional taper: draw the beam as a quad that is wide at the shooter
|
||
// and narrows to a faint tip at the target, so it reads as an arrow
|
||
// pointing away from whoever fired it. Without this, a beam strung
|
||
// between two nearby ships is symmetric and gives no cue which end is the
|
||
// source (the readability problem this addresses).
|
||
const float widthPx = std::max(1.0f, static_cast<float>(m_visuals->beams.widthPx));
|
||
const float baseHalf = widthPx * 1.f;
|
||
const float tipHalf = widthPx * 0.35f;
|
||
|
||
QPolygonF quad;
|
||
quad << QPointF(s.x() + static_cast<qreal>(perp.x() * baseHalf),
|
||
s.y() + static_cast<qreal>(perp.y() * baseHalf))
|
||
<< QPointF(s.x() - static_cast<qreal>(perp.x() * baseHalf),
|
||
s.y() - static_cast<qreal>(perp.y() * baseHalf))
|
||
<< QPointF(t.x() - static_cast<qreal>(perp.x() * tipHalf),
|
||
t.y() - static_cast<qreal>(perp.y() * tipHalf))
|
||
<< QPointF(t.x() + static_cast<qreal>(perp.x() * tipHalf),
|
||
t.y() + static_cast<qreal>(perp.y() * tipHalf));
|
||
|
||
QColor bright = color;
|
||
bright.setAlpha(255);
|
||
QColor faint = color;
|
||
faint.setAlpha(90);
|
||
|
||
QLinearGradient bodyGrad(s, t);
|
||
bodyGrad.setColorAt(0.0, bright);
|
||
bodyGrad.setColorAt(1.0, faint);
|
||
|
||
painter.setPen(Qt::NoPen);
|
||
painter.setBrush(bodyGrad);
|
||
painter.drawPolygon(quad);
|
||
}
|
||
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.setRenderHints(savedHints);
|
||
}
|
||
|
||
void GameWorldView::drawOverlays(QPainter& painter)
|
||
{
|
||
// Builder-mode ghost
|
||
if (m_builderType.has_value())
|
||
{
|
||
drawBuildingGhost(painter, *m_builderType, m_ghostTile,
|
||
m_ghostRotation, m_ghostValid);
|
||
}
|
||
|
||
// Blueprint placement ghost
|
||
if (m_blueprintMode.has_value())
|
||
{
|
||
for (const BlueprintBuilding& bb : m_blueprintMode->buildings)
|
||
{
|
||
const QPoint anchor = m_blueprintGhostTile + bb.offset;
|
||
const bool valid = isValidPlacement(bb.type, anchor, bb.rotation);
|
||
drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid);
|
||
}
|
||
}
|
||
|
||
// Demolish tint: while dragging a demolish box, tint every covered
|
||
// building/site (REQ-BLD-DEMOLISH-BOX); otherwise tint the hovered one.
|
||
if (m_demolishMode && m_boxSelecting)
|
||
{
|
||
for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile))
|
||
{
|
||
const Building* b = m_sim->getBuildings().findBuilding(id);
|
||
if (b && b->type == BuildingType::Hq) { continue; }
|
||
const std::vector<QPoint>* cells = nullptr;
|
||
const ConstructionSite* s = nullptr;
|
||
if (b) { cells = &b->bodyCells; }
|
||
else if ((s = m_sim->getBuildings().findSite(id))) { cells = &s->bodyCells; }
|
||
if (cells)
|
||
{
|
||
for (const QPoint& cell : *cells)
|
||
{
|
||
painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId)
|
||
{
|
||
const Building* b = m_sim->getBuildings().findBuilding(m_demolishHoverBuildingId);
|
||
if (b)
|
||
{
|
||
for (const QPoint& cell : b->bodyCells)
|
||
{
|
||
painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Box-select rectangle
|
||
if (m_boxSelecting)
|
||
{
|
||
const QPoint tl(std::min(m_boxStartTile.x(), m_boxCurrentTile.x()),
|
||
std::min(m_boxStartTile.y(), m_boxCurrentTile.y()));
|
||
const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1,
|
||
std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1);
|
||
const QRectF selRect(tileToWidget(tl), tileToWidget(br));
|
||
painter.setPen(QPen(m_visuals->overlays.selectionRect, 1));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(selRect);
|
||
}
|
||
}
|
||
|
||
void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
|
||
QPoint anchorTile, Rotation rotation,
|
||
bool valid)
|
||
{
|
||
const BuildingDef* def = findBuildingDef(type);
|
||
if (!def) { return; }
|
||
|
||
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
|
||
m_visuals->buildings.find(type);
|
||
if (it == m_visuals->buildings.end()) { return; }
|
||
const BuildingVisuals& bv = it->second;
|
||
|
||
// Valid ghosts show the building type's own colors; invalid ghosts override
|
||
// with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The
|
||
// invalid color's RGB is taken at full opacity so it does not double-dim
|
||
// against the setOpacity below (the configured color carries its own alpha).
|
||
const QColor invalidColor(m_visuals->overlays.ghostInvalid.red(),
|
||
m_visuals->overlays.ghostInvalid.green(),
|
||
m_visuals->overlays.ghostInvalid.blue());
|
||
const QColor fillColor = valid ? bv.fill : invalidColor;
|
||
const QColor lineColor = valid ? bv.outline : invalidColor;
|
||
|
||
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
|
||
if (parsed.bodyCells.empty()) { return; }
|
||
|
||
painter.setOpacity(0.5);
|
||
|
||
QPoint minCell = parsed.bodyCells.front();
|
||
QPoint maxCell = parsed.bodyCells.front();
|
||
for (const QPoint& cell : parsed.bodyCells)
|
||
{
|
||
painter.fillRect(tileRect(anchorTile + cell), fillColor);
|
||
minCell.setX(std::min(minCell.x(), cell.x()));
|
||
minCell.setY(std::min(minCell.y(), cell.y()));
|
||
maxCell.setX(std::max(maxCell.x(), cell.x()));
|
||
maxCell.setY(std::max(maxCell.y(), cell.y()));
|
||
}
|
||
|
||
const QPointF tl = tileToWidget(anchorTile + minCell);
|
||
const QRectF bboxRect(tl.x(), tl.y(),
|
||
(maxCell.x() - minCell.x() + 1) * static_cast<qreal>(getTilePx()),
|
||
(maxCell.y() - minCell.y() + 1) * static_cast<qreal>(getTilePx()));
|
||
|
||
painter.setPen(QPen(lineColor, 1));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRect(bboxRect);
|
||
|
||
if (!bv.glyph.isEmpty())
|
||
{
|
||
painter.setPen(lineColor);
|
||
painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
|
||
}
|
||
|
||
for (const Port& port : parsed.outputPorts)
|
||
{
|
||
drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction),
|
||
port.direction, lineColor);
|
||
}
|
||
|
||
painter.setOpacity(1.0);
|
||
}
|
||
|
||
void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
|
||
{
|
||
}
|
||
|
||
void GameWorldView::drawPauseBorder(QPainter& painter)
|
||
{
|
||
if (m_gameSpeedMultiplier > 0.0) { return; }
|
||
|
||
// Border thickness in pixels, capped so it never exceeds half the viewport on
|
||
// very small windows (which would leave no un-darkened center).
|
||
const qreal borderPx = std::min<qreal>(
|
||
100.0, std::min(width(), height()) / 2.0);
|
||
if (borderPx <= 0.0) { return; }
|
||
|
||
const QColor edge(0, 0, 0, 128); // 50% black at the viewport edge
|
||
const QColor inner(0, 0, 0, 0); // fully transparent toward the center
|
||
|
||
const qreal w = width();
|
||
const qreal h = height();
|
||
|
||
painter.save();
|
||
|
||
// Each side is a linear gradient fading from `edge` at the viewport border to
|
||
// `inner` toward the center. The four sides are clipped to trapezoids that meet
|
||
// along the corner diagonals (a mitred picture frame), so the strips never
|
||
// overlap and don't double-darken the corners. Because the border thickness is
|
||
// the same on every side, both adjoining gradients evaluate equally along each
|
||
// 45-degree diagonal and join seamlessly.
|
||
const std::function<void(const QLinearGradient&, const QPolygonF&)> drawSide =
|
||
[&](const QLinearGradient& gradient, const QPolygonF& trapezoid) {
|
||
QPainterPath clip;
|
||
clip.addPolygon(trapezoid);
|
||
painter.save();
|
||
painter.setClipPath(clip);
|
||
painter.fillRect(QRectF(0, 0, w, h), gradient);
|
||
painter.restore();
|
||
};
|
||
|
||
QLinearGradient left(0, 0, borderPx, 0);
|
||
left.setColorAt(0.0, edge);
|
||
left.setColorAt(1.0, inner);
|
||
drawSide(left, QPolygonF({ QPointF(0, 0), QPointF(borderPx, borderPx),
|
||
QPointF(borderPx, h - borderPx), QPointF(0, h) }));
|
||
|
||
QLinearGradient right(w, 0, w - borderPx, 0);
|
||
right.setColorAt(0.0, edge);
|
||
right.setColorAt(1.0, inner);
|
||
drawSide(right, QPolygonF({ QPointF(w, 0), QPointF(w - borderPx, borderPx),
|
||
QPointF(w - borderPx, h - borderPx), QPointF(w, h) }));
|
||
|
||
QLinearGradient top(0, 0, 0, borderPx);
|
||
top.setColorAt(0.0, edge);
|
||
top.setColorAt(1.0, inner);
|
||
drawSide(top, QPolygonF({ QPointF(0, 0), QPointF(w, 0),
|
||
QPointF(w - borderPx, borderPx), QPointF(borderPx, borderPx) }));
|
||
|
||
QLinearGradient bottom(0, h, 0, h - borderPx);
|
||
bottom.setColorAt(0.0, edge);
|
||
bottom.setColorAt(1.0, inner);
|
||
drawSide(bottom, QPolygonF({ QPointF(0, h), QPointF(w, h),
|
||
QPointF(w - borderPx, h - borderPx), QPointF(borderPx, h - borderPx) }));
|
||
|
||
painter.restore();
|
||
}
|
||
|
||
void GameWorldView::drawReplayOverlay(QPainter& painter)
|
||
{
|
||
if (!m_replayPlayer) { return; }
|
||
|
||
painter.save();
|
||
|
||
QFont tag = painter.font();
|
||
tag.setPixelSize(16);
|
||
tag.setBold(true);
|
||
painter.setFont(tag);
|
||
painter.setPen(QColor(255, 220, 80));
|
||
painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY"));
|
||
|
||
// The replay is over once the recorded stream is exhausted or the run reached
|
||
// its terminal state (win / defeat) — matching where playback freezes above.
|
||
const bool ended = m_replayPlayer->isFinished()
|
||
|| m_sim->isWon() || m_sim->isGameOver();
|
||
if (ended)
|
||
{
|
||
// Dim the world so the end message reads clearly over it.
|
||
painter.fillRect(rect(), QColor(0, 0, 0, 140));
|
||
|
||
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
|
||
QString message;
|
||
if (desync.has_value())
|
||
{
|
||
message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*desync));
|
||
painter.setPen(QColor(255, 90, 90));
|
||
}
|
||
else if (m_sim->isWon())
|
||
{
|
||
message = tr("Replay ended — Victory");
|
||
painter.setPen(QColor(120, 255, 120));
|
||
}
|
||
else if (m_sim->isGameOver())
|
||
{
|
||
message = tr("Replay ended — Defeat");
|
||
painter.setPen(QColor(255, 255, 255));
|
||
}
|
||
else
|
||
{
|
||
message = tr("Replay ended");
|
||
painter.setPen(QColor(255, 255, 255));
|
||
}
|
||
QFont big = painter.font();
|
||
big.setPixelSize(28);
|
||
painter.setFont(big);
|
||
painter.drawText(rect(), Qt::AlignCenter, message);
|
||
}
|
||
|
||
painter.restore();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Input
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameWorldView::keyPressEvent(QKeyEvent* event)
|
||
{
|
||
if (event->isAutoRepeat())
|
||
{
|
||
QOpenGLWidget::keyPressEvent(event);
|
||
return;
|
||
}
|
||
|
||
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
|
||
// physical digit independent of keyboard layout and Shift (with Shift held, key()
|
||
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
|
||
const quint32 virtualKey = event->nativeVirtualKey();
|
||
if (virtualKey >= 0x31 && virtualKey <= 0x39)
|
||
{
|
||
const int digit = static_cast<int>(virtualKey - 0x30);
|
||
const bool shift = (event->modifiers() & Qt::ShiftModifier) != 0;
|
||
std::optional<BuildingType> type;
|
||
if (!shift)
|
||
{
|
||
switch (digit)
|
||
{
|
||
case 1: type = BuildingType::Belt; break;
|
||
case 2: type = BuildingType::Splitter; break;
|
||
case 3: type = BuildingType::TunnelEntry; break;
|
||
case 4: type = BuildingType::TunnelExit; break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
switch (digit)
|
||
{
|
||
case 1: type = BuildingType::Miner; break;
|
||
case 2: type = BuildingType::Smelter; break;
|
||
case 3: type = BuildingType::Assembler; break;
|
||
case 4: type = BuildingType::Shipyard; break;
|
||
case 5: type = BuildingType::SalvageBay; break;
|
||
case 6: type = BuildingType::ReprocessingPlant; break;
|
||
}
|
||
}
|
||
if (type.has_value())
|
||
{
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<BuildHotkeyPressedEvent>(*type));
|
||
return;
|
||
}
|
||
}
|
||
|
||
switch (event->key())
|
||
{
|
||
case Qt::Key_A:
|
||
m_scrollLeft = true;
|
||
break;
|
||
case Qt::Key_D:
|
||
m_scrollRight = true;
|
||
break;
|
||
case Qt::Key_Space:
|
||
if (m_gameSpeedMultiplier > 0.0)
|
||
{
|
||
m_prevNonZeroSpeed = m_gameSpeedMultiplier;
|
||
setGameSpeed(0.0);
|
||
}
|
||
else
|
||
{
|
||
setGameSpeed(m_prevNonZeroSpeed);
|
||
}
|
||
break;
|
||
case Qt::Key_W:
|
||
stepSpeed(+1);
|
||
break;
|
||
case Qt::Key_S:
|
||
stepSpeed(-1);
|
||
break;
|
||
case Qt::Key_R:
|
||
rotateGhost(event->modifiers() & Qt::ShiftModifier);
|
||
break;
|
||
case Qt::Key_Q:
|
||
if (m_builderType.has_value()) { exitBuilderMode(); }
|
||
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
||
else { toggleDemolishMode(); }
|
||
break;
|
||
case Qt::Key_T:
|
||
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
|
||
// The BlueprintPanel owns the selection and blueprint-capture logic; it decides
|
||
// whether anything placeable is selected and drives placement mode from there.
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<TemporaryBlueprintRequestedEvent>());
|
||
break;
|
||
case Qt::Key_Escape:
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<EscapeMenuRequestedEvent>());
|
||
break;
|
||
case Qt::Key_F3:
|
||
m_debugDraw = !m_debugDraw;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
|
||
break;
|
||
case Qt::Key_F4:
|
||
EventManager::getInstance()->addEvent(std::make_shared<TracePrintRequestedEvent>());
|
||
break;
|
||
default:
|
||
QOpenGLWidget::keyPressEvent(event);
|
||
break;
|
||
}
|
||
}
|
||
|
||
void GameWorldView::keyReleaseEvent(QKeyEvent* event)
|
||
{
|
||
if (event->isAutoRepeat())
|
||
{
|
||
QOpenGLWidget::keyReleaseEvent(event);
|
||
return;
|
||
}
|
||
if (event->key() == Qt::Key_A) { m_scrollLeft = false; }
|
||
if (event->key() == Qt::Key_D) { m_scrollRight = false; }
|
||
// Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG).
|
||
if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); }
|
||
QOpenGLWidget::keyReleaseEvent(event);
|
||
}
|
||
|
||
void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||
{
|
||
if (event->button() != Qt::LeftButton)
|
||
{
|
||
if (event->button() == Qt::RightButton)
|
||
{
|
||
if (m_builderType.has_value()) { exitBuilderMode(); }
|
||
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
||
else if (m_demolishMode) { toggleDemolishMode(); }
|
||
else if (event->modifiers() & Qt::ShiftModifier)
|
||
{
|
||
// Shift + right-click copies a building's settings, but only in the
|
||
// default selection mode (REQ-BLD-COPY-CONFIG).
|
||
const QPoint tile = widgetToTile(event->pos());
|
||
BuildingId id = buildingAtTile(tile);
|
||
if (id == kInvalidBuildingId) { id = siteAtTile(tile); }
|
||
if (id != kInvalidBuildingId) { copyConfigFrom(id); }
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const QPoint tile = widgetToTile(event->pos());
|
||
|
||
if (m_builderType.has_value())
|
||
{
|
||
const BuildingType type = *m_builderType;
|
||
if (type == BuildingType::Belt)
|
||
{
|
||
m_dragging = true;
|
||
m_beltDragTiles.clear();
|
||
placeAtTile(tile);
|
||
}
|
||
else
|
||
{
|
||
placeAtTile(tile);
|
||
}
|
||
}
|
||
else if (m_blueprintMode.has_value())
|
||
{
|
||
placeBlueprintAtTile(tile);
|
||
}
|
||
else if (m_demolishMode)
|
||
{
|
||
// Start a demolish box drag; a plain click resolves as a 1x1 box on
|
||
// release (REQ-BLD-DEMOLISH-CLICK, REQ-BLD-DEMOLISH-BOX).
|
||
m_boxSelecting = true;
|
||
m_boxStartTile = tile;
|
||
m_boxCurrentTile = tile;
|
||
}
|
||
else
|
||
{
|
||
// Shift + left-click applies the copied settings to a same-type building
|
||
// (REQ-BLD-COPY-CONFIG). Consumes the click so it does not change the
|
||
// selection. Only active in the default selection mode.
|
||
if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value())
|
||
{
|
||
BuildingId id = buildingAtTile(tile);
|
||
if (id == kInvalidBuildingId) { id = siteAtTile(tile); }
|
||
if (id != kInvalidBuildingId)
|
||
{
|
||
pasteConfigTo(id);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const QVector2D worldPos = widgetToWorld(event->pos());
|
||
const entt::entity hitEntity = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||
|
||
if (hitEntity != entt::null)
|
||
{
|
||
// Actors (ship/station) win over scrap and buildings (REQ-UI-SCRAP-CLICK-SELECT).
|
||
clearScrapSelection();
|
||
m_selectedBuildingIds.clear();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
m_selectedEntity = hitEntity;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<EntitySelectedEvent>(hitEntity));
|
||
}
|
||
else
|
||
{
|
||
if (m_selectedEntity.has_value())
|
||
{
|
||
m_selectedEntity = std::nullopt;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<EntitySelectedEvent>(std::nullopt));
|
||
}
|
||
|
||
BuildingId id = buildingAtTile(tile);
|
||
if (id == kInvalidBuildingId)
|
||
{
|
||
id = siteAtTile(tile);
|
||
}
|
||
if (id != kInvalidBuildingId)
|
||
{
|
||
// A building/construction site outranks scrap (REQ-UI-SCRAP-CLICK-SELECT).
|
||
clearScrapSelection();
|
||
if (event->modifiers() & Qt::ControlModifier)
|
||
{
|
||
bool found = false;
|
||
std::vector<BuildingId> newSel;
|
||
for (BuildingId sel : m_selectedBuildingIds)
|
||
{
|
||
if (sel == id) { found = true; }
|
||
else { newSel.push_back(sel); }
|
||
}
|
||
if (!found) { newSel.push_back(id); }
|
||
m_selectedBuildingIds = newSel;
|
||
}
|
||
else
|
||
{
|
||
m_selectedBuildingIds = { id };
|
||
}
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
}
|
||
else if (const entt::entity scrapHit =
|
||
scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null)
|
||
{
|
||
// Scrap forms its own selection category; picking it clears any
|
||
// building selection (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
|
||
if (!m_selectedBuildingIds.empty())
|
||
{
|
||
m_selectedBuildingIds.clear();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
}
|
||
if (event->modifiers() & Qt::ControlModifier)
|
||
{
|
||
bool found = false;
|
||
std::vector<entt::entity> newSel;
|
||
for (entt::entity sel : m_selectedScrap)
|
||
{
|
||
if (sel == scrapHit) { found = true; }
|
||
else { newSel.push_back(sel); }
|
||
}
|
||
if (!found) { newSel.push_back(scrapHit); }
|
||
m_selectedScrap = newSel;
|
||
}
|
||
else
|
||
{
|
||
m_selectedScrap = { scrapHit };
|
||
}
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||
}
|
||
else
|
||
{
|
||
if (!(event->modifiers() & Qt::ControlModifier))
|
||
{
|
||
m_selectedBuildingIds.clear();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
clearScrapSelection();
|
||
}
|
||
m_boxSelecting = true;
|
||
m_boxStartTile = tile;
|
||
m_boxCurrentTile = tile;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
||
{
|
||
const QPoint tile = widgetToTile(event->pos());
|
||
|
||
if (m_builderType.has_value())
|
||
{
|
||
m_ghostTile = tile;
|
||
m_ghostValid = isValidPlacement(*m_builderType, tile, m_ghostRotation);
|
||
|
||
if (m_dragging)
|
||
{
|
||
placeAtTile(tile);
|
||
}
|
||
}
|
||
else if (m_blueprintMode.has_value())
|
||
{
|
||
m_blueprintGhostTile = tile;
|
||
}
|
||
else if (m_demolishMode)
|
||
{
|
||
m_demolishHoverBuildingId = buildingAtTile(tile);
|
||
if (m_boxSelecting) { m_boxCurrentTile = tile; }
|
||
}
|
||
else if (m_boxSelecting)
|
||
{
|
||
m_boxCurrentTile = tile;
|
||
}
|
||
}
|
||
|
||
void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||
{
|
||
if (event->button() != Qt::LeftButton) { return; }
|
||
|
||
if (m_dragging)
|
||
{
|
||
m_dragging = false;
|
||
m_beltDragTiles.clear();
|
||
}
|
||
|
||
if (m_boxSelecting)
|
||
{
|
||
m_boxSelecting = false;
|
||
|
||
const std::vector<BuildingId> boxIds =
|
||
buildingsInBox(m_boxStartTile, m_boxCurrentTile);
|
||
|
||
if (m_demolishMode)
|
||
{
|
||
// Demolish every covered building/site; the HQ is protected
|
||
// (REQ-BLD-DEMOLISH, REQ-BLD-DEMOLISH-BOX).
|
||
for (BuildingId id : boxIds)
|
||
{
|
||
const Building* b = m_sim->getBuildings().findBuilding(id);
|
||
if (b && b->type == BuildingType::Hq) { continue; }
|
||
std::shared_ptr<DemolishCommand> command =
|
||
std::make_shared<DemolishCommand>();
|
||
command->id = id;
|
||
enqueueCommand(command);
|
||
}
|
||
m_demolishHoverBuildingId = kInvalidBuildingId;
|
||
return;
|
||
}
|
||
|
||
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
|
||
|
||
if (!boxIds.empty())
|
||
{
|
||
// A box covering any building selects buildings; scrap in the box is
|
||
// ignored (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
|
||
clearScrapSelection();
|
||
if (!ctrl)
|
||
{
|
||
m_selectedBuildingIds = boxIds;
|
||
}
|
||
else
|
||
{
|
||
for (BuildingId id : boxIds)
|
||
{
|
||
bool found = false;
|
||
for (BuildingId sel : m_selectedBuildingIds)
|
||
{
|
||
if (sel == id) { found = true; break; }
|
||
}
|
||
if (!found) { m_selectedBuildingIds.push_back(id); }
|
||
}
|
||
}
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
return;
|
||
}
|
||
|
||
// No buildings in the box: a scrap-only box selects the scrap it covers
|
||
// (REQ-UI-SCRAP-MULTI-SELECT).
|
||
const std::vector<entt::entity> boxScrap =
|
||
scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
|
||
if (!boxScrap.empty())
|
||
{
|
||
if (!m_selectedBuildingIds.empty())
|
||
{
|
||
m_selectedBuildingIds.clear();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
}
|
||
if (!ctrl)
|
||
{
|
||
m_selectedScrap = boxScrap;
|
||
}
|
||
else
|
||
{
|
||
for (entt::entity e : boxScrap)
|
||
{
|
||
bool found = false;
|
||
for (entt::entity sel : m_selectedScrap)
|
||
{
|
||
if (sel == e) { found = true; break; }
|
||
}
|
||
if (!found) { m_selectedScrap.push_back(e); }
|
||
}
|
||
}
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||
return;
|
||
}
|
||
|
||
// Empty box: a plain (non-additive) drag clears the current selection.
|
||
if (!ctrl)
|
||
{
|
||
m_selectedBuildingIds.clear();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||
clearScrapSelection();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Methods (formerly slots)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameWorldView::toggleDemolishMode()
|
||
{
|
||
if (m_demolishMode)
|
||
{
|
||
m_demolishMode = false;
|
||
m_demolishHoverBuildingId = kInvalidBuildingId;
|
||
}
|
||
else
|
||
{
|
||
if (m_builderType.has_value()) { exitBuilderMode(); }
|
||
if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
|
||
m_demolishMode = true;
|
||
}
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<DemolishModeChangedEvent>(m_demolishMode));
|
||
}
|
||
|
||
void GameWorldView::rotateGhost(bool clockwise)
|
||
{
|
||
if (m_builderType.has_value())
|
||
{
|
||
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
|
||
: rotateCounterClockwise(m_ghostRotation);
|
||
m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
|
||
}
|
||
else if (m_blueprintMode.has_value())
|
||
{
|
||
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
|
||
{
|
||
const BuildingDef* def = findBuildingDef(bb.type);
|
||
if (!def) { continue; }
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
|
||
int minX = INT_MAX, minY = INT_MAX;
|
||
for (const QPoint& cell : mask.bodyCells)
|
||
{
|
||
const QPoint abs = bb.offset + cell;
|
||
if (clockwise)
|
||
{
|
||
minX = std::min(minX, -abs.y());
|
||
minY = std::min(minY, abs.x());
|
||
}
|
||
else
|
||
{
|
||
minX = std::min(minX, abs.y());
|
||
minY = std::min(minY, -abs.x());
|
||
}
|
||
}
|
||
bb.offset = QPoint(minX, minY);
|
||
bb.rotation = clockwise ? rotateClockwise(bb.rotation)
|
||
: rotateCounterClockwise(bb.rotation);
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameWorldView::copyConfigFrom(BuildingId id)
|
||
{
|
||
const std::optional<BuildingConfig> config = readBuildingConfig(*m_sim, id);
|
||
if (!config.has_value()) { return; }
|
||
|
||
// Only cache when there is something to copy: a selected recipe / schematic
|
||
// (Miner, Assembler, Shipyard) or any Splitter (empty filters = accept-all).
|
||
// Building types with no settings (Smelter, Reprocessing Plant, Salvage Bay,
|
||
// belts / tunnels, HQ) leave any existing cache untouched (REQ-BLD-COPY-CONFIG).
|
||
if (!config->recipeId.has_value() && !config->isSplitter) { return; }
|
||
|
||
m_copiedConfig = config;
|
||
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
|
||
}
|
||
|
||
void GameWorldView::pasteConfigTo(BuildingId id)
|
||
{
|
||
if (!m_copiedConfig.has_value()) { return; }
|
||
|
||
const std::optional<BuildingConfig> target = readBuildingConfig(*m_sim, id);
|
||
if (!target.has_value() || target->type != m_copiedConfig->type) { return; }
|
||
|
||
// The paste applies below; flash the target to confirm (REQ-BLD-COPY-CONFIG-FEEDBACK).
|
||
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
|
||
|
||
const BuildingConfig& source = *m_copiedConfig;
|
||
|
||
// The cached settings were valid on a same-type source building, so they are
|
||
// valid and available on the target: unlock state is global, so a selected
|
||
// recipe / schematic (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC) and splitter
|
||
// filter item types (REQ-LOCK-UI-SPLITTER) remain unlocked. Applying reuses the
|
||
// same configuration commands as the selected building panel, inheriting their
|
||
// buffer-clearing and mid-cycle-cancel semantics (REQ-MAT-INPUT-BUFFER,
|
||
// REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||
if (source.isSplitter)
|
||
{
|
||
// Operational splitters are configured by tile; sites by BuildingId
|
||
// (mirrors SelectedBuildingPanel::onSplitterFilterChanged).
|
||
if (const Building* building = m_sim->getBuildings().findBuilding(id))
|
||
{
|
||
std::shared_ptr<SetSplitterFiltersCommand> command =
|
||
std::make_shared<SetSplitterFiltersCommand>();
|
||
command->tile = building->anchor;
|
||
command->filterA = source.splitterFilterA;
|
||
command->filterB = source.splitterFilterB;
|
||
enqueueCommand(command);
|
||
}
|
||
else
|
||
{
|
||
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
|
||
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||
command->id = id;
|
||
command->filterA = source.splitterFilterA;
|
||
command->filterB = source.splitterFilterB;
|
||
enqueueCommand(command);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (source.recipeId.has_value())
|
||
{
|
||
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
||
command->id = id;
|
||
command->recipeId = *source.recipeId;
|
||
enqueueCommand(command);
|
||
}
|
||
|
||
// For a shipyard the schematic (above) must be applied before its module
|
||
// layout, so both commands drain in order at the next tick boundary.
|
||
if (source.type == BuildingType::Shipyard && source.shipLayout.has_value())
|
||
{
|
||
std::shared_ptr<SetShipLayoutCommand> command =
|
||
std::make_shared<SetShipLayoutCommand>();
|
||
command->id = id;
|
||
command->layout = *source.shipLayout;
|
||
enqueueCommand(command);
|
||
}
|
||
}
|
||
|
||
void GameWorldView::enterBuilderMode(BuildingType type)
|
||
{
|
||
m_builderType = type;
|
||
m_ghostRotation = Rotation::East;
|
||
m_ghostValid = false;
|
||
m_demolishMode = false;
|
||
m_blueprintMode.reset();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<DemolishModeChangedEvent>(false));
|
||
}
|
||
|
||
void GameWorldView::enterBlueprintMode(Blueprint blueprint)
|
||
{
|
||
if (m_builderType.has_value()) { exitBuilderMode(); }
|
||
m_demolishMode = false;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<DemolishModeChangedEvent>(false));
|
||
m_blueprintGhostTile = m_ghostTile;
|
||
m_blueprintMode = std::move(blueprint);
|
||
}
|
||
|
||
void GameWorldView::exitBlueprintMode()
|
||
{
|
||
m_blueprintMode.reset();
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<BlueprintModeExitedEvent>());
|
||
}
|
||
|
||
void GameWorldView::exitBuilderMode()
|
||
{
|
||
m_builderType.reset();
|
||
m_beltDragTiles.clear();
|
||
m_dragging = false;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<BuilderModeExitedEvent>());
|
||
}
|
||
|
||
double GameWorldView::getGameSpeed() const
|
||
{
|
||
return m_gameSpeedMultiplier;
|
||
}
|
||
|
||
bool GameWorldView::isDebugDrawEnabled() const
|
||
{
|
||
return m_debugDraw;
|
||
}
|
||
|
||
void GameWorldView::resetFrameTimer()
|
||
{
|
||
m_frameTimer.restart();
|
||
}
|
||
|
||
void GameWorldView::setGameSpeed(double multiplier)
|
||
{
|
||
m_gameSpeedMultiplier = multiplier;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<GameSpeedChangedEvent>(m_gameSpeedMultiplier));
|
||
}
|
||
|
||
void GameWorldView::resetForNewGame()
|
||
{
|
||
exitBuilderMode();
|
||
exitBlueprintMode();
|
||
m_activeBeams.clear();
|
||
m_schematicChoiceShown = false;
|
||
m_ghostRotation = Rotation::East;
|
||
m_ghostValid = false;
|
||
m_demolishMode = false;
|
||
m_demolishHoverBuildingId = kInvalidBuildingId;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<DemolishModeChangedEvent>(false));
|
||
m_selectedBuildingIds.clear();
|
||
m_copiedConfig = std::nullopt;
|
||
m_copyConfigFlashes.clear();
|
||
m_boxSelecting = false;
|
||
m_scrollXTiles = 0.0f;
|
||
m_scrollLeft = false;
|
||
m_scrollRight = false;
|
||
m_gameOverShown = false;
|
||
m_winShown = false;
|
||
m_prevNonZeroSpeed = 1.0;
|
||
m_lastTick = Tick(-1);
|
||
m_lastBlocks = -1;
|
||
m_lastExpansionCost = -1;
|
||
m_lastBossCounter = -1;
|
||
m_lastBossCountdown = Tick(-1);
|
||
m_lastArtifactCount = -1;
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<SelectionChangedEvent>(std::vector<BuildingId>{}));
|
||
setGameSpeed(1.0);
|
||
// Rebase the wall-clock time source so a fresh run starts from a clean time
|
||
// base. Without this, wall time accumulated while a modal (Game Over, Win, or
|
||
// the escape menu) was open would be converted into ticks on the new run,
|
||
// fast-forwarding it by the time the player spent in the dialog.
|
||
m_frameTimer.restart();
|
||
m_tickDriver.reset();
|
||
update();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Event handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
|
||
{
|
||
// Endpoint offset is a fraction of the target's visual size (REQ-SHP-FIRING-BEAM):
|
||
// half a ship's rendered radius, half a station's shorter footprint side, or
|
||
// half a scrap pile's rendered radius (scrap is drawn at getTilePx()*0.2).
|
||
float maxRadius = 0.125f;
|
||
if (m_sim->getAdmin().isValid(event->target)
|
||
&& m_sim->getAdmin().hasAll<StationBodyComponent>(event->target))
|
||
{
|
||
const StationBodyComponent& sb = m_sim->getAdmin().get<StationBodyComponent>(event->target);
|
||
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
|
||
maxRadius = shorter / 2.0f;
|
||
}
|
||
else if (m_sim->getAdmin().isValid(event->target)
|
||
&& m_sim->getAdmin().hasAll<ScrapDataComponent>(event->target))
|
||
{
|
||
maxRadius = 0.1f;
|
||
}
|
||
|
||
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
|
||
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
|
||
const float angle = angleDist(m_rng);
|
||
const float radius = radiusDist(m_rng);
|
||
|
||
ActiveBeam beam;
|
||
beam.event = *event;
|
||
beam.targetOffset = QVector2D(radius * std::cos(angle),
|
||
radius * std::sin(angle));
|
||
m_activeBeams.push_back(beam);
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
|
||
{
|
||
enterBuilderMode(event->type);
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
|
||
{
|
||
exitBuilderMode();
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> /*event*/)
|
||
{
|
||
toggleDemolishMode();
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
|
||
{
|
||
enterBlueprintMode(event->blueprint);
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
|
||
{
|
||
exitBlueprintMode();
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event)
|
||
{
|
||
setGameSpeed(event->multiplier);
|
||
}
|
||
|
||
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
|
||
{
|
||
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
|
||
// event; GameWorldView owns the CommandManager and enqueues them.
|
||
if (event->command && event->command->kind == CommandKind::Reset)
|
||
{
|
||
m_viewResetPending = true;
|
||
}
|
||
enqueueCommand(event->command);
|
||
}
|
||
|
||
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
|
||
{
|
||
m_commandManager.enqueue(std::move(command));
|
||
}
|
||
|
||
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
||
{
|
||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||
command->type = type;
|
||
command->anchor = anchor;
|
||
command->rotation = rotation;
|
||
enqueueCommand(command);
|
||
}
|
||
|
||
bool GameWorldView::canAfford(BuildingType type) const
|
||
{
|
||
const BuildingDef* def = findBuildingDef(type);
|
||
if (!def) { return false; }
|
||
return m_sim->getBuildingBlocksStock() >= def->cost;
|
||
}
|