Files
dota_factory/src/ui/GameWorldView.cpp
Malte Langkabel 25cb23f66c Redefine camera scroll as view center
m_scrollXTiles now stores the world-X (tiles) at the center of the
viewport instead of its left edge. A new viewLeftTiles() helper derives
the left edge for the world<->widget conversions and tile culling, so
the half-viewport math lives in one place.

The view now opens centered on the asteroid/space edge and pans left
until the buildable edge is centered and right until the enemy stations
are centered (revealing a little space beyond them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
2026-07-13 21:28:49 +02:00

2365 lines
83 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 <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#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->currentTick());
}
}
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();
}
// 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->currentTick();
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->currentTick();
const int newBlocks = m_sim->buildingBlocksStock();
const int newExpCost = m_sim->currentExpansionCost();
const int newBoss = m_sim->bossWaveCounter();
const Tick newCountdown = m_sim->bossCountdownTicks();
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->artifactCount() 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->artifactCount();
if (currentArtifactCount != m_lastArtifactCount)
{
m_lastArtifactCount = currentArtifactCount;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ArtifactCountChangedEvent>(
currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount));
}
update();
}
void GameWorldView::paintGL()
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false);
drawTiles(painter);
drawBuildings(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);
drawReplayOverlay(painter);
}
// ---------------------------------------------------------------------------
// Coordinate helpers
// ---------------------------------------------------------------------------
float GameWorldView::tilePx() const
{
if (m_config->world.heightTiles <= 0) { return 1.0f; }
return static_cast<float>(height()) / static_cast<float>(m_config->world.heightTiles);
}
float GameWorldView::viewportWidthTiles() const
{
return static_cast<float>(width()) / tilePx();
}
float GameWorldView::viewLeftTiles() const
{
return m_scrollXTiles - viewportWidthTiles() / 2.0f;
}
QPointF GameWorldView::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>((worldPos.x() - viewLeftTiles()) * tilePx()),
static_cast<qreal>(worldPos.y() * tilePx()));
}
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()) / tilePx() + viewLeftTiles();
const float wy = static_cast<float>(widgetPt.y()) / tilePx();
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()) / tilePx() + viewLeftTiles();
const float wy = static_cast<float>(widgetPt.y()) / tilePx();
return QVector2D(wx, wy);
}
QRectF GameWorldView::tileRect(QPoint tile) const
{
const QPointF tl = tileToWidget(tile);
return QRectF(tl.x(), tl.y(),
static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx()));
}
QRect GameWorldView::viewportRect() const
{
const int left = static_cast<int>(std::floor(viewLeftTiles())) - 1;
const int top = 0;
const int right = static_cast<int>(std::ceil(viewLeftTiles() + viewportWidthTiles())) + 1;
const int bottom = m_config->world.heightTiles;
return QRect(left, top, right - left, bottom - top);
}
float GameWorldView::asteroidLeftEdge() const
{
float leftX = -static_cast<float>(m_sim->currentAsteroidWidth_tiles());
for (const Building& b : m_sim->buildings().allBuildings())
{
for (const QPoint& cell : b.bodyCells)
{
if (static_cast<float>(cell.x()) < leftX)
{
leftX = static_cast<float>(cell.x());
}
}
}
return leftX;
}
float GameWorldView::enemyStationRightEdge() const
{
float rightX = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles
+ m_config->world.regions.contestZoneWidth_tiles);
m_sim->admin().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 = enemyStationRightEdge();
// 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 = asteroidLeftEdge();
const float rightBound = enemyStationRightEdge();
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->buildings().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->buildings().isTileOccupied(anchor + relCell))
{
anyOccupied = true;
break;
}
}
if (anyOccupied)
{
return m_sim->buildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
}
return true;
}
BuildingId GameWorldView::buildingAtTile(QPoint tile) const
{
for (const Building& b : m_sim->buildings().allBuildings())
{
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->buildings().allSites())
{
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->buildings().allBuildings())
{
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->buildings().allSites())
{
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->admin().isValid(entity) || !m_sim->admin().hasAll<PositionComponent>(entity))
{
return std::nullopt;
}
return m_sim->admin().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->scraps().allScrapInfo())
{
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->buildings().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->buildingBlocksStock() < totalCost) { return; }
for (const BlueprintBuilding& bb : bp.buildings)
{
const QPoint anchor = center + bb.offset;
const std::optional<BuildingId> rotateTarget =
m_sim->buildings().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->buildings().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->buildings().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->buildings().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 = tilePx();
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(viewLeftTiles())) - 1;
const int rightTile = leftTile + static_cast<int>(std::ceil(viewportWidthTiles())) + 2;
const int bottomTile = m_config->world.heightTiles;
painter.setPen(Qt::NoPen);
for (int x = leftTile; x <= rightTile; ++x)
{
const QColor& fill = (x < 0)
? m_visuals->asteroid.fill
: m_visuals->space.fill;
for (int y = 0; y < bottomTile; ++y)
{
painter.fillRect(tileRect(QPoint(x, y)), fill);
}
}
}
void GameWorldView::drawBuildings(QPainter& painter)
{
for (const Building& b : m_sim->buildings().allBuildings())
{
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>(tilePx()),
b.footprint.height() * static_cast<qreal>(tilePx()));
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->admin().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->buildings().allSites())
{
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>(tilePx()),
s.footprint.height() * static_cast<qreal>(tilePx()));
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->currentTick()
- (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->buildings().findBuilding(id))
{
anchor = b->anchor;
footprint = b->footprint;
}
else if (const ConstructionSite* s = m_sim->buildings().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>(tilePx()),
footprint->height() * static_cast<qreal>(tilePx()));
}
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 tilePx()*0.2, matching drawScrap) (REQ-UI-SCRAP-CLICK-SELECT).
if (!m_selectedScrap.empty())
{
const qreal outlineRadius = static_cast<qreal>(tilePx() * 0.2f) + 3.0;
for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
{
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->buildings().allBuildings())
{
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->buildings().allSites())
{
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::drawBeltItems(QPainter& painter)
{
const float halfPx = tilePx() * 0.5f * 0.5f;
const QRect vr = viewportRect();
m_sim->belts().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 = tilePx() * 0.2f;
for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
{
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->admin().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>(tilePx()),
sb.footprint.height() * static_cast<qreal>(tilePx()));
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->admin().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 = tilePx() * 0.45f;
const float side = tilePx() * 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>(tilePx()) * 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->admin().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>(tilePx());
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->admin().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->admin().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->admin().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->threatLevel(), 0, 'f', 1),
tr("Time until Wave: %1s")
.arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1),
tr("Threat Accumulation Rate: %1 threat/s")
.arg(m_sim->threatAccumulationRate(), 0, 'f', 1),
tr("Max Factory Production: %1 threat/s")
.arg(m_sim->maxFactoryProductionThreatRate(), 0, 'f', 1),
tr("Current Factory Production: %1 threat/s")
.arg(m_sim->currentFactoryProductionThreatRate(), 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)
{
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;
}
painter.setPen(QPen(color, m_visuals->beams.widthPx));
painter.drawLine(worldToWidget(*shooterPos),
worldToWidget(*targetPos + beam.targetOffset));
}
}
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->buildings().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->buildings().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->buildings().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>(tilePx()),
(maxCell.y() - minCell.y() + 1) * static_cast<qreal>(tilePx()));
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::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 || type == BuildingType::Splitter)
{
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->admin(), 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->admin(), 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->buildings().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->admin(), 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->buildings().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::gameSpeed() 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);
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 tilePx()*0.2).
float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
{
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(event->target);
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f;
}
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().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->buildingBlocksStock() >= def->cost;
}