#include "GameWorldView.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 "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 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; } 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(*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 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 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(elapsed) / 1000.0f; const float scrollBefore = m_scrollXTiles; if (m_scrollLeft) { m_scrollXTiles -= delta; } if (m_scrollRight) { m_scrollXTiles += delta; } clampScroll(); // While the view scrolls, the tile under a stationary cursor changes, // so refresh the box-select rectangle even though no mouse move fires. if (m_boxSelecting && m_scrollXTiles != scrollBefore) { m_boxCurrentTile = widgetToTile(mapFromGlobal(QCursor::pos())); } } // Fire events for any state that changed since the last frame { const Tick newTick = m_sim->getCurrentTick(); const int newBlocks = m_sim->getBuildingBlocksStock(); const int newExpCost = m_sim->getCurrentExpansionCost(); const int newBoss = m_sim->getBossWaveCounter(); const Tick newCountdown = m_sim->getBossCountdownTicks(); if (newTick != m_lastTick) { m_lastTick = newTick; EventManager::getInstance()->sendEventImmediately( std::make_shared(newTick)); } if (newBlocks != m_lastBlocks) { m_lastBlocks = newBlocks; EventManager::getInstance()->sendEventImmediately( std::make_shared(newBlocks)); } if (newExpCost != m_lastExpansionCost) { m_lastExpansionCost = newExpCost; EventManager::getInstance()->sendEventImmediately( std::make_shared(newExpCost)); } if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown) { m_lastBossCounter = newBoss; m_lastBossCountdown = newCountdown; EventManager::getInstance()->sendEventImmediately( std::make_shared(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(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( currentArtifactCount, m_sim->getConfig().world.artifacts.artifactWinCount)); } update(); } void GameWorldView::paintGL() { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); drawTiles(painter); drawBuildings(painter); // Port items are drawn over the buildings but clipped to a thin margin at each // machine's edges (see drawPortItems), so items appear to emerge from / sink // into the port and stay visible while crossing directly between two touching // buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE). drawPortItems(painter); drawCopyConfigFeedback(painter); drawStations(painter); drawBeltItems(painter); drawScrap(painter); if (m_debugDraw) { drawDebugSensorRanges(painter); drawDebugTargetLines(painter); drawDebugOverlay(painter); } drawShips(painter); drawBeams(painter); drawOverlays(painter); drawScreenSpace(painter); drawPauseBorder(painter); drawReplayOverlay(painter); } // --------------------------------------------------------------------------- // Coordinate helpers // --------------------------------------------------------------------------- float GameWorldView::getTilePx() const { if (m_config->world.heightTiles <= 0) { return 1.0f; } return static_cast(height()) / static_cast(m_config->world.heightTiles); } float GameWorldView::getViewportWidthTiles() const { return static_cast(width()) / getTilePx(); } float GameWorldView::getViewLeftTiles() const { return m_scrollXTiles - getViewportWidthTiles() / 2.0f; } QPointF GameWorldView::worldToWidget(QVector2D worldPos) const { return QPointF( static_cast((worldPos.x() - getViewLeftTiles()) * getTilePx()), static_cast(worldPos.y() * getTilePx())); } QPointF GameWorldView::tileToWidget(QPoint tile) const { return worldToWidget(QVector2D(static_cast(tile.x()), static_cast(tile.y()))); } QPoint GameWorldView::widgetToTile(QPoint widgetPt) const { const float wx = static_cast(widgetPt.x()) / getTilePx() + getViewLeftTiles(); const float wy = static_cast(widgetPt.y()) / getTilePx(); return QPoint(static_cast(std::floor(wx)), static_cast(std::floor(wy))); } QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const { const float wx = static_cast(widgetPt.x()) / getTilePx() + getViewLeftTiles(); const float wy = static_cast(widgetPt.y()) / getTilePx(); return QVector2D(wx, wy); } QRectF GameWorldView::tileRect(QPoint tile) const { const QPointF tl = tileToWidget(tile); return QRectF(tl.x(), tl.y(), static_cast(getTilePx()), static_cast(getTilePx())); } QRect GameWorldView::getViewportRect() const { const int left = static_cast(std::floor(getViewLeftTiles())) - 1; const int top = 0; const int right = static_cast(std::ceil(getViewLeftTiles() + getViewportWidthTiles())) + 1; const int bottom = m_config->world.heightTiles; return QRect(left, top, right - left, bottom - top); } float GameWorldView::getAsteroidLeftEdge() const { float leftX = -static_cast(m_sim->getCurrentAsteroidWidth_tiles()); for (const Building& b : m_sim->getBuildings().getAllBuildings()) { 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; } 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(m_config->world.scroll.panSpeedSlow_tps); const float fast = static_cast(m_config->world.scroll.panSpeedFast_tps); const float half = static_cast(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f; const float leftEdge = static_cast(m_config->world.regions.playerBufferWidth_tiles); const float rightEdge = getEnemyStationRightEdge(); // Rising ramp at the left boundary (slow -> fast) and falling ramp at the right // boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in // the middle, and — if the bands overlap in a narrow contest zone — a single peak // below the fast speed where the two ramps cross. const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles); const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles); return std::min(leftRamp, rightRamp); } void GameWorldView::clampScroll() { // m_scrollXTiles is the view center, so the pan limits are the edges themselves: // the view can pan left until the buildable/asteroid edge is centered, and right // until the enemy stations are centered (revealing a little space beyond them). const float leftBound = getAsteroidLeftEdge(); const float rightBound = getEnemyStationRightEdge(); m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound)); } // --------------------------------------------------------------------------- // Placement helpers // --------------------------------------------------------------------------- const BuildingDef* GameWorldView::findBuildingDef(BuildingType type) const { for (const BuildingDef& def : m_config->buildings.buildings) { if (def.type == type) { return &def; } } return nullptr; } bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const { // Terrain and world-bounds validity are owned by the simulation // (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy / // rotate-in-place check. if (!m_sim->getBuildings().isPlacementValid(type, anchor, rot)) { return false; } const BuildingDef* def = findBuildingDef(type); if (!def) { return false; } const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot); bool anyOccupied = false; for (const QPoint& relCell : parsed.bodyCells) { if (m_sim->getBuildings().isTileOccupied(anchor + relCell)) { anyOccupied = true; break; } } if (anyOccupied) { return m_sim->getBuildings().findRotateInPlaceTarget(type, anchor, rot).has_value(); } return true; } BuildingId GameWorldView::buildingAtTile(QPoint tile) const { for (const Building& b : m_sim->getBuildings().getAllBuildings()) { for (const QPoint& cell : b.bodyCells) { if (cell == tile) { return b.id; } } } return kInvalidBuildingId; } BuildingId GameWorldView::siteAtTile(QPoint tile) const { for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) { for (const QPoint& cell : s.bodyCells) { if (cell == tile) { return s.id; } } } return kInvalidBuildingId; } std::vector 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 ids; for (const Building& b : m_sim->getBuildings().getAllBuildings()) { for (const QPoint& cell : b.bodyCells) { if (cell.x() >= x0 && cell.x() <= x1 && cell.y() >= y0 && cell.y() <= y1) { ids.push_back(b.id); break; } } } for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) { for (const QPoint& cell : s.bodyCells) { if (cell.x() >= x0 && cell.x() <= x1 && cell.y() >= y0 && cell.y() <= y1) { ids.push_back(s.id); break; } } } return ids; } std::optional GameWorldView::entityPosition(entt::entity entity) const { if (!m_sim->getAdmin().isValid(entity) || !m_sim->getAdmin().hasAll(entity)) { return std::nullopt; } return m_sim->getAdmin().get(entity).value; } void GameWorldView::clearScrapSelection() { if (m_selectedScrap.empty()) { return; } m_selectedScrap.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedScrap)); } void GameWorldView::pruneDespawnedScrap() { if (m_selectedScrap.empty()) { return; } std::vector live; for (const ScrapInfo& info : m_sim->getScraps().getAllScrapInfo()) { if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), info.entity) != m_selectedScrap.end()) { live.push_back(info.entity); } } if (live.size() != m_selectedScrap.size()) { m_selectedScrap = std::move(live); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedScrap)); } } void GameWorldView::stepSpeed(int delta) { const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const int kCount = 5; int current = 2; for (int i = 0; i < kCount; ++i) { if (std::abs(kSpeeds[i] - m_gameSpeedMultiplier) < 0.001) { current = i; break; } } const int next = std::max(0, std::min(kCount - 1, current + delta)); setGameSpeed(kSpeeds[next]); } void GameWorldView::placeBlueprintAtTile(QPoint center) { const Blueprint& bp = *m_blueprintMode; for (const BlueprintBuilding& bb : bp.buildings) { if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; } } // Cost only applies to buildings that are genuinely new (not rotate-in-place). int totalCost = 0; for (const BlueprintBuilding& bb : bp.buildings) { if (m_sim->getBuildings().findRotateInPlaceTarget( bb.type, center + bb.offset, bb.rotation).has_value()) { continue; } const BuildingDef* def = findBuildingDef(bb.type); if (def) { totalCost += def->cost; } } if (m_sim->getBuildingBlocksStock() < totalCost) { return; } for (const BlueprintBuilding& bb : bp.buildings) { const QPoint anchor = center + bb.offset; const std::optional rotateTarget = m_sim->getBuildings().findRotateInPlaceTarget(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::placeAtTile(QPoint tile) { if (!m_builderType.has_value()) { return; } const BuildingType type = *m_builderType; if (!isValidPlacement(type, tile, m_ghostRotation)) { return; } const std::optional rotateTarget = m_sim->getBuildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); if (rotateTarget.has_value()) { std::shared_ptr command = std::make_shared(); command->id = *rotateTarget; command->newRotation = m_ghostRotation; enqueueCommand(command); return; } // For placements whose UI follow-up depends on success (belt-drag bookkeeping, // tunnel entry/exit toggle), pre-validate occupancy + affordability so the // optimistic UI update matches what the deferred command will do — isValidPlacement // (above) already covered terrain/bounds. if (type == BuildingType::Belt) { if (m_beltDragTiles.count(tile) > 0) { return; } if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type)) { enqueuePlaceBuilding(type, tile, m_ghostRotation); m_beltDragTiles.insert(tile); } } else if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type)) { enqueuePlaceBuilding(type, tile, m_ghostRotation); if (type == BuildingType::TunnelEntry) { m_builderType = BuildingType::TunnelExit; } else if (type == BuildingType::TunnelExit) { m_builderType = BuildingType::TunnelEntry; } } } else { enqueuePlaceBuilding(type, tile, m_ghostRotation); } } // --------------------------------------------------------------------------- // Port glyph helper // --------------------------------------------------------------------------- void GameWorldView::drawPortGlyph(QPainter& painter, QPoint bodyTile, Rotation direction, const QColor& color) { const float px = getTilePx(); const QRectF tr = tileRect(bodyTile); const QPointF center(tr.x() + static_cast(px) * 0.5, tr.y() + static_cast(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(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(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(std::floor(getViewLeftTiles())) - 1; const int rightTile = leftTile + static_cast(std::ceil(getViewportWidthTiles())) + 2; const int bottomTile = m_config->world.heightTiles; // Asteroid columns left of the buildable edge are not yet unlocked by // expansion; tint them so the player sees the reachable-but-locked area. const int buildableLeftX = -m_sim->getCurrentAsteroidWidth_tiles(); painter.setPen(Qt::NoPen); for (int x = leftTile; x <= rightTile; ++x) { const QColor& fill = (x < 0) ? m_visuals->asteroid.fill : m_visuals->space.fill; const bool locked = (x < buildableLeftX); for (int y = 0; y < bottomTile; ++y) { const QRectF rect = tileRect(QPoint(x, y)); painter.fillRect(rect, fill); if (locked) { painter.fillRect(rect, m_visuals->overlays.lockedAsteroid); } } } } void GameWorldView::drawBuildings(QPainter& painter) { for (const Building& b : m_sim->getBuildings().getAllBuildings()) { const std::map::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(getTilePx()), b.footprint.height() * static_cast(getTilePx())); painter.setPen(QPen(bv.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); if (!bv.glyph.isEmpty()) { painter.setPen(bv.outline); painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); } for (const Port& port : b.outputPorts) { drawPortGlyph(painter, portBodyTile(port.tile, port.direction), port.direction, bv.outline); } // HP bar below the HQ footprint; the HQ's HP lives on its proxy entity. if (b.type == BuildingType::Hq) { m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, const FactionComponent& f, const HealthComponent& h) { if (h.maxHp > 0.0f) { drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0, bboxRect.width(), h.hp / h.maxHp, f.isEnemy); } }); } } painter.setOpacity(0.5); for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) { const std::map::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(getTilePx()), s.footprint.height() * static_cast(getTilePx())); painter.setPen(QPen(bv.outline, 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); const BuildingDef* siteDef = findBuildingDef(s.type); if (siteDef) { // Glyph + progress percentage const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds); int pct = 0; if (s.completesAt > 0 && durationTicks > 0) { const Tick elapsed = m_sim->getCurrentTick() - (s.completesAt - durationTicks); pct = static_cast( 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 GameWorldView::footprintWidgetRect(BuildingId id) const { std::optional anchor; std::optional footprint; if (const Building* b = m_sim->getBuildings().findBuilding(id)) { anchor = b->anchor; footprint = b->footprint; } else if (const ConstructionSite* s = m_sim->getBuildings().findSite(id)) { anchor = s->anchor; footprint = s->footprint; } if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; } const QPointF tl = tileToWidget(*anchor); return QRectF(tl.x(), tl.y(), footprint->width() * static_cast(getTilePx()), footprint->height() * static_cast(getTilePx())); } void GameWorldView::drawSelectionHighlights(QPainter& painter) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); for (BuildingId selId : m_selectedBuildingIds) { const std::optional rect = footprintWidgetRect(selId); if (!rect.has_value()) { continue; } // Outline sits 1px outside the footprint (into adjacent tiles). painter.drawRect(rect->adjusted(-1, -1, 1, 1)); } // A ring around each selected scrap pile, sitting just outside the pile's // rendered circle (radius getTilePx()*0.2, matching drawScrap) (REQ-UI-SCRAP-CLICK-SELECT). if (!m_selectedScrap.empty()) { const qreal outlineRadius = static_cast(getTilePx() * 0.2f) + 3.0; for (const ScrapInfo& scrap : m_sim->getScraps().getAllScrapInfo()) { if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), scrap.entity) == m_selectedScrap.end()) { continue; } painter.drawEllipse(worldToWidget(scrap.position), outlineRadius, outlineRadius); } } } void GameWorldView::drawCopyConfigFeedback(QPainter& painter) { const QColor color = m_visuals->overlays.copyConfig; // Eligible-target tint: while a configuration is cached, every same-type // building and site (the source included) is a valid paste target and is // washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK). if (m_copiedConfig.has_value()) { painter.setPen(Qt::NoPen); painter.setBrush(color); const BuildingType type = m_copiedConfig->type; for (const Building& b : m_sim->getBuildings().getAllBuildings()) { if (b.type != type) { continue; } const std::optional rect = footprintWidgetRect(b.id); if (rect.has_value()) { painter.drawRect(*rect); } } for (const ConstructionSite& s : m_sim->getBuildings().getAllSites()) { if (s.type != type) { continue; } const std::optional 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 rect = footprintWidgetRect(flash.id); if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); } } } void GameWorldView::drawPortItems(QPainter& painter) { const float halfPx = getTilePx() * 0.5f * 0.5f; // Port items are drawn over the buildings (drawBuildings runs first) but clipped // to a thin margin at each machine's edges: the clip region is the whole view // minus every machine's interior (its footprint inset by kPortMarginTiles). So a // transiting item shows only near the port edge — appearing to emerge from / sink // into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying // visible in the ~2×margin band at a seam between two touching buildings // (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so // items on belts stay fully visible. constexpr double kPortMarginTiles = 0.2; const double margin = kPortMarginTiles * static_cast(getTilePx()); QRegion clip(rect()); for (const Building& b : m_sim->getBuildings().getAllBuildings()) { if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter || b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) { continue; } const std::set cells(b.bodyCells.begin(), b.bodyCells.end()); for (const QPoint& cell : b.bodyCells) { // Inset an edge only where the neighbouring cell is not part of the same // building, so interior cell seams stay filled (handles L-shaped footprints). const double l = cells.count(cell + QPoint(-1, 0)) ? 0.0 : margin; const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin; const double r = cells.count(cell + QPoint( 1, 0)) ? 0.0 : margin; const double d = cells.count(cell + QPoint( 0, 1)) ? 0.0 : margin; clip = clip.subtracted(QRegion(tileRect(cell).adjusted(l, t, -r, -d).toRect())); } } // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square + outline. const std::function drawItem = [&](const ItemType& type, QPointF worldPos) { const std::map::const_iterator it = m_visuals->items.find(type.id); if (it == m_visuals->items.end()) { return; } const QPointF center = worldToWidget( QVector2D(static_cast(worldPos.x()), static_cast(worldPos.y()))); const QRectF itemRect(center.x() - halfPx, center.y() - halfPx, halfPx * 2, halfPx * 2); painter.fillRect(itemRect, it->second.fill); painter.setPen(QPen(it->second.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(itemRect); }; painter.save(); painter.setClipRegion(clip); m_sim->getBuildings().forEachEmergingItem(drawItem); m_sim->getBuildings().forEachIncomingItem(drawItem); painter.restore(); } void GameWorldView::drawBeltItems(QPainter& painter) { const float halfPx = getTilePx() * 0.5f * 0.5f; const QRect vr = getViewportRect(); m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi) { const std::map::const_iterator it = m_visuals->items.find(vi.type.id); if (it == m_visuals->items.end()) { return; } const QPointF center = worldToWidget( QVector2D(static_cast(vi.worldPos.x()), static_cast(vi.worldPos.y()))); const QRectF rect(center.x() - halfPx, center.y() - halfPx, halfPx * 2, halfPx * 2); painter.fillRect(rect, it->second.fill); painter.setPen(QPen(it->second.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(rect); }); } void GameWorldView::drawScrap(QPainter& painter) { const float r = getTilePx() * 0.2f; for (const ScrapInfo& scrap : m_sim->getScraps().getAllScrapInfo()) { const QPointF center = worldToWidget(scrap.position); painter.setBrush(QColor(128, 110, 90)); painter.setPen(QPen(QColor(50, 40, 30), 1)); painter.drawEllipse(center, static_cast(r), static_cast(r)); } } void GameWorldView::drawStations(QPainter& painter) { m_sim->getAdmin().forEach( [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h) { const BuildingType visType = f.isEnemy ? BuildingType::EnemyDefenceStation : BuildingType::PlayerDefenceStation; const std::map::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(getTilePx()), sb.footprint.height() * static_cast(getTilePx())); painter.setPen(QPen(bv.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); if (m_selectedEntity.has_value() && *m_selectedEntity == e) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2)); } // HP bar below footprint. if (h.maxHp > 0.0f) { drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0, bboxRect.width(), h.hp / h.maxHp, f.isEnemy); } }); } void GameWorldView::drawShips(QPainter& painter) { m_sim->getAdmin().forEach( [&](entt::entity e, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& facing, const FactionComponent& fac, const HealthComponent& h) { const std::map::const_iterator it = m_visuals->ships.find(si.schematicId); if (it == m_visuals->ships.end()) { return; } const QPointF center = worldToWidget(pos.value); const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians)); const QVector2D perp(-dir.y(), dir.x()); const float fwd = getTilePx() * 0.45f; const float side = getTilePx() * 0.25f; QPolygonF tri; tri << QPointF(center.x() + static_cast(dir.x() * fwd), center.y() + static_cast(dir.y() * fwd)) << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), center.y() + static_cast(perp.y() * side - dir.y() * side)) << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), center.y() + static_cast(-perp.y() * side - dir.y() * side)); painter.setPen(QPen(it->second.outline, 1)); painter.setBrush(it->second.fill); painter.drawPolygon(tri); 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(fwd) + 2.0; painter.drawEllipse(center, r, r); } if (h.maxHp > 0.0f) { const qreal barW = static_cast(fwd) * 2.0; const qreal barX = center.x() - static_cast(fwd); const qreal barY = center.y() + static_cast(fwd) + 1.0; drawHpBar(painter, 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(getTilePx()) * 0.12; const float clamped = std::max(0.0f, fraction); painter.fillRect(QRectF(left, top, width, barH), QColor(60, 60, 60)); painter.fillRect(QRectF(left, top, width * static_cast(clamped), barH), isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); } void GameWorldView::drawDebugSensorRanges(QPainter& painter) { painter.setBrush(Qt::NoBrush); m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& /*facing*/, const FactionComponent& /*fac*/, const SensorRangeComponent& sensor) { const std::map::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(sensor.value_tiles) * static_cast(getTilePx()); QColor circleColor = it->second.outline; circleColor.setAlpha(77); painter.setPen(QPen(circleColor, 1)); painter.drawEllipse(center, radiusPx, radiusPx); }); } void GameWorldView::drawDebugTargetLines(QPainter& painter) { // Draw a thin translucent line from a ship to a target, colored by the ship's // own schematic fill. Shared by the attack, repair and salvage target lines. const std::function drawTargetLine = [&](const std::string& schematicId, const QVector2D& from, const QVector2D& to) { const std::map::const_iterator it = m_visuals->ships.find(schematicId); if (it == m_visuals->ships.end()) { return; } QColor lineColor = it->second.fill; lineColor.setAlpha(128); painter.setPen(QPen(lineColor, 1)); painter.drawLine(worldToWidget(from), worldToWidget(to)); }; m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const AttackBehavior& attack) { if (!attack.currentTarget.has_value()) { return; } const std::optional targetPos = entityPosition(*attack.currentTarget); if (!targetPos.has_value()) { return; } drawTargetLine(si.schematicId, pos.value, *targetPos); }); m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const RepairBehavior& repair) { if (!repair.currentTarget.has_value()) { return; } const std::optional targetPos = entityPosition(*repair.currentTarget); if (!targetPos.has_value()) { return; } drawTargetLine(si.schematicId, pos.value, *targetPos); }); m_sim->getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& si, const PositionComponent& pos, const SalvageScrapBehavior& salvage) { if (!salvage.scrapTarget.has_value()) { return; } drawTargetLine(si.schematicId, pos.value, *salvage.scrapTarget); }); } void GameWorldView::drawDebugOverlay(QPainter& painter) { painter.resetTransform(); const QStringList lines = { tr("Accumulated Threat Level: %1") .arg(m_sim->getThreatLevel(), 0, 'f', 1), tr("Time until Wave: %1s") .arg(ticksToSeconds(m_sim->getNormalGapRemainingTicks()), 0, 'f', 1), tr("Threat Accumulation Rate: %1 threat/s") .arg(m_sim->getThreatAccumulationRate(), 0, 'f', 1), tr("Max Factory Production: %1 threat/s") .arg(m_sim->getMaxFactoryProductionThreatRate(), 0, 'f', 1), tr("Current Factory Production: %1 threat/s") .arg(m_sim->getCurrentFactoryProductionThreatRate(), 0, 'f', 1), }; QFont font = painter.font(); font.setPointSize(m_visuals->toast.fontSize); painter.setFont(font); const QFontMetrics fm = painter.fontMetrics(); const int lineH = fm.height(); const int padding = 8; const int spacing = 4; int textW = 0; for (const QString& line : lines) { textW = std::max(textW, fm.horizontalAdvance(line)); } const int bgW = textW + padding * 2; const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2; const QRect bgRect(padding, padding, bgW, bgH); painter.fillRect(bgRect, QColor(0, 0, 0, 160)); painter.setPen(Qt::white); int y = padding * 2; for (const QString& line : lines) { const QRect textRect(padding * 2, y, textW, lineH); painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line); y += lineH + spacing; } } void GameWorldView::drawBeams(QPainter& painter) { const QPainter::RenderHints savedHints = painter.renderHints(); painter.setRenderHint(QPainter::Antialiasing, true); for (const ActiveBeam& beam : m_activeBeams) { const std::optional shooterPos = entityPosition(beam.event.shooter); const std::optional targetPos = entityPosition(beam.event.target); if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } QColor color = m_visuals->beams.weaponColor; switch (beam.event.kind) { case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break; case BeamKind::Repair: color = m_visuals->beams.repairColor; break; case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break; } const QPointF s = worldToWidget(*shooterPos); const QPointF t = worldToWidget(*targetPos + beam.targetOffset); // Unit direction/perpendicular of the beam in widget space. A degenerate // zero-length beam (shooter and target coincide) has no direction to // taper along, so skip it. const QVector2D delta(static_cast(t.x() - s.x()), static_cast(t.y() - s.y())); const float lengthPx = delta.length(); if (lengthPx < 0.001f) { continue; } const QVector2D dir = delta / lengthPx; const QVector2D perp(-dir.y(), dir.x()); // Directional taper: draw the beam as a quad that is wide at the shooter // and narrows to a faint tip at the target, so it reads as an arrow // pointing away from whoever fired it. Without this, a beam strung // between two nearby ships is symmetric and gives no cue which end is the // source (the readability problem this addresses). const float widthPx = std::max(1.0f, static_cast(m_visuals->beams.widthPx)); const float baseHalf = widthPx * 1.f; const float tipHalf = widthPx * 0.35f; QPolygonF quad; quad << QPointF(s.x() + static_cast(perp.x() * baseHalf), s.y() + static_cast(perp.y() * baseHalf)) << QPointF(s.x() - static_cast(perp.x() * baseHalf), s.y() - static_cast(perp.y() * baseHalf)) << QPointF(t.x() - static_cast(perp.x() * tipHalf), t.y() - static_cast(perp.y() * tipHalf)) << QPointF(t.x() + static_cast(perp.x() * tipHalf), t.y() + static_cast(perp.y() * tipHalf)); QColor bright = color; bright.setAlpha(255); QColor faint = color; faint.setAlpha(90); QLinearGradient bodyGrad(s, t); bodyGrad.setColorAt(0.0, bright); bodyGrad.setColorAt(1.0, faint); painter.setPen(Qt::NoPen); painter.setBrush(bodyGrad); painter.drawPolygon(quad); } painter.setBrush(Qt::NoBrush); painter.setRenderHints(savedHints); } void GameWorldView::drawOverlays(QPainter& painter) { // Builder-mode ghost if (m_builderType.has_value()) { drawBuildingGhost(painter, *m_builderType, m_ghostTile, m_ghostRotation, m_ghostValid); } // Blueprint placement ghost if (m_blueprintMode.has_value()) { for (const BlueprintBuilding& bb : m_blueprintMode->buildings) { const QPoint anchor = m_blueprintGhostTile + bb.offset; const bool valid = isValidPlacement(bb.type, anchor, bb.rotation); drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid); } } // Demolish tint: while dragging a demolish box, tint every covered // building/site (REQ-BLD-DEMOLISH-BOX); otherwise tint the hovered one. if (m_demolishMode && m_boxSelecting) { for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile)) { const Building* b = m_sim->getBuildings().findBuilding(id); if (b && b->type == BuildingType::Hq) { continue; } const std::vector* cells = nullptr; const ConstructionSite* s = nullptr; if (b) { cells = &b->bodyCells; } else if ((s = m_sim->getBuildings().findSite(id))) { cells = &s->bodyCells; } if (cells) { for (const QPoint& cell : *cells) { painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint); } } } } else if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId) { const Building* b = m_sim->getBuildings().findBuilding(m_demolishHoverBuildingId); if (b) { for (const QPoint& cell : b->bodyCells) { painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint); } } } // Box-select rectangle if (m_boxSelecting) { const QPoint tl(std::min(m_boxStartTile.x(), m_boxCurrentTile.x()), std::min(m_boxStartTile.y(), m_boxCurrentTile.y())); const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1, std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1); const QRectF selRect(tileToWidget(tl), tileToWidget(br)); painter.setPen(QPen(m_visuals->overlays.selectionRect, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(selRect); } } void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, QPoint anchorTile, Rotation rotation, bool valid) { const BuildingDef* def = findBuildingDef(type); if (!def) { return; } const std::map::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(getTilePx()), (maxCell.y() - minCell.y() + 1) * static_cast(getTilePx())); painter.setPen(QPen(lineColor, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); if (!bv.glyph.isEmpty()) { painter.setPen(lineColor); painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); } for (const Port& port : parsed.outputPorts) { drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction), port.direction, lineColor); } painter.setOpacity(1.0); } void GameWorldView::drawScreenSpace(QPainter& /*painter*/) { } void GameWorldView::drawPauseBorder(QPainter& painter) { if (m_gameSpeedMultiplier > 0.0) { return; } // Border thickness in pixels, capped so it never exceeds half the viewport on // very small windows (which would leave no un-darkened center). const qreal borderPx = std::min( 100.0, std::min(width(), height()) / 2.0); if (borderPx <= 0.0) { return; } const QColor edge(0, 0, 0, 128); // 50% black at the viewport edge const QColor inner(0, 0, 0, 0); // fully transparent toward the center const qreal w = width(); const qreal h = height(); painter.save(); // Each side is a linear gradient fading from `edge` at the viewport border to // `inner` toward the center. The four sides are clipped to trapezoids that meet // along the corner diagonals (a mitred picture frame), so the strips never // overlap and don't double-darken the corners. Because the border thickness is // the same on every side, both adjoining gradients evaluate equally along each // 45-degree diagonal and join seamlessly. const std::function 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; } // 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(virtualKey - 0x30); const bool shift = (event->modifiers() & Qt::ShiftModifier) != 0; std::optional 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(*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()); break; case Qt::Key_Escape: EventManager::getInstance()->sendEventImmediately( std::make_shared()); break; case Qt::Key_F3: m_debugDraw = !m_debugDraw; EventManager::getInstance()->sendEventImmediately( std::make_shared(m_debugDraw)); break; case Qt::Key_F4: EventManager::getInstance()->addEvent(std::make_shared()); break; default: QOpenGLWidget::keyPressEvent(event); break; } } void GameWorldView::keyReleaseEvent(QKeyEvent* event) { if (event->isAutoRepeat()) { QOpenGLWidget::keyReleaseEvent(event); return; } if (event->key() == Qt::Key_A) { m_scrollLeft = false; } if (event->key() == Qt::Key_D) { m_scrollRight = false; } // Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG). if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); } QOpenGLWidget::keyReleaseEvent(event); } void GameWorldView::mousePressEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) { if (event->button() == Qt::RightButton) { if (m_builderType.has_value()) { exitBuilderMode(); } else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } else if (m_demolishMode) { toggleDemolishMode(); } else if (event->modifiers() & Qt::ShiftModifier) { // Shift + right-click copies a building's settings, but only in the // default selection mode (REQ-BLD-COPY-CONFIG). const QPoint tile = widgetToTile(event->pos()); BuildingId id = buildingAtTile(tile); if (id == kInvalidBuildingId) { id = siteAtTile(tile); } if (id != kInvalidBuildingId) { copyConfigFrom(id); } } } return; } const QPoint tile = widgetToTile(event->pos()); if (m_builderType.has_value()) { const BuildingType type = *m_builderType; if (type == BuildingType::Belt) { m_dragging = true; m_beltDragTiles.clear(); placeAtTile(tile); } else { placeAtTile(tile); } } else if (m_blueprintMode.has_value()) { placeBlueprintAtTile(tile); } else if (m_demolishMode) { // Start a demolish box drag; a plain click resolves as a 1x1 box on // release (REQ-BLD-DEMOLISH-CLICK, REQ-BLD-DEMOLISH-BOX). m_boxSelecting = true; m_boxStartTile = tile; m_boxCurrentTile = tile; } else { // Shift + left-click applies the copied settings to a same-type building // (REQ-BLD-COPY-CONFIG). Consumes the click so it does not change the // selection. Only active in the default selection mode. if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value()) { BuildingId id = buildingAtTile(tile); if (id == kInvalidBuildingId) { id = siteAtTile(tile); } if (id != kInvalidBuildingId) { pasteConfigTo(id); return; } } const QVector2D worldPos = widgetToWorld(event->pos()); const entt::entity hitEntity = entityAtWorldPos(m_sim->getAdmin(), worldPos); if (hitEntity != entt::null) { // Actors (ship/station) win over scrap and buildings (REQ-UI-SCRAP-CLICK-SELECT). clearScrapSelection(); m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); m_selectedEntity = hitEntity; EventManager::getInstance()->sendEventImmediately( std::make_shared(hitEntity)); } else { if (m_selectedEntity.has_value()) { m_selectedEntity = std::nullopt; EventManager::getInstance()->sendEventImmediately( std::make_shared(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 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(m_selectedBuildingIds)); } else if (const entt::entity scrapHit = scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null) { // Scrap forms its own selection category; picking it clears any // building selection (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT). if (!m_selectedBuildingIds.empty()) { m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); } if (event->modifiers() & Qt::ControlModifier) { bool found = false; std::vector 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(m_selectedScrap)); } else { if (!(event->modifiers() & Qt::ControlModifier)) { m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(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 boxIds = buildingsInBox(m_boxStartTile, m_boxCurrentTile); if (m_demolishMode) { // Demolish every covered building/site; the HQ is protected // (REQ-BLD-DEMOLISH, REQ-BLD-DEMOLISH-BOX). for (BuildingId id : boxIds) { const Building* b = m_sim->getBuildings().findBuilding(id); if (b && b->type == BuildingType::Hq) { continue; } std::shared_ptr command = std::make_shared(); 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(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 boxScrap = scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); if (!boxScrap.empty()) { if (!m_selectedBuildingIds.empty()) { m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(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(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(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(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 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 = m_sim->getBuildings().findBuilding(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); } } 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(false)); } void GameWorldView::enterBlueprintMode(Blueprint blueprint) { if (m_builderType.has_value()) { exitBuilderMode(); } m_demolishMode = false; EventManager::getInstance()->sendEventImmediately( std::make_shared(false)); m_blueprintGhostTile = m_ghostTile; m_blueprintMode = std::move(blueprint); } void GameWorldView::exitBlueprintMode() { m_blueprintMode.reset(); EventManager::getInstance()->sendEventImmediately( std::make_shared()); } void GameWorldView::exitBuilderMode() { m_builderType.reset(); m_beltDragTiles.clear(); m_dragging = false; EventManager::getInstance()->sendEventImmediately( std::make_shared()); } 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() { 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(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(std::vector{})); 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 scrap pile's rendered radius (scrap is drawn at getTilePx()*0.2). float maxRadius = 0.125f; if (m_sim->getAdmin().isValid(event->target) && m_sim->getAdmin().hasAll(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) { enterBuilderMode(event->type); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { exitBuilderMode(); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { toggleDemolishMode(); } void GameWorldView::handleEvent(std::shared_ptr event) { enterBlueprintMode(event->blueprint); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { exitBlueprintMode(); } void GameWorldView::handleEvent(std::shared_ptr event) { setGameSpeed(event->multiplier); } 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 = findBuildingDef(type); if (!def) { return false; } return m_sim->getBuildingBlocksStock() >= def->cost; }