#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 "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 "PositionComponent.h" #include "RepairBehavior.h" #include "SalvageScrapBehavior.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 "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->currentTick()); } } else { // Drain queued player commands once per frame, before the tick batch. This // runs even at 0x so a paused player sees placed construction sites // immediately, while staying deterministic (see docs/replay_design.md). const bool commandsApplied = m_commandManager.hasPending(); m_commandManager.drain(); // A drained Reset reinitialized the simulation; reset the view to match. if (m_viewResetPending) { m_viewResetPending = false; resetForNewGame(); } // Notify presentation widgets that queued commands were applied, so a // paused player still sees the effect (e.g. a shipyard's layout preview // after picking a schematic) even though no tick advances. UI-only: this // does not touch the command queue or simulation, so replay recording // and determinism are unaffected. if (commandsApplied) { EventManager::getInstance()->sendEventImmediately( std::make_shared()); } 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->currentTick(); std::vector live; for (const ActiveBeam& b : m_activeBeams) { if (now - b.event.emittedAt < kBeamLifetimeTicks) { live.push_back(b); } } m_activeBeams = std::move(live); } // Apply held scroll { const float delta = kScrollSpeedTilesPerSec * 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->currentTick(); const int newBlocks = m_sim->buildingBlocksStock(); const int newExpCost = m_sim->currentExpansionCost(); const int newBoss = m_sim->bossWaveCounter(); const Tick newCountdown = m_sim->bossCountdownTicks(); if (newTick != m_lastTick) { m_lastTick = newTick; EventManager::getInstance()->sendEventImmediately( std::make_shared(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->artifactCount() forward and // the header bar must track it. Fires on the first frame (count 0 vs the -1 // sentinel), which also populates the win-count max (replacing the "0/?" label). const int currentArtifactCount = m_sim->artifactCount(); if (currentArtifactCount != m_lastArtifactCount) { m_lastArtifactCount = currentArtifactCount; EventManager::getInstance()->sendEventImmediately( std::make_shared( currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount)); } update(); } void GameWorldView::paintGL() { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); drawTiles(painter); drawBuildings(painter); drawStations(painter); drawBeltItems(painter); drawScrap(painter); if (m_debugDraw) { drawDebugSensorRanges(painter); drawDebugTargetLines(painter); drawDebugOverlay(painter); } drawShips(painter); drawBeams(painter); drawOverlays(painter); drawScreenSpace(painter); drawReplayOverlay(painter); } // --------------------------------------------------------------------------- // Coordinate helpers // --------------------------------------------------------------------------- float GameWorldView::tilePx() const { if (m_config->world.heightTiles <= 0) { return 1.0f; } return static_cast(height()) / static_cast(m_config->world.heightTiles); } float GameWorldView::viewportWidthTiles() const { return static_cast(width()) / tilePx(); } QPointF GameWorldView::worldToWidget(QVector2D worldPos) const { return QPointF( static_cast((worldPos.x() - m_scrollXTiles) * tilePx()), static_cast(worldPos.y() * tilePx())); } 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()) / tilePx() + m_scrollXTiles; const float wy = static_cast(widgetPt.y()) / tilePx(); 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()) / tilePx() + m_scrollXTiles; const float wy = static_cast(widgetPt.y()) / tilePx(); return QVector2D(wx, wy); } QRectF GameWorldView::tileRect(QPoint tile) const { const QPointF tl = tileToWidget(tile); return QRectF(tl.x(), tl.y(), static_cast(tilePx()), static_cast(tilePx())); } QRect GameWorldView::viewportRect() const { const int left = static_cast(std::floor(m_scrollXTiles)) - 1; const int top = 0; const int right = static_cast(std::ceil(m_scrollXTiles + viewportWidthTiles())) + 1; const int bottom = m_config->world.heightTiles; return QRect(left, top, right - left, bottom - top); } float GameWorldView::asteroidLeftEdge() const { float leftX = -static_cast(m_sim->currentAsteroidWidth_tiles()); for (const Building& b : m_sim->buildings().allBuildings()) { for (const QPoint& cell : b.bodyCells) { if (static_cast(cell.x()) < leftX) { leftX = static_cast(cell.x()); } } } return leftX; } float GameWorldView::enemyStationRightEdge() const { float rightX = static_cast(m_config->world.regions.playerBufferWidth_tiles + m_config->world.regions.contestZoneWidth_tiles); m_sim->admin().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; } void GameWorldView::clampScroll() { const float leftBound = asteroidLeftEdge(); const float rightBound = enemyStationRightEdge() - viewportWidthTiles(); m_scrollXTiles = std::max(leftBound, std::min(m_scrollXTiles, rightBound)); } // --------------------------------------------------------------------------- // Placement helpers // --------------------------------------------------------------------------- const BuildingDef* GameWorldView::findBuildingDef(BuildingType type) const { for (const BuildingDef& def : m_config->buildings.buildings) { if (def.type == type) { return &def; } } return nullptr; } bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const { // Terrain and world-bounds validity are owned by the simulation // (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy / // rotate-in-place check. if (!m_sim->buildings().isPlacementValid(type, anchor, rot)) { return false; } const BuildingDef* def = findBuildingDef(type); if (!def) { return false; } const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot); bool anyOccupied = false; for (const QPoint& relCell : parsed.bodyCells) { if (m_sim->buildings().isTileOccupied(anchor + relCell)) { anyOccupied = true; break; } } if (anyOccupied) { return m_sim->buildings().findRotateInPlaceTarget(type, anchor, rot).has_value(); } return true; } BuildingId GameWorldView::buildingAtTile(QPoint tile) const { for (const Building& b : m_sim->buildings().allBuildings()) { for (const QPoint& cell : b.bodyCells) { if (cell == tile) { return b.id; } } } return kInvalidBuildingId; } BuildingId GameWorldView::siteAtTile(QPoint tile) const { for (const ConstructionSite& s : m_sim->buildings().allSites()) { for (const QPoint& cell : s.bodyCells) { if (cell == tile) { return s.id; } } } return kInvalidBuildingId; } std::optional GameWorldView::entityPosition(entt::entity entity) const { if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll(entity)) { return std::nullopt; } return m_sim->admin().get(entity).value; } void GameWorldView::stepSpeed(int delta) { const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const int kCount = 5; int current = 2; for (int i = 0; i < kCount; ++i) { if (std::abs(kSpeeds[i] - m_gameSpeedMultiplier) < 0.001) { current = i; break; } } const int next = std::max(0, std::min(kCount - 1, current + delta)); setGameSpeed(kSpeeds[next]); } void GameWorldView::placeBlueprintAtTile(QPoint center) { const Blueprint& bp = *m_blueprintMode; for (const BlueprintBuilding& bb : bp.buildings) { if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; } } // Cost only applies to buildings that are genuinely new (not rotate-in-place). int totalCost = 0; for (const BlueprintBuilding& bb : bp.buildings) { if (m_sim->buildings().findRotateInPlaceTarget( bb.type, center + bb.offset, bb.rotation).has_value()) { continue; } const BuildingDef* def = findBuildingDef(bb.type); if (def) { totalCost += def->cost; } } if (m_sim->buildingBlocksStock() < totalCost) { return; } for (const BlueprintBuilding& bb : bp.buildings) { const QPoint anchor = center + bb.offset; const std::optional rotateTarget = m_sim->buildings().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->buildings().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->buildings().isTileOccupied(tile) && canAfford(type)) { enqueuePlaceBuilding(type, tile, m_ghostRotation); m_beltDragTiles.insert(tile); } } else if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type)) { enqueuePlaceBuilding(type, tile, m_ghostRotation); if (type == BuildingType::TunnelEntry) { m_builderType = BuildingType::TunnelExit; } else if (type == BuildingType::TunnelExit) { m_builderType = BuildingType::TunnelEntry; } } } else { enqueuePlaceBuilding(type, tile, m_ghostRotation); } } // --------------------------------------------------------------------------- // Port glyph helper // --------------------------------------------------------------------------- void GameWorldView::drawPortGlyph(QPainter& painter, QPoint bodyTile, Rotation direction, const QColor& color) { const float px = tilePx(); const QRectF tr = tileRect(bodyTile); const QPointF center(tr.x() + static_cast(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(m_scrollXTiles)) - 1; const int rightTile = leftTile + static_cast(std::ceil(viewportWidthTiles())) + 2; const int bottomTile = m_config->world.heightTiles; painter.setPen(Qt::NoPen); for (int x = leftTile; x <= rightTile; ++x) { const QColor& fill = (x < 0) ? m_visuals->asteroid.fill : m_visuals->space.fill; for (int y = 0; y < bottomTile; ++y) { painter.fillRect(tileRect(QPoint(x, y)), fill); } } } void GameWorldView::drawBuildings(QPainter& painter) { for (const Building& b : m_sim->buildings().allBuildings()) { const std::map::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(tilePx()), b.footprint.height() * static_cast(tilePx())); painter.setPen(QPen(bv.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); if (!bv.glyph.isEmpty()) { painter.setPen(bv.outline); painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); } for (const Port& port : b.outputPorts) { drawPortGlyph(painter, portBodyTile(port.tile, port.direction), port.direction, bv.outline); } bool selected = false; for (BuildingId selId : m_selectedBuildingIds) { if (selId == b.id) { selected = true; break; } } if (selected) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1)); } } painter.setOpacity(0.5); for (const ConstructionSite& s : m_sim->buildings().allSites()) { 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(tilePx()), s.footprint.height() * static_cast(tilePx())); painter.setPen(QPen(bv.outline, 1, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); bool selected = false; for (BuildingId selId : m_selectedBuildingIds) { if (selId == s.id) { selected = true; break; } } if (selected) { painter.setOpacity(1.0); painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1)); painter.setOpacity(0.5); } const BuildingDef* siteDef = findBuildingDef(s.type); if (siteDef) { // Glyph + progress percentage const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds); int pct = 0; if (s.completesAt > 0 && durationTicks > 0) { const Tick elapsed = m_sim->currentTick() - (s.completesAt - durationTicks); pct = static_cast( 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); } void GameWorldView::drawBeltItems(QPainter& painter) { const float halfPx = tilePx() * 0.5f * 0.5f; const QRect vr = viewportRect(); m_sim->belts().forEachVisualItem(vr, [&](const VisualItem& vi) { const std::map::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 = tilePx() * 0.2f; for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo()) { const QPointF center = worldToWidget(scrap.position); painter.setBrush(QColor(128, 110, 90)); painter.setPen(QPen(QColor(50, 40, 30), 1)); painter.drawEllipse(center, static_cast(r), static_cast(r)); } } void GameWorldView::drawStations(QPainter& painter) { m_sim->admin().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(tilePx()), sb.footprint.height() * static_cast(tilePx())); painter.setPen(QPen(bv.outline, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); if (m_selectedEntity.has_value() && *m_selectedEntity == e) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2)); } // HP bar below footprint. if (h.maxHp > 0.0f) { const float fraction = std::max(0.0f, h.hp / h.maxHp); const qreal barH = static_cast(tilePx()) * 0.12; const qreal barY = bboxRect.bottom() + 1.0; const qreal barW = bboxRect.width(); painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH), QColor(60, 60, 60)); painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast(fraction), barH), f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); } }); } void GameWorldView::drawShips(QPainter& painter) { m_sim->admin().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 = tilePx() * 0.45f; const float side = tilePx() * 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 float fraction = std::max(0.0f, h.hp / h.maxHp); const qreal barW = static_cast(fwd) * 2.0; const qreal barH = static_cast(tilePx()) * 0.12; const qreal barX = center.x() - static_cast(fwd); const qreal barY = center.y() + static_cast(fwd) + 1.0; painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60)); painter.fillRect(QRectF(barX, barY, barW * static_cast(fraction), barH), fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); } }); } void GameWorldView::drawDebugSensorRanges(QPainter& painter) { painter.setBrush(Qt::NoBrush); m_sim->admin().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(tilePx()); QColor circleColor = it->second.outline; circleColor.setAlpha(77); painter.setPen(QPen(circleColor, 1)); painter.drawEllipse(center, radiusPx, radiusPx); }); } void GameWorldView::drawDebugTargetLines(QPainter& painter) { // Draw a thin translucent line from a ship to a target, colored by the ship's // own schematic fill. Shared by the attack, repair and salvage target lines. const std::function 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->admin().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->admin().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->admin().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->threatLevel(), 0, 'f', 1), tr("Time until Wave: %1s") .arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1), tr("Threat Accumulation Rate: %1 threat/s") .arg(m_sim->threatAccumulationRate(), 0, 'f', 1), tr("Max Factory Production: %1 threat/s") .arg(m_sim->maxFactoryProductionThreatRate(), 0, 'f', 1), tr("Current Factory Production: %1 threat/s") .arg(m_sim->currentFactoryProductionThreatRate(), 0, 'f', 1), }; QFont font = painter.font(); font.setPointSize(m_visuals->toast.fontSize); painter.setFont(font); const QFontMetrics fm = painter.fontMetrics(); const int lineH = fm.height(); const int padding = 8; const int spacing = 4; int textW = 0; for (const QString& line : lines) { textW = std::max(textW, fm.horizontalAdvance(line)); } const int bgW = textW + padding * 2; const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2; const QRect bgRect(padding, padding, bgW, bgH); painter.fillRect(bgRect, QColor(0, 0, 0, 160)); painter.setPen(Qt::white); int y = padding * 2; for (const QString& line : lines) { const QRect textRect(padding * 2, y, textW, lineH); painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line); y += lineH + spacing; } } void GameWorldView::drawBeams(QPainter& painter) { for (const ActiveBeam& beam : m_activeBeams) { const std::optional 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; } painter.setPen(QPen(color, m_visuals->beams.widthPx)); painter.drawLine(worldToWidget(*shooterPos), worldToWidget(*targetPos + beam.targetOffset)); } } void GameWorldView::drawOverlays(QPainter& painter) { // Builder-mode ghost if (m_builderType.has_value()) { drawBuildingGhost(painter, *m_builderType, m_ghostTile, m_ghostRotation, m_ghostValid); } // Blueprint placement ghost if (m_blueprintMode.has_value()) { for (const BlueprintBuilding& bb : m_blueprintMode->buildings) { const QPoint anchor = m_blueprintGhostTile + bb.offset; const bool valid = isValidPlacement(bb.type, anchor, bb.rotation); drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid); } } // Demolish hover tint if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId) { const Building* b = m_sim->buildings().findBuilding(m_demolishHoverBuildingId); if (b) { for (const QPoint& cell : b->bodyCells) { painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint); } } } // Box-select rectangle if (m_boxSelecting) { const QPoint tl(std::min(m_boxStartTile.x(), m_boxCurrentTile.x()), std::min(m_boxStartTile.y(), m_boxCurrentTile.y())); const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1, std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1); const QRectF selRect(tileToWidget(tl), tileToWidget(br)); painter.setPen(QPen(m_visuals->overlays.selectionRect, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(selRect); } } void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type, QPoint anchorTile, Rotation rotation, bool valid) { const BuildingDef* def = findBuildingDef(type); if (!def) { return; } const std::map::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(tilePx()), (maxCell.y() - minCell.y() + 1) * static_cast(tilePx())); painter.setPen(QPen(lineColor, 1)); painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); if (!bv.glyph.isEmpty()) { painter.setPen(lineColor); painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); } for (const Port& port : parsed.outputPorts) { drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction), port.direction, lineColor); } painter.setOpacity(1.0); } void GameWorldView::drawScreenSpace(QPainter& /*painter*/) { } void GameWorldView::drawReplayOverlay(QPainter& painter) { if (!m_replayPlayer) { return; } painter.save(); QFont tag = painter.font(); tag.setPixelSize(16); tag.setBold(true); painter.setFont(tag); painter.setPen(QColor(255, 220, 80)); painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY")); // The replay is over once the recorded stream is exhausted or the run reached // its terminal state (win / defeat) — matching where playback freezes above. const bool ended = m_replayPlayer->isFinished() || m_sim->isWon() || m_sim->isGameOver(); if (ended) { // Dim the world so the end message reads clearly over it. painter.fillRect(rect(), QColor(0, 0, 0, 140)); const std::optional 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_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; } 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(); } } return; } const QPoint tile = widgetToTile(event->pos()); if (m_builderType.has_value()) { const BuildingType type = *m_builderType; if (type == BuildingType::Belt || type == BuildingType::Splitter) { m_dragging = true; m_beltDragTiles.clear(); placeAtTile(tile); } else { placeAtTile(tile); } } else if (m_blueprintMode.has_value()) { placeBlueprintAtTile(tile); } else if (m_demolishMode) { BuildingId hovered = buildingAtTile(tile); if (hovered == kInvalidBuildingId) { hovered = siteAtTile(tile); } if (hovered != kInvalidBuildingId) { const Building* b = m_sim->buildings().findBuilding(hovered); const bool isProtected = b && b->type == BuildingType::Hq; if (!isProtected) { std::shared_ptr command = std::make_shared(); command->id = hovered; enqueueCommand(command); m_demolishHoverBuildingId = kInvalidBuildingId; } } } else { const QVector2D worldPos = widgetToWorld(event->pos()); const entt::entity hitEntity = entityAtWorldPos(m_sim->admin(), worldPos); if (hitEntity != entt::null) { 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) { 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 (!(event->modifiers() & Qt::ControlModifier)) { m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); } 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); } 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 int x0 = std::min(m_boxStartTile.x(), m_boxCurrentTile.x()); const int y0 = std::min(m_boxStartTile.y(), m_boxCurrentTile.y()); const int x1 = std::max(m_boxStartTile.x(), m_boxCurrentTile.x()); const int y1 = std::max(m_boxStartTile.y(), m_boxCurrentTile.y()); std::vector boxSel; for (const Building& b : m_sim->buildings().allBuildings()) { for (const QPoint& cell : b.bodyCells) { if (cell.x() >= x0 && cell.x() <= x1 && cell.y() >= y0 && cell.y() <= y1) { boxSel.push_back(b.id); break; } } } for (const ConstructionSite& s : m_sim->buildings().allSites()) { for (const QPoint& cell : s.bodyCells) { if (cell.x() >= x0 && cell.x() <= x1 && cell.y() >= y0 && cell.y() <= y1) { boxSel.push_back(s.id); break; } } } if (!(event->modifiers() & Qt::ControlModifier)) { m_selectedBuildingIds = boxSel; } else { for (BuildingId id : boxSel) { 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)); } } // --------------------------------------------------------------------------- // 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::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::gameSpeed() const { return m_gameSpeedMultiplier; } bool GameWorldView::isDebugDrawEnabled() const { return m_debugDraw; } void GameWorldView::resetFrameTimer() { m_frameTimer.restart(); } void GameWorldView::setGameSpeed(double multiplier) { m_gameSpeedMultiplier = multiplier; EventManager::getInstance()->sendEventImmediately( std::make_shared(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_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); 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 tilePx()*0.2). float maxRadius = 0.125f; if (m_sim->admin().isValid(event->target) && m_sim->admin().hasAll(event->target)) { const StationBodyComponent& sb = m_sim->admin().get(event->target); const int shorter = std::min(sb.footprint.width(), sb.footprint.height()); maxRadius = shorter / 2.0f; } else if (m_sim->admin().isValid(event->target) && m_sim->admin().hasAll(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->buildingBlocksStock() >= def->cost; }