#include "GameWorldView.h" #include "PlacementRules.h" #include "FactoryQueries.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "Building.h" #include "BuildingSystem.h" #include "Command.h" #include "ReplayPlayer.h" #include "ReplayReader.h" #include "ReplayRecorder.h" #include "DeconstructModeChangedEvent.h" #include "EntityHitTest.h" #include "EventManager.h" #include "FactionComponent.h" #include "GameOverEvent.h" #include "HealthComponent.h" #include "ItemIconCache.h" #include "PositionComponent.h" #include "DebrisSystem.h" #include "SelectionChangedEvent.h" #include "ShipIdentityComponent.h" #include "ShipSystem.h" #include "Simulation.h" #include "StationBodyComponent.h" #include "DebrisComponent.h" #include "SurfaceMask.h" #include "Tick.h" #include "TunnelCompletion.h" #include "EscapeMenuRequestedEvent.h" #include "TracePrintRequestedEvent.h" #include "BuildHotkeyPressedEvent.h" #include "GameResetEvent.h" #include "BossWaveUpdatedEvent.h" #include "BuilderModeExitedEvent.h" #include "BlueprintModeExitedEvent.h" #include "BuildingBlocksChangedEvent.h" #include "UnlockedBuildingsChangedEvent.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 filterUnlockedItems(const std::vector& filter, const Simulation& sim) { std::vector 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; } } // namespace GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, const VisualsConfig* visuals, const std::string& configDir, ItemIconCache* itemIcons, 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_camera(config->world.scroll, config->world.regions) , m_debugDraw(false) , m_rng(std::random_device{}()) , m_boxSelecting(false) , m_gameOverShown(false) , m_schematicChoiceShown(false) { setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); m_renderer = std::make_unique(*sim, *visuals, itemIcons, configDir); 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(*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( 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(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()); } const int ticks = m_tickDriver.advance( static_cast(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 fires = m_sim->drainBeamFiredEvents(); for (const BeamFiredEvent& fe : fires) { EventManager::getInstance()->sendEventImmediately( std::make_shared(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 live; for (const ActiveBeam& b : m_activeBeams) { if (now - b.event.emittedAt < kBeamLifetimeTicks) { live.push_back(b); } } m_activeBeams = std::move(live); } // Drop selected debris that were collected or despawned this frame, so the // panel stops counting them and the selection empties out (REQ-UI-DEBRIS-CLICK-SELECT). pruneDespawnedDebris(); pruneDespawnedActors(); // 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 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 { const bool viewMoved = m_camera.advance(m_panDirection, elapsed, getScrollBounds()); // 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 && viewMoved) { m_boxCurrentTile = getCoordinates().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()); } if (newBlocks != m_lastBlocks) { m_lastBlocks = newBlocks; EventManager::getInstance()->sendEventImmediately( std::make_shared()); } if (newExpCost != m_lastExpansionCost) { m_lastExpansionCost = newExpCost; EventManager::getInstance()->sendEventImmediately( std::make_shared()); } if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown) { m_lastBossCounter = newBoss; m_lastBossCountdown = newCountdown; EventManager::getInstance()->sendEventImmediately( std::make_shared()); } // Unlocked building set changes only after a drop is applied or on Restart // (REQ-LOCK-BUILDING); the build bar rebuilds its visible buttons. int newUnlockedBuildingCount = 0; for (const BuildingDef& def : m_sim->getConfig().buildings.buildings) { if (m_sim->isBuildingUnlocked(def.type)) { ++newUnlockedBuildingCount; } } if (newUnlockedBuildingCount != m_lastUnlockedBuildingCount) { m_lastUnlockedBuildingCount = newUnlockedBuildingCount; EventManager::getInstance()->sendEventImmediately( std::make_shared()); } } // 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(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()); } // Game over check if (m_sim->isGameOver() && !m_gameOverShown) { m_gameOverShown = true; m_gameSpeedMultiplier = 0.0; EventManager::getInstance()->sendEventImmediately( std::make_shared()); } } // 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()); } update(); } void GameWorldView::paintGL() { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); m_renderer->render(painter, getCoordinates(), makeRenderFrame()); // Screen-anchored chrome over the finished world. drawScreenSpace(painter); if (m_debugDraw) { drawDebugOverlay(painter); } drawPauseBorder(painter); drawDeconstructBorder(painter); drawReplayOverlay(painter); } WorldRenderFrame GameWorldView::makeRenderFrame() const { return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, m_copiedConfig, m_copyConfigFlashes, m_boxSelecting, m_boxStartTile, m_boxCurrentTile, m_debugDraw}; } // --------------------------------------------------------------------------- // Coordinate helpers // --------------------------------------------------------------------------- WorldCoordinates GameWorldView::getCoordinates() const { return WorldCoordinates::scrolling(size(), m_config->world.heightTiles, m_camera.getViewCenterXTiles()); } float GameWorldView::getAsteroidLeftEdge() const { float leftX = -static_cast(m_sim->getCurrentAsteroidWidth_tiles()); for (const Building& b : getAllBuildings(m_sim->getFactoryState())) { for (const QPoint& cell : b.bodyCells) { if (static_cast(cell.x()) < leftX) { leftX = static_cast(cell.x()); } } } return leftX; } float GameWorldView::getEnemyStationRightEdge() const { float rightX = static_cast(m_config->world.regions.playerBufferWidth_tiles + m_config->world.regions.contestZoneWidth_tiles); m_sim->getAdmin().forEach( [&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(cell.x() + 1); if (cx > rightX) { rightX = cx; } } }); return rightX; } ScrollBounds GameWorldView::getScrollBounds() const { // The camera clamps its view center to these, 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). return ScrollBounds{getAsteroidLeftEdge(), getEnemyStationRightEdge()}; } // --------------------------------------------------------------------------- // Placement helpers // --------------------------------------------------------------------------- bool GameWorldView::canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const { return canPlaceBuilding(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot); } std::optional GameWorldView::buildingAtTile(QPoint tile) const { for (const Building& b : getAllBuildings(m_sim->getFactoryState())) { for (const QPoint& cell : b.bodyCells) { if (cell == tile) { return b.id; } } } return std::nullopt; } std::optional GameWorldView::siteAtTile(QPoint tile) const { for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState())) { for (const QPoint& cell : s.bodyCells) { if (cell == tile) { return s.id; } } } return std::nullopt; } void GameWorldView::pruneDespawnedDebris() { const std::vector& selected = m_selection.getSelectedDebris(); if (selected.empty()) { return; } // Keeps the debris that still exist, in the order the simulation reports them. std::vector live; for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin())) { if (std::find(selected.begin(), selected.end(), info.entity) != selected.end()) { live.push_back(info.entity); } } if (live.size() != selected.size()) { m_selection.setSelectedDebris(std::move(live)); } } void GameWorldView::pruneDespawnedActors() { const std::vector& selected = m_selection.getSelectedActors(); if (selected.empty()) { return; } EntityAdmin& admin = m_sim->getAdmin(); std::vector live; for (entt::entity e : selected) { if (admin.isValid(e) && admin.hasAll(e) && admin.get(e).hp > 0.0f) { live.push_back(e); } } m_selection.setSelectedActors(std::move(live)); } 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_buildMode.getBlueprint(); for (const BlueprintBuilding& bb : bp.buildings) { // Locked building types are excluded from this placement entirely // (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here. if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } if (!canPlaceBuildingHere(bb.type, center + bb.offset, bb.rotation)) { return; } } // Cost only applies to buildings that are genuinely new (not rotate-in-place), // and excludes locked building types (REQ-LOCK-UI-BLUEPRINT). int totalCost = 0; for (const BlueprintBuilding& bb : bp.buildings) { if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value()) { continue; } const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type); if (def) { totalCost += def->cost; } } if (m_sim->getBuildingBlocksStock() < totalCost) { return; } for (const BlueprintBuilding& bb : bp.buildings) { if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = center + bb.offset; const std::optional rotateTarget = findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation); if (rotateTarget.has_value()) { std::shared_ptr rotateCommand = std::make_shared(); 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 command = std::make_shared(); 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::updateTunnelGhost() { // The connection preview and entry/exit switch only apply at a valid placement // (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry. if (!m_buildMode.isGhostValid()) { m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt); return; } const TunnelTileMap tunnels = collectTunnelTiles(m_sim->getFactoryState()); const TunnelLookup lookup = makeTunnelLookup(tunnels); const TunnelCompletion completion = resolveTunnelCompletion(lookup, m_buildMode.getGhostTile(), m_buildMode.getGhostRotation(), m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos); m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile); } void GameWorldView::placeAtTile(QPoint tile) { if (!m_buildMode.isBuilderMode()) { return; } const BuildingType type = m_buildMode.getEffectiveBuilderType(); const Rotation rotation = m_buildMode.getGhostRotation(); if (!canPlaceBuildingHere(type, tile, rotation)) { return; } const std::optional rotateTarget = findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, rotation); if (rotateTarget.has_value()) { std::shared_ptr command = std::make_shared(); command->id = *rotateTarget; command->newRotation = rotation; enqueueCommand(command); return; } // For the splitter and tunnels, pre-validate occupancy + affordability so the // optimistic UI update matches what the deferred command will do — isValidPlacement // (above) already covered terrain/bounds. In tunnel mode the resolved type (entry // or exit) is placed as-is (REQ-BLD-TUNNEL-MODE); there is no post-placement toggle. // Belts are placed via the drag path (applyBeltDragPath), not here. if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { if (!isTileOccupied(m_sim->getFactoryState(), tile) && canAfford(type)) { enqueuePlaceBuilding(type, tile, rotation); } } else { enqueuePlaceBuilding(type, tile, rotation); } } // --------------------------------------------------------------------------- // Belt drag placement (REQ-BLD-BELT-DRAG) // --------------------------------------------------------------------------- void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) { QPoint endTile = cursorTile; std::optional forcedEndRotation; // If the cursor is over a non-belt building or construction site, snap the end // tile to the input-capable adjacent tile closest to the cursor, pointing into // the target (REQ-BLD-BELT-DRAG). std::optional targetId = buildingAtTile(cursorTile); std::optional targetType; if (targetId.has_value()) { if (const Building* building = findBuilding(m_sim->getFactoryState(), *targetId)) { targetType = building->type; } } else if (std::optional siteId = siteAtTile(cursorTile); siteId.has_value()) { targetId = siteId; if (const ConstructionSite* site = findSite(m_sim->getFactoryState(), *siteId)) { targetType = site->type; } } if (targetId.has_value() && targetType.has_value() && *targetType != BuildingType::Belt) { const std::vector inputPorts = getInputPorts(m_sim->getFactoryState(), m_sim->getConfig(), *targetId); std::optional best; float bestDistanceSq = 0.0f; for (const Port& port : inputPorts) { const QVector2D center(static_cast(port.tile.x()) + 0.5f, static_cast(port.tile.y()) + 0.5f); const float distanceSq = (center - m_cursorWorldPos).lengthSquared(); if (!best.has_value() || distanceSq < bestDistanceSq) { best = port; bestDistanceSq = distanceSq; } } if (best.has_value()) { endTile = best->tile; forcedEndRotation = best->direction; } } std::vector path = computeBeltDragPath( m_buildMode.getBeltDragAnchor(), endTile, m_buildMode.getGhostRotation()); if (forcedEndRotation.has_value() && !path.empty()) { // The end tile points into the target, overriding its incoming-step // orientation (REQ-BLD-BELT-DRAG "Snapping to a building"). path.back().rotation = *forcedEndRotation; } m_buildMode.setBeltDragPath(std::move(path)); } std::vector GameWorldView::resolveBeltDragPath() const { return ::resolveBeltDragPath(m_buildMode.getBeltDragPath(), m_sim->getFactoryState(), m_sim->getConfig(), m_sim->getBuildingBlocksStock()); } void GameWorldView::applyBeltDragPath() { const std::vector resolved = resolveBeltDragPath(); for (std::size_t index = 0; index < resolved.size(); ++index) { const BeltDragResolved& item = resolved[index]; const BeltPathTile& entry = m_buildMode.getBeltDragPath()[index]; if (item.action == BeltTileAction::PlaceNew && item.affordable) { enqueuePlaceBuilding(BuildingType::Belt, entry.tile, entry.rotation); } else if (item.action == BeltTileAction::RotateInPlace) { std::shared_ptr command = std::make_shared(); command->id = *item.rotateId; command->newRotation = entry.rotation; enqueueCommand(command); } } } // --------------------------------------------------------------------------- // Port glyph helper // --------------------------------------------------------------------------- void GameWorldView::drawDebugOverlay(QPainter& painter) { 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::drawScreenSpace(QPainter& /*painter*/) { } void GameWorldView::drawPauseBorder(QPainter& painter) { if (m_gameSpeedMultiplier > 0.0) { return; } drawVignetteBorder(painter, QColor(0, 0, 0, 128)); // 50% black at the edge } void GameWorldView::drawDeconstructBorder(QPainter& painter) { if (!m_buildMode.isDeconstructMode()) { return; } // Reuse the deconstruct overlay color (with its configured alpha) as the edge color. drawVignetteBorder(painter, m_visuals->overlays.deconstructTint); } void GameWorldView::drawVignetteBorder(QPainter& painter, const QColor& edgeColor) { // Border thickness in pixels, capped so it never exceeds half the viewport on // very small windows (which would leave no un-tinted center). const qreal borderPx = std::min( 100.0, std::min(width(), height()) / 2.0); if (borderPx <= 0.0) { return; } const QColor& edge = edgeColor; // full color (and alpha) at the viewport edge QColor inner = edgeColor; inner.setAlpha(0); // same color, 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 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 desync = m_replayPlayer->getDesyncTick(); QString message; if (desync.has_value()) { message = tr("Desync at tick %1").arg(static_cast(*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; } // Keys are turned into actions and published by the input mapper // (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so // nothing is handled here directly. if (m_inputMapper.handleKeyPress(event)) { return; } QOpenGLWidget::keyPressEvent(event); } void GameWorldView::keyReleaseEvent(QKeyEvent* event) { if (event->isAutoRepeat()) { QOpenGLWidget::keyReleaseEvent(event); return; } if (m_inputMapper.handleKeyRelease(event)) { return; } // Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG). // Stays here rather than moving to the input mapper: Shift is the modifier of a // mouse gesture, not a keyboard action of its own. if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); } QOpenGLWidget::keyReleaseEvent(event); } void GameWorldView::focusOutEvent(QFocusEvent* event) { // Without this, holding A while a modal opens (the escape menu, a schematic // choice, game over) leaves the pan key held: the key-up goes to the dialog, // never here, and the view pans on its own once the dialog closes. Panning runs // on wall-clock time, so pausing does not mask it either. m_inputMapper.releaseAll(); QOpenGLWidget::focusOutEvent(event); } void GameWorldView::mousePressEvent(QMouseEvent* event) { const WorldCoordinates coordinates = getCoordinates(); if (event->button() != Qt::LeftButton) { if (event->button() == Qt::RightButton) { if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt()) { // Cancel the in-progress belt drag without placing anything; // stay in belt builder mode (REQ-BLD-BELT-DRAG). m_buildMode.cancelBeltDrag(); } else if (m_buildMode.getMode() != BuildMode::None) { m_buildMode.exitCurrentMode(); } 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 = coordinates.widgetToTile(event->pos()); std::optional id = buildingAtTile(tile); if (!id.has_value()) { id = siteAtTile(tile); } if (id.has_value()) { copyConfigFrom(*id); } } } return; } const QPoint tile = coordinates.widgetToTile(event->pos()); if (m_buildMode.isBuilderMode()) { if (m_buildMode.getBuilderType() == BuildingType::Belt) { // Deferred placement: start the drag and show the path ghost; nothing // is placed until release (REQ-BLD-BELT-DRAG). m_buildMode.beginBeltDrag(tile); m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); recomputeBeltDragPath(tile); } else { placeAtTile(tile); } } else if (m_buildMode.isBlueprintMode()) { placeBlueprintAtTile(tile); } else if (m_buildMode.isDeconstructMode()) { // Start a deconstruct box drag; a plain click resolves as a 1x1 box on // release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-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()) { std::optional id = buildingAtTile(tile); if (!id.has_value()) { id = siteAtTile(tile); } if (id.has_value()) { pasteConfigTo(*id); return; } } const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0; // Only a click that hit nothing starts a box drag. Starting one on a hit // would re-resolve the same object as a 1x1 box on release and undo the // click: a Ctrl+click would toggle the building off, then straight back on. if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl)) { // selectAtPoint has already cleared the selection unless Ctrl is // preserving it for an additive drag. m_boxSelecting = true; m_boxStartTile = tile; m_boxCurrentTile = tile; } } } bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive) { // Point hit-test precedence: buildings win over actors, which win over debris // (REQ-UI-SELECTION-CATEGORIES). What each hit then does to the existing // selection is the controller's business, not this method's. const SelectionMode mode = additive ? SelectionMode::Toggle : SelectionMode::Replace; std::optional buildingHit = buildingAtTile(tile); if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); } if (buildingHit.has_value()) { m_selection.selectBuildings({*buildingHit}, mode); return true; } const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos); if (actorHit != entt::null) { m_selection.selectFieldObjects({actorHit}, {}, mode); return true; } const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos); if (debrisHit != entt::null) { m_selection.selectFieldObjects({}, {debrisHit}, mode); return true; } // Empty space: a plain click clears the whole selection, Ctrl preserves it. if (!additive) { m_selection.clearAll(); } return false; } void GameWorldView::selectInBox(bool additive) { // Same precedence as a point click, over everything the box covers // (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). The only difference is the // mode: a Ctrl box adds and never deselects, where a Ctrl click toggles. const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace; const std::vector boxIds = buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); if (!boxIds.empty()) { m_selection.selectBuildings(boxIds, mode); return; } const std::vector boxActors = actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); const std::vector boxDebris = debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); if (!boxActors.empty() || !boxDebris.empty()) { m_selection.selectFieldObjects(boxActors, boxDebris, mode); return; } // Empty box: a plain (non-additive) drag clears the current selection. if (!additive) { m_selection.clearAll(); } } void GameWorldView::mouseMoveEvent(QMouseEvent* event) { const WorldCoordinates coordinates = getCoordinates(); const QPoint tile = coordinates.widgetToTile(event->pos()); m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); if (m_buildMode.isBuilderMode()) { m_buildMode.setGhostTile(tile); m_buildMode.setGhostValidity( canPlaceBuildingHere(m_buildMode.getBuilderType(), tile, m_buildMode.getGhostRotation())); if (m_buildMode.isTunnelMode()) { // Resolve entry vs exit and the completion partner for the new hover // position and sub-tile cursor (REQ-BLD-TUNNEL-MODE). updateTunnelGhost(); } if (m_buildMode.isDraggingBelt()) { // Belt drag: update the previewed path; placement happens on release // (REQ-BLD-BELT-DRAG). recomputeBeltDragPath(tile); } } else if (m_buildMode.isBlueprintMode()) { m_buildMode.setBlueprintGhostTile(tile); } else if (m_buildMode.isDeconstructMode()) { m_buildMode.setDeconstructHoverBuildingId(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_buildMode.isDraggingBelt()) { // Apply the previewed belt path now that the button is released // (REQ-BLD-BELT-DRAG). applyBeltDragPath(); m_buildMode.cancelBeltDrag(); } if (m_boxSelecting) { m_boxSelecting = false; const std::vector boxIds = buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); if (m_buildMode.isDeconstructMode()) { const FactoryState& factory = m_sim->getFactoryState(); // Split covered ids into construction sites (removed instantly) and // operational deconstructible buildings (the HQ is protected; player // defence stations are not Buildings and never appear in the box). std::vector sites; std::vector operational; for (BuildingId id : boxIds) { if (const Building* b = findBuilding(factory, id)) { if (b->type == BuildingType::Hq) { continue; } operational.push_back(id); } else if (findSite(factory, id)) { sites.push_back(id); } } // Sites: instant demolition with the full refund (REQ-BLD-DECONSTRUCT). for (BuildingId id : sites) { std::shared_ptr command = std::make_shared(); command->id = id; enqueueCommand(command); } // Operational buildings: if every covered one is already queued, // un-queue them all; otherwise queue each one not yet queued. A plain // click is the one-element case, giving the queue/un-queue toggle // (REQ-BLD-DECONSTRUCT-BOX, REQ-BLD-DECONSTRUCT-CLICK). bool allQueued = !operational.empty(); for (BuildingId id : operational) { if (!isQueuedForDeconstruction(factory, id)) { allQueued = false; break; } } for (BuildingId id : operational) { if (allQueued) { std::shared_ptr command = std::make_shared(); command->id = id; enqueueCommand(command); } else if (!isQueuedForDeconstruction(factory, id)) { std::shared_ptr command = std::make_shared(); command->id = id; enqueueCommand(command); } } m_buildMode.setDeconstructHoverBuildingId(std::nullopt); return; } selectInBox((event->modifiers() & Qt::ControlModifier) != 0); } } // --------------------------------------------------------------------------- // Methods (formerly slots) // --------------------------------------------------------------------------- void GameWorldView::rotateGhost(bool clockwise) { if (m_buildMode.isBuilderMode()) { m_buildMode.rotateGhost(clockwise); m_buildMode.setGhostValidity( canPlaceBuildingHere(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(), m_buildMode.getGhostRotation())); // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE). if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); } // Rotating during a belt drag re-picks the path's primary axis immediately, // without waiting for the next mouse move (REQ-BLD-BELT-DRAG). if (m_buildMode.isDraggingBelt()) { recomputeBeltDragPath(m_buildMode.getGhostTile()); } } else if (m_buildMode.isBlueprintMode()) { for (BlueprintBuilding& bb : m_buildMode.getMutableBlueprint().buildings) { const BuildingDef* def = m_config->buildings.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 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 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 = findBuilding(m_sim->getFactoryState(), id)) { std::shared_ptr command = std::make_shared(); command->tile = building->anchor; command->filterA = source.splitterFilterA; command->filterB = source.splitterFilterB; enqueueCommand(command); } else { std::shared_ptr command = std::make_shared(); command->id = id; command->filterA = source.splitterFilterA; command->filterB = source.splitterFilterB; enqueueCommand(command); } return; } if (source.recipeId.has_value()) { std::shared_ptr command = std::make_shared(); 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 command = std::make_shared(); command->id = id; command->layout = *source.shipLayout; enqueueCommand(command); } } 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(m_gameSpeedMultiplier)); } void GameWorldView::resetForNewGame() { // Leaves whichever mode was active, announcing that one exit rather than all // three; a mode that was not active has nothing to announce. m_buildMode.exitCurrentMode(); m_activeBeams.clear(); m_schematicChoiceShown = false; m_selection.clearAll(); m_copiedConfig = std::nullopt; m_copyConfigFlashes.clear(); m_boxSelecting = false; m_camera.reset(); // Drops any key still held across the restart, which also republishes the pan // direction so m_panDirection follows. m_inputMapper.releaseAll(); 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(std::vector{})); // The one place a restart actually lands (the menu, game over and win dialogs only // enqueue the command), so it is where presentation state belonging to the finished // run is dropped -- e.g. the temporary blueprint (REQ-UI-BLUEPRINT-TEMP). EventManager::getInstance()->sendEventImmediately(std::make_shared()); 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 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 piece of debris's rendered radius (debris is drawn at getTilePx()*0.2). float maxRadius = 0.125f; if (m_sim->getAdmin().isValid(event->target) && m_sim->getAdmin().hasAll(event->target)) { const StationBodyComponent& sb = m_sim->getAdmin().get(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(event->target)) { maxRadius = 0.1f; } std::uniform_real_distribution angleDist(0.0f, 6.28318530f); std::uniform_real_distribution 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 event) { m_buildMode.enterBuilderMode(event->type); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { m_buildMode.exitBuilderMode(); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { m_buildMode.toggleDeconstructMode(); } void GameWorldView::handleEvent(std::shared_ptr event) { m_buildMode.enterBlueprintMode(event->blueprint); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { m_buildMode.exitBlueprintMode(); } void GameWorldView::handleEvent(std::shared_ptr event) { setGameSpeed(event->multiplier); } void GameWorldView::handleEvent(std::shared_ptr event) { m_panDirection = event->direction; } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { if (m_gameSpeedMultiplier > 0.0) { m_prevNonZeroSpeed = m_gameSpeedMultiplier; setGameSpeed(0.0); } else { setGameSpeed(m_prevNonZeroSpeed); } } void GameWorldView::handleEvent(std::shared_ptr event) { stepSpeed(event->delta); } void GameWorldView::handleEvent(std::shared_ptr event) { rotateGhost(event->clockwise); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { // One key backs out of whichever mode is active, and enters deconstruct mode // when none is (REQ-UI-HOTKEYS). // One key backs out of whichever mode is active, and enters deconstruct mode // when none is (REQ-UI-HOTKEYS). if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); } else { m_buildMode.exitCurrentMode(); } } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { m_debugDraw = !m_debugDraw; EventManager::getInstance()->sendEventImmediately( std::make_shared(m_debugDraw)); } void GameWorldView::handleEvent(std::shared_ptr 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 command) { m_commandManager.enqueue(std::move(command)); } void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) { std::shared_ptr command = std::make_shared(); command->type = type; command->anchor = anchor; command->rotation = rotation; enqueueCommand(command); } bool GameWorldView::canAfford(BuildingType type) const { const BuildingDef* def = m_config->buildings.findBuildingDef(type); if (!def) { return false; } return m_sim->getBuildingBlocksStock() >= def->cost; }