#include "GameWorldView.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "EntityHitTest.h" #include "EntitySelectedEvent.h" #include "EventManager.h" #include "FacingComponent.h" #include "FactionComponent.h" #include "HealthComponent.h" #include "PositionComponent.h" #include "ScrapSystem.h" #include "SensorRangeComponent.h" #include "ShipIdentityComponent.h" #include "ShipSystem.h" #include "Simulation.h" #include "StationBodyComponent.h" #include "SurfaceMask.h" #include "Tick.h" #include "TracePrintRequestedEvent.h" #include "BossWaveUpdatedEvent.h" #include "BuildingBlocksChangedEvent.h" #include "GameSpeedChangedEvent.h" #include "TickAdvancedEvent.h" namespace { 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; } QString toDisplayName(const std::string& id) { QString result; bool nextUpper = true; for (char c : id) { if (c == '_') { result += ' '; nextUpper = true; } else if (nextUpper) { result += static_cast(std::toupper(static_cast(c))); nextUpper = false; } else { result += c; } } return result; } 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, QWidget* parent) : QOpenGLWidget(parent) , m_sim(sim) , m_config(config) , m_visuals(visuals) , m_wallMs(0) , 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) { 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(); } void GameWorldView::initializeGL() { // QPainter handles all rendering; no custom GL state needed. } void GameWorldView::onFrame() { const qint64 elapsed = m_frameTimer.restart(); m_wallMs += elapsed; // Advance simulation { const int ticks = m_tickDriver.advance( static_cast(elapsed), m_gameSpeedMultiplier); for (int i = 0; i < ticks; ++i) { m_sim->tick(); } } // Drain fire events → active beams { const std::vector fires = m_sim->drainFireEvents(); for (const FireEvent& fe : fires) { float maxRadius = 0.125f; if (m_sim->admin().isValid(fe.target) && m_sim->admin().hasAll(fe.target)) { const StationBodyComponent& sb = m_sim->admin().get(fe.target); const int shorter = std::min(sb.footprint.width(), sb.footprint.height()); maxRadius = shorter / 2.0f; } 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 = fe; beam.emittedWallMs = m_wallMs; beam.targetOffset = QVector2D(radius * std::cos(angle), radius * std::sin(angle)); m_activeBeams.push_back(beam); } } // Drain schematic drop events → toasts { const std::vector drops = m_sim->drainSchematicDropEvents(); for (const SchematicDropEvent& ev : drops) { const QString name = toDisplayName(ev.schematicId); ToastEntry toast; if (ev.isModuleSchematic) { toast.text = ev.wasNewUnlock ? tr("Module unlocked: ") + name : name + tr(" production level -> ") + QString::number(ev.newLevel); } else { toast.text = ev.wasNewUnlock ? tr("Schematic unlocked: ") + name : name + tr(" production level -> ") + QString::number(ev.newLevel); } toast.createdWallMs = m_wallMs; m_toasts.push_back(toast); } } // Expire old beams { std::vector live; for (const ActiveBeam& b : m_activeBeams) { if (m_wallMs - b.emittedWallMs < kBeamLifetimeMs) { live.push_back(b); } } m_activeBeams = std::move(live); } // Expire old toasts { std::vector live; for (const ToastEntry& t : m_toasts) { if (m_wallMs - t.createdWallMs < kToastLifetimeMs) { live.push_back(t); } } m_toasts = std::move(live); } // Apply held scroll { const float delta = kScrollSpeedTilesPerSec * static_cast(elapsed) / 1000.0f; if (m_scrollLeft) { m_scrollXTiles -= delta; } if (m_scrollRight) { m_scrollXTiles += delta; } clampScroll(); } // 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 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 (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown) { m_lastBossCounter = newBoss; m_lastBossCountdown = newCountdown; EventManager::getInstance()->sendEventImmediately( std::make_shared(newBoss, newCountdown)); } } // Game over check if (m_sim->isGameOver() && !m_gameOverShown) { m_gameOverShown = true; m_gameSpeedMultiplier = 0.0; emit gameOver(); } 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); drawDebugOverlay(painter); } drawShips(painter); drawBeams(painter); drawOverlays(painter); drawScreenSpace(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_config->world.regions.asteroidWidth_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 { 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) { const QPoint worldCell = anchor + relCell; // Terrain check: S cells must be space (x >= 0), A cells must be asteroid (x < 0) bool isShipDock = false; for (const QPoint& dock : parsed.shipDockCells) { if (dock == relCell) { isShipDock = true; break; } } if (isShipDock && worldCell.x() < 0) { return false; } if (!isShipDock && worldCell.x() >= 0) { return false; } if (m_sim->buildings().isTileOccupied(worldCell)) { anyOccupied = true; } } 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()) { m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation); continue; } const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); if (id == kInvalidBuildingId) { continue; } if (!bb.recipeId.empty()) { if (bb.type == BuildingType::Shipyard) { if (m_sim->isSchematicUnlocked(bb.recipeId)) { m_sim->buildings().setRecipe(id, bb.recipeId); } } else { m_sim->buildings().setRecipe(id, bb.recipeId); } } if (bb.shipLayout.has_value()) { m_sim->buildings().setShipLayout(id, *bb.shipLayout); } } } 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()) { m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation); return; } if (type == BuildingType::Belt) { if (m_beltDragTiles.count(tile) > 0) { return; } if (!m_sim->buildings().isTileOccupied(tile)) { const BuildingId id = m_sim->tryPlaceBuilding( type, tile, m_ghostRotation); if (id != kInvalidBuildingId) { m_beltDragTiles.insert(tile); } } } else if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { if (!m_sim->buildings().isTileOccupied(tile)) { const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); if (id != kInvalidBuildingId) { if (type == BuildingType::TunnelEntry) { m_builderType = BuildingType::TunnelExit; } else if (type == BuildingType::TunnelExit) { m_builderType = BuildingType::TunnelEntry; } } } } else { m_sim->tryPlaceBuilding(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); 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()); painter.setPen(QPen(it->second.outline, 1)); painter.drawEllipse(center, radiusPx, radiusPx); }); } void GameWorldView::drawDebugOverlay(QPainter& painter) { painter.resetTransform(); const QString line1 = tr("Accumulated Threat Level: %1") .arg(m_sim->threatLevel(), 0, 'f', 1); const QString line2 = tr("Time until Wave: %1s") .arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 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; const int textW = std::max(fm.horizontalAdvance(line1), fm.horizontalAdvance(line2)); const int bgW = textW + padding * 2; const int bgH = lineH * 2 + spacing + padding * 2; const QRect bgRect(padding, padding, bgW, bgH); painter.fillRect(bgRect, QColor(0, 0, 0, 160)); painter.setPen(Qt::white); const QRect textRect1(padding * 2, padding + padding, textW, lineH); const QRect textRect2(padding * 2, textRect1.bottom() + spacing, textW, lineH); painter.drawText(textRect1, Qt::AlignLeft | Qt::AlignVCenter, line1); painter.drawText(textRect2, Qt::AlignLeft | Qt::AlignVCenter, line2); } void GameWorldView::drawBeams(QPainter& painter) { painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx)); 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; } painter.drawLine(worldToWidget(*shooterPos), worldToWidget(*targetPos + beam.targetOffset)); } } void GameWorldView::drawOverlays(QPainter& painter) { // Builder-mode ghost if (m_builderType.has_value()) { const BuildingDef* def = findBuildingDef(*m_builderType); if (def) { const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, m_ghostRotation); const QColor& ghostColor = m_ghostValid ? m_visuals->overlays.ghostValid : m_visuals->overlays.ghostInvalid; for (const QPoint& cell : parsed.bodyCells) { painter.fillRect(tileRect(m_ghostTile + cell), ghostColor); } for (const Port& port : parsed.outputPorts) { const QPoint absBody = m_ghostTile + portBodyTile(port.tile, port.direction); drawPortGlyph(painter, absBody, port.direction, Qt::white); } } } // 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); const QColor& ghostColor = valid ? m_visuals->overlays.ghostValid : m_visuals->overlays.ghostInvalid; const BuildingDef* def = findBuildingDef(bb.type); if (!def) { continue; } const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, bb.rotation); for (const QPoint& cell : parsed.bodyCells) { painter.fillRect(tileRect(anchor + cell), ghostColor); } for (const Port& port : parsed.outputPorts) { drawPortGlyph(painter, anchor + portBodyTile(port.tile, port.direction), port.direction, Qt::white); } } } // 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::drawScreenSpace(QPainter& painter) { painter.resetTransform(); const int margin = 8; const int toastW = 320; const int toastH = 36; const int spacing = 4; QFont toastFont = painter.font(); toastFont.setPointSize(m_visuals->toast.fontSize); painter.setFont(toastFont); int y = margin; for (const ToastEntry& toast : m_toasts) { const qint64 age = m_wallMs - toast.createdWallMs; double opacity = 1.0; if (age > kToastFadeStartMs) { opacity = 1.0 - static_cast(age - kToastFadeStartMs) / static_cast(kToastLifetimeMs - kToastFadeStartMs); opacity = std::max(0.0, opacity); } painter.setOpacity(opacity); const int x = width() - toastW - margin; const QRect toastRect(x, y, toastW, toastH); painter.fillRect(toastRect, m_visuals->toast.bg); painter.setPen(m_visuals->toast.fg); painter.drawText(toastRect.adjusted(8, 0, -8, 0), Qt::AlignVCenter | Qt::AlignLeft, toast.text); painter.setOpacity(1.0); y += toastH + spacing; } } // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- void GameWorldView::keyPressEvent(QKeyEvent* event) { if (event->isAutoRepeat()) { QOpenGLWidget::keyPressEvent(event); 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_E: if (m_builderType.has_value()) { m_ghostRotation = rotateClockwise(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; minX = std::min(minX, -abs.y()); minY = std::min(minY, abs.x()); } bb.offset = QPoint(minX, minY); bb.rotation = rotateClockwise(bb.rotation); } } break; case Qt::Key_Q: if (m_builderType.has_value()) { 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; minX = std::min(minX, abs.y()); minY = std::min(minY, -abs.x()); } bb.offset = QPoint(minX, minY); bb.rotation = rotateCounterClockwise(bb.rotation); } } break; case Qt::Key_Escape: emit escapeMenuRequested(); break; case Qt::Key_Backspace: toggleDemolishMode(); break; case Qt::Key_M: m_debugDraw = !m_debugDraw; break; case Qt::Key_L: 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(); } } 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) { m_sim->demolish(hovered); 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(); emit selectionChanged(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 }; } emit selectionChanged(m_selectedBuildingIds); } else { if (!(event->modifiers() & Qt::ControlModifier)) { m_selectedBuildingIds.clear(); emit selectionChanged(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); } } } emit selectionChanged(m_selectedBuildingIds); } } // --------------------------------------------------------------------------- // 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; } emit demolishModeChanged(m_demolishMode); } void GameWorldView::enterBuilderMode(BuildingType type) { m_builderType = type; m_ghostRotation = Rotation::East; m_ghostValid = false; m_demolishMode = false; m_blueprintMode.reset(); emit demolishModeChanged(false); } void GameWorldView::enterBlueprintMode(Blueprint blueprint) { if (m_builderType.has_value()) { exitBuilderMode(); } m_demolishMode = false; emit demolishModeChanged(false); m_blueprintGhostTile = m_ghostTile; m_blueprintMode = std::move(blueprint); } void GameWorldView::exitBlueprintMode() { m_blueprintMode.reset(); emit blueprintModeExited(); } void GameWorldView::exitBuilderMode() { m_builderType.reset(); m_beltDragTiles.clear(); m_dragging = false; emit builderModeExited(); } double GameWorldView::gameSpeed() const { return m_gameSpeedMultiplier; } 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_toasts.clear(); m_ghostRotation = Rotation::East; m_ghostValid = false; m_demolishMode = false; m_demolishHoverBuildingId = kInvalidBuildingId; emit demolishModeChanged(false); m_selectedBuildingIds.clear(); m_boxSelecting = false; m_scrollXTiles = 0.0f; m_scrollLeft = false; m_scrollRight = false; m_gameOverShown = false; m_prevNonZeroSpeed = 1.0; m_lastTick = Tick(-1); m_lastBlocks = -1; m_lastBossCounter = -1; m_lastBossCountdown = Tick(-1); emit selectionChanged({}); setGameSpeed(1.0); update(); }