diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 372421a..802e9fa 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -71,16 +71,44 @@ unset(SRCS) # ============================================================ # ui — QtWidgets + QOpenGLWidget -# Depends on lib. No sources yet; declared as INTERFACE library. -# When UI source files are added under src/ui/, change this to -# a regular static library and enable AUTOMOC on the target. +# Depends on lib. # ============================================================ +set(HDRS) +set(SRCS) + add_subdirectory(ui) -add_library(${TARGET_UI_NAME} INTERFACE) +set(CMAKE_AUTOMOC ON) -target_link_libraries(${TARGET_UI_NAME} INTERFACE +set(RELATIVE_HDRS) +foreach (_file ${HDRS}) + file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}") + list(APPEND RELATIVE_HDRS "${_relPath}") +endforeach() + +set(RELATIVE_SRCS) +foreach (_file ${SRCS}) + file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}") + list(APPEND RELATIVE_SRCS "${_relPath}") +endforeach() + +add_files(UI_FILES ${RELATIVE_HDRS} ${RELATIVE_SRCS}) + +add_library(${TARGET_UI_NAME} STATIC ${UI_FILES}) +create_source_groups(${UI_FILES}) + +set_target_properties(${TARGET_UI_NAME} PROPERTIES + AUTOMOC ON + CXX_STANDARD 17 +) +set_property(TARGET ${TARGET_UI_NAME} PROPERTY INCLUDE_DIRECTORIES + "${TARGET_UI_INCLUDE_DIRS}" + "${TARGET_LIB_INCLUDE_DIRS}" + "${LIB_INCLUDE_PATH}" +) + +target_link_libraries(${TARGET_UI_NAME} ${TARGET_LIB_NAME} Qt5::Widgets ${OPENGL_LIBRARIES} @@ -89,11 +117,13 @@ target_link_libraries(${TARGET_UI_NAME} INTERFACE Qt5::Charts ) -target_include_directories(${TARGET_UI_NAME} INTERFACE - "${TARGET_UI_INCLUDE_DIRS}" - "${TARGET_LIB_INCLUDE_DIRS}" - "${LIB_INCLUDE_PATH}" -) +set(CMAKE_AUTOMOC OFF) + +unset(UI_FILES) +unset(RELATIVE_HDRS) +unset(RELATIVE_SRCS) +unset(HDRS) +unset(SRCS) # ============================================================ @@ -145,6 +175,9 @@ set_property(TARGET ${TARGET_APP_NAME} PROPERTY CXX_STANDARD 17) set_target_properties(${TARGET_APP_NAME} PROPERTIES VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/app/" ) +target_compile_definitions(${TARGET_APP_NAME} PRIVATE + DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/config" +) target_link_libraries(${TARGET_APP_NAME} ${TARGET_UI_NAME}) unset(APP_FILES) diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 83c6803..b9b25b4 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,5 +12,3 @@ SET(SRCS PARENT_SCOPE ) - - diff --git a/src/app/main.cpp b/src/app/main.cpp index 13c4239..4b322a0 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -3,9 +3,13 @@ #include #include +#include "ConfigLoader.h" #include "ConsoleLogger.h" #include "logging.h" #include "LogManager.h" +#include "MainWindow.h" +#include "Simulation.h" +#include "VisualsLoader.h" int main(int argc, char *argv[]) { @@ -28,6 +32,13 @@ int main(int argc, char *argv[]) QDir().mkdir(dataDir.dirName()); } + GameConfig config = ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR); + VisualsConfig visuals = VisualsLoader::load(std::string(DOTA_FACTORY_CONFIG_DIR) + "/visuals.toml"); + std::unique_ptr sim = std::make_unique(config); + + MainWindow window(sim.get(), &config, &visuals); + window.show(); + const int ret = application.exec(); return ret; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index a17065c..83ddbc9 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -389,6 +389,25 @@ bool Simulation::isBlueprintUnlocked(const std::string& shipId) const return it->second.unlocked; } +EntityId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) +{ + int cost = 0; + for (const BuildingDef& def : m_config.buildings.buildings) + { + if (def.type == type) + { + cost = def.cost; + break; + } + } + if (m_buildingBlocksStock < cost) + { + return kInvalidEntityId; + } + m_buildingBlocksStock -= cost; + return m_buildingSystem->place(type, anchor, rotation, m_currentTick); +} + BuildingSystem& Simulation::buildings() { return *m_buildingSystem; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index cbc4d7d..c076c96 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -6,11 +6,15 @@ #include #include +#include + #include "BeltSystem.h" #include "BlueprintDropEvent.h" +#include "BuildingType.h" #include "EntityId.h" #include "FireEvent.h" #include "GameConfig.h" +#include "Rotation.h" #include "Tick.h" class BuildingSystem; @@ -44,6 +48,10 @@ public: int blueprintLevel(const std::string& shipId) const; bool isBlueprintUnlocked(const std::string& shipId) const; + // Checks affordability, deducts building blocks, and places the building. + // Returns the new entity id, or kInvalidEntityId if blocks are insufficient. + EntityId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); + BuildingSystem& buildings(); const BuildingSystem& buildings() const; BeltSystem& belts(); diff --git a/src/ui/BuildButtonGrid.cpp b/src/ui/BuildButtonGrid.cpp new file mode 100644 index 0000000..3d7f4d8 --- /dev/null +++ b/src/ui/BuildButtonGrid.cpp @@ -0,0 +1,129 @@ +#include "BuildButtonGrid.h" + +#include +#include + +#include +#include +#include + +#include "BuildingType.h" + +namespace +{ + +QString displayName(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; +} + +} // namespace + + +BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent) + : QWidget(parent) + , m_config(config) + , m_activeIndex(-1) +{ + QGridLayout* layout = new QGridLayout(this); + layout->setSpacing(4); + layout->setContentsMargins(4, 4, 4, 4); + + QSignalMapper* mapper = new QSignalMapper(this); + int col = 0; + int row = 0; + const int kCols = 3; + + for (const BuildingDef& def : config->buildings.buildings) + { + if (!def.playerPlaceable) + { + continue; + } + m_types.push_back(def.type); + m_costs[def.type] = def.cost; + + const QString label = displayName(def.id) + + "\n" + QString::number(def.cost) + " Blocks"; + QPushButton* btn = new QPushButton(label, this); + btn->setCheckable(true); + btn->setFixedHeight(48); + layout->addWidget(btn, row, col); + + const int idx = static_cast(m_buttons.size()); + m_buttons.push_back(btn); + mapper->setMapping(btn, idx); + connect(btn, SIGNAL(clicked()), mapper, SLOT(map())); + + ++col; + if (col >= kCols) + { + col = 0; + ++row; + } + } + connect(mapper, SIGNAL(mapped(int)), this, SLOT(onBuildButton(int))); +} + +void BuildButtonGrid::updateAffordability(int buildingBlocks) +{ + for (std::size_t i = 0; i < m_buttons.size(); ++i) + { + const BuildingType type = m_types[i]; + const std::map::const_iterator it = m_costs.find(type); + const int cost = (it != m_costs.end()) ? it->second : 0; + m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == static_cast(i)); + } +} + +void BuildButtonGrid::clearActiveButton() +{ + if (m_activeIndex >= 0 && m_activeIndex < static_cast(m_buttons.size())) + { + m_buttons[static_cast(m_activeIndex)]->setChecked(false); + } + m_activeIndex = -1; +} + +void BuildButtonGrid::onBuildButton(int index) +{ + if (index < 0 || index >= static_cast(m_buttons.size())) + { + return; + } + + if (m_activeIndex == index) + { + clearActiveButton(); + emit builderModeExited(); + return; + } + + if (m_activeIndex >= 0 && m_activeIndex < static_cast(m_buttons.size())) + { + m_buttons[static_cast(m_activeIndex)]->setChecked(false); + } + + m_activeIndex = index; + m_buttons[static_cast(index)]->setChecked(true); + emit buildingTypeSelected(m_types[static_cast(index)]); +} diff --git a/src/ui/BuildButtonGrid.h b/src/ui/BuildButtonGrid.h new file mode 100644 index 0000000..eaa215a --- /dev/null +++ b/src/ui/BuildButtonGrid.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include + +#include "BuildingType.h" +#include "GameConfig.h" + +class QPushButton; + +class BuildButtonGrid : public QWidget +{ + Q_OBJECT + +public: + BuildButtonGrid(const GameConfig* config, QWidget* parent = nullptr); + + void updateAffordability(int buildingBlocks); + void clearActiveButton(); + +signals: + void buildingTypeSelected(BuildingType type); + void builderModeExited(); + +private slots: + void onBuildButton(int index); + +private: + const GameConfig* m_config; + std::vector m_types; + std::vector m_buttons; + std::map m_costs; + int m_activeIndex; +}; diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index a06e79b..a55d6a2 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -1,7 +1,22 @@ -# UI source files are listed here as they are added. -# Append headers and sources to HDRS and SRCS in PARENT_SCOPE, -# following the same pattern used by src/lib/. -# -# When this directory has actual sources, the parent CMakeLists.txt -# must be updated to convert DotaFactory_ui from an INTERFACE library -# to a regular static library (and enable AUTOMOC on it). +SET(HDRS + ${HDRS} + ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h + ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h + ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h + ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h + ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h + PARENT_SCOPE +) + +SET(SRCS + ${SRCS} + ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp + PARENT_SCOPE +) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp new file mode 100644 index 0000000..aea1677 --- /dev/null +++ b/src/ui/GameWorldView.cpp @@ -0,0 +1,982 @@ +#include "GameWorldView.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Building.h" +#include "BuildingSystem.h" +#include "BeltSystem.h" +#include "Scrap.h" +#include "ScrapSystem.h" +#include "Ship.h" +#include "ShipSystem.h" +#include "Simulation.h" +#include "SurfaceMask.h" +#include "Tick.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; +} + +ShipRole shipRole(const Ship& ship) +{ + if (ship.isEnemy) { return ShipRole::Enemy; } + if (ship.cargo.has_value()) { return ShipRole::Salvage; } + if (ship.repairTool.has_value()) { return ShipRole::Repair; } + return ShipRole::PlayerCombat; +} + +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; +} + +} // 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_demolishHoverId(kInvalidEntityId) + , 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, SIGNAL(timeout()), this, SLOT(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) + { + ActiveBeam beam; + beam.event = fe; + beam.emittedWallMs = m_wallMs; + m_activeBeams.push_back(beam); + } + } + + // Drain blueprint drop events → toasts + { + const std::vector drops = + m_sim->drainBlueprintDropEvents(); + for (const BlueprintDropEvent& ev : drops) + { + const QString shipName = toDisplayName(ev.blueprintId); + ToastEntry toast; + if (ev.wasNewUnlock) + { + toast.text = "Blueprint unlocked: " + shipName; + } + else + { + toast.text = shipName + " production level \u2192 " + + 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(); + } + + // Emit state update for header bar / build grid + emit stateUpdated(m_sim->currentTick(), + m_sim->buildingBlocksStock(), + m_gameSpeedMultiplier); + + // 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); + painter.translate(-static_cast(m_scrollXTiles) * static_cast(tilePx()), 0.0); + + drawTiles(painter); + drawBuildings(painter); + drawBeltItems(painter); + drawScrap(painter); + drawShips(painter); + drawBeams(painter); + drawOverlays(painter); + drawScreenSpace(painter); +} + +// --------------------------------------------------------------------------- +// Coordinate helpers +// --------------------------------------------------------------------------- + +float GameWorldView::tilePx() const +{ + return 20.0f; +} + +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))); +} + +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); + 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 + + m_config->world.regions.contestZoneWidth); + for (const Building& b : m_sim->buildings().allBuildings()) + { + if (b.type == BuildingType::EnemyDefenceStation) + { + for (const QPoint& cell : b.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); + + 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; } + + // Occupancy check + if (m_sim->buildings().isTileOccupied(worldCell)) { return false; } + } + return true; +} + +EntityId 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 kInvalidEntityId; +} + +std::optional GameWorldView::entityPosition(EntityId id) const +{ + const Ship* ship = m_sim->ships().findShip(id); + if (ship) + { + return ship->position; + } + const Building* bldg = m_sim->buildings().findBuilding(id); + if (bldg) + { + return QVector2D( + bldg->anchor.x() + bldg->footprint.width() * 0.5f, + bldg->anchor.y() + bldg->footprint.height() * 0.5f); + } + return std::nullopt; +} + +void GameWorldView::stepSpeed(int delta) +{ + const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 4.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::placeAtTile(QPoint tile) +{ + if (!m_builderType.has_value()) { return; } + const BuildingType type = *m_builderType; + + if (!isValidPlacement(type, tile, m_ghostRotation)) { return; } + + if (type == BuildingType::Belt) + { + if (m_beltDragTiles.count(tile) > 0) { return; } + if (!m_sim->buildings().isTileOccupied(tile)) + { + const EntityId id = m_sim->tryPlaceBuilding( + type, tile, m_ghostRotation); + if (id != kInvalidEntityId) + { + m_beltDragTiles.insert(tile); + } + } + } + else if (type == BuildingType::Splitter) + { + if (!m_sim->buildings().isTileOccupied(tile)) + { + m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); + } + } + else + { + m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); + } +} + +// --------------------------------------------------------------------------- +// 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); + } + + bool selected = false; + for (EntityId selId : m_selectedIds) + { + 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); + } + 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 Scrap& scrap : m_sim->scraps().allScraps()) + { + 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::drawShips(QPainter& painter) +{ + for (const Ship& ship : m_sim->ships().allShips()) + { + const ShipRole role = shipRole(ship); + const std::map::const_iterator it = + m_visuals->ships.find(role); + if (it == m_visuals->ships.end()) { continue; } + + const QPointF center = worldToWidget(ship.position); + const QVector2D vel = ship.velocity; + const QVector2D dir = (vel.length() > 0.0001f) + ? vel.normalized() + : QVector2D(1.0f, 0.0f); + 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); + } +} + +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)); + } +} + +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); + } + } + } + + // Demolish hover tint + if (m_demolishMode && m_demolishHoverId != kInvalidEntityId) + { + const Building* b = m_sim->buildings().findBuilding(m_demolishHoverId); + 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); + } + break; + case Qt::Key_Q: + if (m_builderType.has_value()) + { + m_ghostRotation = rotateCounterClockwise(m_ghostRotation); + m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation); + } + break; + case Qt::Key_Escape: + if (m_builderType.has_value()) + { + exitBuilderMode(); + } + else if (m_demolishMode) + { + m_demolishMode = false; + m_demolishHoverId = kInvalidEntityId; + } + break; + case Qt::Key_Backspace: + if (m_demolishMode) + { + m_demolishMode = false; + m_demolishHoverId = kInvalidEntityId; + } + else + { + if (m_builderType.has_value()) { exitBuilderMode(); } + m_demolishMode = true; + } + 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 && m_builderType.has_value()) + { + exitBuilderMode(); + } + 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_demolishMode) + { + const EntityId hovered = buildingAtTile(tile); + if (hovered != kInvalidEntityId) + { + const Building* b = m_sim->buildings().findBuilding(hovered); + if (b && b->type != BuildingType::Hq + && b->type != BuildingType::PlayerDefenceStation) + { + m_sim->buildings().demolish(hovered); + m_demolishHoverId = kInvalidEntityId; + } + } + } + else + { + const EntityId id = buildingAtTile(tile); + if (id != kInvalidEntityId) + { + if (event->modifiers() & Qt::ControlModifier) + { + bool found = false; + std::vector newSel; + for (EntityId sel : m_selectedIds) + { + if (sel == id) { found = true; } + else { newSel.push_back(sel); } + } + if (!found) { newSel.push_back(id); } + m_selectedIds = newSel; + } + else + { + m_selectedIds = { id }; + } + emit selectionChanged(m_selectedIds); + } + else + { + if (!(event->modifiers() & Qt::ControlModifier)) + { + m_selectedIds.clear(); + emit selectionChanged(m_selectedIds); + } + 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_demolishMode) + { + m_demolishHoverId = 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; + } + } + } + + if (!(event->modifiers() & Qt::ControlModifier)) + { + m_selectedIds = boxSel; + } + else + { + for (EntityId id : boxSel) + { + bool found = false; + for (EntityId sel : m_selectedIds) + { + if (sel == id) { found = true; break; } + } + if (!found) { m_selectedIds.push_back(id); } + } + } + emit selectionChanged(m_selectedIds); + } +} + +// --------------------------------------------------------------------------- +// Slots +// --------------------------------------------------------------------------- + +void GameWorldView::enterBuilderMode(BuildingType type) +{ + m_builderType = type; + m_ghostRotation = Rotation::East; + m_ghostValid = false; + m_demolishMode = false; +} + +void GameWorldView::exitBuilderMode() +{ + m_builderType.reset(); + m_beltDragTiles.clear(); + m_dragging = false; + emit builderModeExited(); +} + +void GameWorldView::setGameSpeed(double multiplier) +{ + m_gameSpeedMultiplier = multiplier; + emit stateUpdated(m_sim->currentTick(), + m_sim->buildingBlocksStock(), + m_gameSpeedMultiplier); +} diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h new file mode 100644 index 0000000..41b2b33 --- /dev/null +++ b/src/ui/GameWorldView.h @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "BlueprintDropEvent.h" +#include "BuildingType.h" +#include "EntityId.h" +#include "FireEvent.h" +#include "GameConfig.h" +#include "Rotation.h" +#include "Tick.h" +#include "TickDriver.h" +#include "VisualsConfig.h" + +class Simulation; +class QPainter; + +struct QPointCompare +{ + bool operator()(const QPoint& a, const QPoint& b) const + { + if (a.x() != b.x()) { return a.x() < b.x(); } + return a.y() < b.y(); + } +}; + +class GameWorldView : public QOpenGLWidget +{ + Q_OBJECT + +public: + GameWorldView(Simulation* sim, const GameConfig* config, + const VisualsConfig* visuals, QWidget* parent = nullptr); + +signals: + void selectionChanged(const std::vector& ids); + void stateUpdated(Tick tick, int blocks, double speed); + void gameOver(); + void builderModeExited(); + +public slots: + void enterBuilderMode(BuildingType type); + void exitBuilderMode(); + void setGameSpeed(double multiplier); + +protected: + void initializeGL() override; + void paintGL() override; + void keyPressEvent(QKeyEvent* event) override; + void keyReleaseEvent(QKeyEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + +private slots: + void onFrame(); + +private: + void drawTiles(QPainter& painter); + void drawBuildings(QPainter& painter); + void drawBeltItems(QPainter& painter); + void drawScrap(QPainter& painter); + void drawShips(QPainter& painter); + void drawBeams(QPainter& painter); + void drawOverlays(QPainter& painter); + void drawScreenSpace(QPainter& painter); + + float tilePx() const; + float viewportWidthTiles() const; + QPointF worldToWidget(QVector2D worldPos) const; + QPointF tileToWidget(QPoint tile) const; + QPoint widgetToTile(QPoint widgetPt) const; + QRectF tileRect(QPoint tile) const; + QRect viewportRect() const; + + float asteroidLeftEdge() const; + float enemyStationRightEdge() const; + void clampScroll(); + + bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const; + const BuildingDef* findBuildingDef(BuildingType type) const; + EntityId buildingAtTile(QPoint tile) const; + + std::optional entityPosition(EntityId id) const; + void stepSpeed(int delta); + void placeAtTile(QPoint tile); + + struct ActiveBeam + { + FireEvent event; + qint64 emittedWallMs; + }; + + struct ToastEntry + { + QString text; + qint64 createdWallMs; + }; + + static constexpr qint64 kBeamLifetimeMs = 300; + static constexpr qint64 kToastLifetimeMs = 4000; + static constexpr qint64 kToastFadeStartMs = 3500; + static constexpr float kScrollSpeedTilesPerSec = 10.0f; + + Simulation* m_sim; + const GameConfig* m_config; + const VisualsConfig* m_visuals; + + TickDriver m_tickDriver; + QElapsedTimer m_frameTimer; + qint64 m_wallMs; + double m_gameSpeedMultiplier; + double m_prevNonZeroSpeed; + float m_scrollXTiles; + + QTimer* m_renderTimer; + + std::vector m_activeBeams; + std::vector m_toasts; + + std::optional m_builderType; + Rotation m_ghostRotation; + QPoint m_ghostTile; + bool m_ghostValid; + std::set m_beltDragTiles; + bool m_dragging; + + bool m_demolishMode; + EntityId m_demolishHoverId; + + std::vector m_selectedIds; + bool m_boxSelecting; + QPoint m_boxStartTile; + QPoint m_boxCurrentTile; + + bool m_scrollLeft; + bool m_scrollRight; + bool m_gameOverShown; +}; diff --git a/src/ui/HeaderBar.cpp b/src/ui/HeaderBar.cpp new file mode 100644 index 0000000..3abdd25 --- /dev/null +++ b/src/ui/HeaderBar.cpp @@ -0,0 +1,72 @@ +#include "HeaderBar.h" + +#include +#include + +#include +#include +#include +#include + +#include "Tick.h" + +const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 4.0 }; +const int HeaderBar::kSpeedCount = 5; + +HeaderBar::HeaderBar(QWidget* parent) + : QWidget(parent) +{ + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(8, 4, 8, 4); + layout->setSpacing(8); + + m_timeLabel = new QLabel("00:00", this); + m_blocksLabel = new QLabel("Blocks: 0", this); + layout->addWidget(m_timeLabel); + layout->addWidget(m_blocksLabel); + layout->addStretch(); + + const char* labels[] = { "0x", "0.5x", "1x", "2x", "4x" }; + QSignalMapper* mapper = new QSignalMapper(this); + for (int i = 0; i < kSpeedCount; ++i) + { + QPushButton* btn = new QPushButton(labels[i], this); + btn->setCheckable(true); + btn->setChecked(i == 2); + layout->addWidget(btn); + m_speedButtons.push_back(btn); + mapper->setMapping(btn, i); + connect(btn, SIGNAL(clicked()), mapper, SLOT(map())); + } + connect(mapper, SIGNAL(mapped(int)), this, SLOT(onSpeedButton(int))); + + setFixedHeight(sizeHint().height()); +} + +void HeaderBar::onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed) +{ + const int totalSeconds = static_cast(ticksToSeconds(tick)); + const int minutes = totalSeconds / 60; + const int seconds = totalSeconds % 60; + + m_timeLabel->setText( + QString("%1:%2") + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0'))); + + m_blocksLabel->setText(QString("Blocks: %1").arg(buildingBlocks)); + + for (int i = 0; i < kSpeedCount; ++i) + { + const bool active = (std::abs(kSpeeds[i] - gameSpeed) < 0.001); + m_speedButtons[static_cast(i)]->setChecked(active); + } +} + +void HeaderBar::onSpeedButton(int index) +{ + if (index >= 0 && index < kSpeedCount) + { + emit speedChanged(kSpeeds[index]); + } +} diff --git a/src/ui/HeaderBar.h b/src/ui/HeaderBar.h new file mode 100644 index 0000000..d4261f9 --- /dev/null +++ b/src/ui/HeaderBar.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include + +#include "Tick.h" + +class QLabel; +class QPushButton; + +class HeaderBar : public QWidget +{ + Q_OBJECT + +public: + explicit HeaderBar(QWidget* parent = nullptr); + +public slots: + void onStateUpdated(Tick tick, int buildingBlocks, double gameSpeed); + +signals: + void speedChanged(double multiplier); + +private slots: + void onSpeedButton(int index); + +private: + QLabel* m_timeLabel; + QLabel* m_blocksLabel; + std::vector m_speedButtons; + + static const double kSpeeds[]; + static const int kSpeedCount; +}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp new file mode 100644 index 0000000..2f4e73a --- /dev/null +++ b/src/ui/MainWindow.cpp @@ -0,0 +1,109 @@ +#include "MainWindow.h" + +#include +#include +#include +#include + +#include "BuildButtonGrid.h" +#include "GameWorldView.h" +#include "HeaderBar.h" +#include "SelectedBuildingPanel.h" +#include "Simulation.h" +#include "Tick.h" + +MainWindow::MainWindow(Simulation* sim, const GameConfig* config, + const VisualsConfig* visuals, QWidget* parent) + : QWidget(parent) + , m_sim(sim) +{ + setWindowTitle("Dota Factory"); + resize(1280, 768); + + m_headerBar = new HeaderBar(this); + + m_gameWorldView = new GameWorldView(sim, config, visuals, this); + + m_bottomPanel = new QWidget(this); + QHBoxLayout* bottomLayout = new QHBoxLayout(m_bottomPanel); + bottomLayout->setContentsMargins(0, 0, 0, 0); + bottomLayout->setSpacing(0); + + m_selectedBuildingPanel = new SelectedBuildingPanel(sim, config, m_bottomPanel); + m_buildButtonGrid = new BuildButtonGrid(config, m_bottomPanel); + + bottomLayout->addWidget(m_selectedBuildingPanel, 1); + bottomLayout->addWidget(m_buildButtonGrid, 1); + + // Signals: game world → other panels + connect(m_gameWorldView, SIGNAL(selectionChanged(std::vector)), + m_selectedBuildingPanel, SLOT(onSelectionChanged(std::vector))); + + connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)), + m_headerBar, SLOT(onStateUpdated(Tick, int, double))); + + connect(m_gameWorldView, SIGNAL(stateUpdated(Tick, int, double)), + this, SLOT(onStateUpdated(Tick, int, double))); // for affordability + + connect(m_gameWorldView, SIGNAL(gameOver()), + this, SLOT(onGameOver())); + + // Signals: build grid → game world + connect(m_buildButtonGrid, SIGNAL(buildingTypeSelected(BuildingType)), + m_gameWorldView, SLOT(enterBuilderMode(BuildingType))); + + connect(m_buildButtonGrid, SIGNAL(builderModeExited()), + m_gameWorldView, SLOT(exitBuilderMode())); + + connect(m_gameWorldView, SIGNAL(builderModeExited()), + m_buildButtonGrid, SLOT(clearActiveButton())); + + // Signals: header bar → game world + connect(m_headerBar, SIGNAL(speedChanged(double)), + m_gameWorldView, SLOT(setGameSpeed(double))); + + m_gameWorldView->setFocus(); +} + +void MainWindow::resizeEvent(QResizeEvent* event) +{ + QWidget::resizeEvent(event); + layoutPanels(); +} + +void MainWindow::layoutPanels() +{ + const int totalW = width(); + const int totalH = height(); + const int headerH = m_headerBar->sizeHint().height(); + if (headerH <= 0) { return; } + const int remaining = totalH - headerH; + const int gameH = remaining * 70 / 100; + const int panelH = remaining - gameH; + + m_headerBar->setGeometry(0, 0, totalW, headerH); + m_gameWorldView->setGeometry(0, headerH, totalW, gameH); + m_bottomPanel->setGeometry(0, headerH + gameH, totalW, panelH); +} + +void MainWindow::onStateUpdated(Tick /*tick*/, int blocks, double /*speed*/) +{ + m_buildButtonGrid->updateAffordability(blocks); +} + +void MainWindow::onGameOver() +{ + const Tick tick = m_sim->currentTick(); + const int totalSeconds = static_cast(ticksToSeconds(tick)); + const int minutes = totalSeconds / 60; + const int seconds = totalSeconds % 60; + + QMessageBox box(this); + box.setWindowTitle("Game Over"); + box.setText(QString("HQ destroyed!\nSurvival time: %1:%2") + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0'))); + box.addButton("Quit", QMessageBox::RejectRole); + box.exec(); + close(); +} diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h new file mode 100644 index 0000000..52f571c --- /dev/null +++ b/src/ui/MainWindow.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include "GameConfig.h" +#include "Tick.h" +#include "VisualsConfig.h" + +class Simulation; +class GameWorldView; +class HeaderBar; +class SelectedBuildingPanel; +class BuildButtonGrid; +class QResizeEvent; + +class MainWindow : public QWidget +{ + Q_OBJECT + +public: + MainWindow(Simulation* sim, const GameConfig* config, + const VisualsConfig* visuals, QWidget* parent = nullptr); + +protected: + void resizeEvent(QResizeEvent* event) override; + +private slots: + void onGameOver(); + void onStateUpdated(Tick tick, int blocks, double speed); + +private: + void layoutPanels(); + + Simulation* m_sim; + GameWorldView* m_gameWorldView; + HeaderBar* m_headerBar; + SelectedBuildingPanel* m_selectedBuildingPanel; + BuildButtonGrid* m_buildButtonGrid; + QWidget* m_bottomPanel; +}; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp new file mode 100644 index 0000000..16058b4 --- /dev/null +++ b/src/ui/SelectedBuildingPanel.cpp @@ -0,0 +1,287 @@ +#include "SelectedBuildingPanel.h" + +#include +#include + +#include +#include +#include +#include + +#include "Building.h" +#include "BuildingType.h" +#include "Simulation.h" + +namespace +{ + +QString buildingTypeName(BuildingType type) +{ + const std::string id = buildingTypeId(type); + 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; +} + +bool isProductionBuilding(BuildingType type) +{ + return type == BuildingType::Miner + || type == BuildingType::Smelter + || type == BuildingType::Assembler + || type == BuildingType::ReprocessingPlant + || type == BuildingType::Shipyard; +} + +bool isBeltLike(BuildingType type) +{ + return type == BuildingType::Belt || type == BuildingType::Splitter; +} + +} // namespace + + +SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, + const GameConfig* config, + QWidget* parent) + : QWidget(parent) + , m_sim(sim) + , m_config(config) + , m_singleId(kInvalidEntityId) +{ + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(8, 8, 8, 8); + m_layout->setSpacing(4); + m_layout->setAlignment(Qt::AlignTop); + + m_titleLabel = new QLabel(this); + m_recipeCombo = new QComboBox(this); + m_clearBeltBtn = new QPushButton("Clear Items", this); + m_buffersLabel = new QLabel(this); + m_buffersLabel->setWordWrap(true); + + m_layout->addWidget(m_titleLabel); + m_layout->addWidget(m_recipeCombo); + m_layout->addWidget(m_clearBeltBtn); + m_layout->addWidget(m_buffersLabel); + + connect(m_recipeCombo, SIGNAL(currentIndexChanged(int)), + this, SLOT(onRecipeChanged(int))); + connect(m_clearBeltBtn, SIGNAL(clicked()), + this, SLOT(onClearBelt())); + + buildEmpty(); +} + +void SelectedBuildingPanel::onSelectionChanged(const std::vector& ids) +{ + m_selection = ids; + rebuild(); +} + +void SelectedBuildingPanel::rebuild() +{ + if (m_selection.empty()) + { + buildEmpty(); + } + else if (m_selection.size() == 1) + { + buildSingle(m_selection[0]); + } + else + { + buildMulti(m_selection); + } +} + +void SelectedBuildingPanel::buildEmpty() +{ + m_singleId = kInvalidEntityId; + m_titleLabel->hide(); + m_recipeCombo->hide(); + m_clearBeltBtn->hide(); + m_buffersLabel->hide(); +} + +void SelectedBuildingPanel::buildSingle(EntityId id) +{ + m_singleId = id; + + const Building* b = m_sim->buildings().findBuilding(id); + if (!b) + { + buildEmpty(); + return; + } + + m_titleLabel->setText(buildingTypeName(b->type)); + m_titleLabel->show(); + m_buffersLabel->show(); + + if (isProductionBuilding(b->type)) + { + m_recipeCombo->blockSignals(true); + m_recipeCombo->clear(); + + m_recipeCombo->addItem("(none)", QString()); + + if (b->type == BuildingType::Shipyard) + { + for (const ShipDef& def : m_config->ships.ships) + { + if (m_sim->isBlueprintUnlocked(def.id)) + { + m_recipeCombo->addItem( + QString::fromStdString(def.id), + QString::fromStdString(def.id)); + } + } + } + else + { + for (const RecipeDef& recipe : m_config->recipes.recipes) + { + if (recipe.building == b->type) + { + m_recipeCombo->addItem( + QString::fromStdString(recipe.id), + QString::fromStdString(recipe.id)); + } + } + } + + const int currentIdx = m_recipeCombo->findData(QString::fromStdString(b->recipeId)); + m_recipeCombo->setCurrentIndex(currentIdx >= 0 ? currentIdx : 0); + m_recipeCombo->blockSignals(false); + m_recipeCombo->show(); + } + else + { + m_recipeCombo->hide(); + } + + if (isBeltLike(b->type)) + { + m_clearBeltBtn->show(); + } + else + { + m_clearBeltBtn->hide(); + } + + QString bufText; + if (!b->inputBuffer.counts.empty()) + { + bufText += "Input: "; + for (const std::pair& entry : b->inputBuffer.counts) + { + const std::map::const_iterator cap = + b->inputBuffer.caps.find(entry.first); + const int capVal = (cap != b->inputBuffer.caps.end()) ? cap->second : 0; + bufText += QString::fromStdString(entry.first.id) + + ": " + QString::number(entry.second) + + "/" + QString::number(capVal) + " "; + } + bufText += "\n"; + } + if (!b->outputBuffer.items.empty()) + { + std::map outCounts; + for (const Item& item : b->outputBuffer.items) + { + outCounts[item.type.id]++; + } + bufText += "Output(" + QString::number(static_cast(b->outputBuffer.items.size())) + + "/" + QString::number(b->outputBuffer.capacity) + "): "; + for (const std::pair& entry : outCounts) + { + bufText += QString::fromStdString(entry.first) + + ":" + QString::number(entry.second) + " "; + } + } + m_buffersLabel->setText(bufText); +} + +void SelectedBuildingPanel::buildMulti(const std::vector& ids) +{ + m_singleId = kInvalidEntityId; + m_recipeCombo->hide(); + m_clearBeltBtn->hide(); + m_buffersLabel->hide(); + + std::map counts; + for (EntityId id : ids) + { + const Building* b = m_sim->buildings().findBuilding(id); + if (b) + { + counts[b->type]++; + } + } + + bool hasBelt = false; + QString text; + for (const std::pair& entry : counts) + { + text += buildingTypeName(entry.first) + ": " + + QString::number(entry.second) + "\n"; + if (isBeltLike(entry.first)) + { + hasBelt = true; + } + } + m_titleLabel->setText(text.trimmed()); + m_titleLabel->show(); + + if (hasBelt) + { + m_clearBeltBtn->show(); + } +} + +void SelectedBuildingPanel::onRecipeChanged(int comboIndex) +{ + if (m_singleId == kInvalidEntityId) + { + return; + } + const QString recipeId = m_recipeCombo->itemData(comboIndex).toString(); + m_sim->buildings().setRecipe(m_singleId, recipeId.toStdString()); +} + +void SelectedBuildingPanel::onClearBelt() +{ + std::vector tiles; + for (EntityId id : m_selection) + { + const Building* b = m_sim->buildings().findBuilding(id); + if (b && isBeltLike(b->type)) + { + for (const QPoint& cell : b->bodyCells) + { + tiles.push_back(cell); + } + } + } + if (!tiles.empty()) + { + m_sim->belts().clearTiles(tiles); + } +} diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h new file mode 100644 index 0000000..adc9fc0 --- /dev/null +++ b/src/ui/SelectedBuildingPanel.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include + +#include "EntityId.h" +#include "GameConfig.h" + +class Simulation; +class QLabel; +class QComboBox; +class QPushButton; +class QVBoxLayout; + +class SelectedBuildingPanel : public QWidget +{ + Q_OBJECT + +public: + SelectedBuildingPanel(Simulation* sim, const GameConfig* config, + QWidget* parent = nullptr); + +public slots: + void onSelectionChanged(const std::vector& ids); + +private slots: + void onRecipeChanged(int comboIndex); + void onClearBelt(); + +private: + void rebuild(); + void clearContent(); + void buildEmpty(); + void buildSingle(EntityId id); + void buildMulti(const std::vector& ids); + + Simulation* m_sim; + const GameConfig* m_config; + std::vector m_selection; + + QVBoxLayout* m_layout; + QLabel* m_titleLabel; + QComboBox* m_recipeCombo; + QPushButton* m_clearBeltBtn; + QLabel* m_buffersLabel; + + EntityId m_singleId; + std::string m_currentRecipeId; +}; diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h new file mode 100644 index 0000000..bd693e1 --- /dev/null +++ b/src/ui/VisualsConfig.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include +#include + +#include "BuildingType.h" + +struct TileVisuals +{ + QColor fill; +}; + +struct BuildingVisuals +{ + QColor fill; + QColor outline; + QString glyph; +}; + +struct ItemVisuals +{ + QColor fill; + QColor outline; +}; + +struct ShipVisuals +{ + QColor fill; + QColor outline; +}; + +struct BeamVisuals +{ + QColor color; + int widthPx; +}; + +struct OverlayVisuals +{ + QColor ghostValid; + QColor ghostInvalid; + QColor demolishTint; + QColor selectionRect; + QColor tileHighlight; + QColor selectedOutline; +}; + +struct ToastVisuals +{ + QColor bg; + QColor fg; + int fontSize; +}; + +enum class ShipRole +{ + PlayerCombat, + Salvage, + Repair, + Enemy, +}; + +struct VisualsConfig +{ + TileVisuals asteroid; + TileVisuals space; + + std::map buildings; + std::map items; + std::map ships; + + BeamVisuals beams; + OverlayVisuals overlays; + ToastVisuals toast; +}; diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp new file mode 100644 index 0000000..1378728 --- /dev/null +++ b/src/ui/VisualsLoader.cpp @@ -0,0 +1,231 @@ +#include "VisualsLoader.h" + +#include +#include +#include + +#include + +#include "toml.hpp" +#include "BuildingType.h" + +namespace +{ + +std::runtime_error makeError(const std::string& section, const std::string& why) +{ + return std::runtime_error("visuals.toml: [" + section + "] " + why); +} + +QColor parseColor(const std::string& s, const std::string& ctx) +{ + if (s.empty() || s[0] != '#') + { + throw std::runtime_error("visuals.toml: invalid color '" + s + "' in " + ctx); + } + if (s.size() == 9) + { + bool ok1 = true, ok2 = true, ok3 = true, ok4 = true; + int r = QString::fromStdString(s.substr(1, 2)).toInt(&ok1, 16); + int g = QString::fromStdString(s.substr(3, 2)).toInt(&ok2, 16); + int b = QString::fromStdString(s.substr(5, 2)).toInt(&ok3, 16); + int a = QString::fromStdString(s.substr(7, 2)).toInt(&ok4, 16); + if (!ok1 || !ok2 || !ok3 || !ok4) + { + throw std::runtime_error("visuals.toml: malformed color '" + s + "' in " + ctx); + } + return QColor(r, g, b, a); + } + QColor c(QString::fromStdString(s)); + if (!c.isValid()) + { + throw std::runtime_error("visuals.toml: invalid color '" + s + "' in " + ctx); + } + return c; +} + +std::string requireString(toml::table& tbl, const std::string& key, + const std::string& ctx) +{ + const std::optional v = tbl[key].value(); + if (!v) + { + throw makeError(ctx, "missing or invalid string '" + key + "'"); + } + return *v; +} + +int requireInt(toml::table& tbl, const std::string& key, const std::string& ctx) +{ + const std::optional v = tbl[key].value(); + if (!v) + { + throw makeError(ctx, "missing or invalid integer '" + key + "'"); + } + return static_cast(*v); +} + +toml::table& requireSubtable(toml::table& tbl, const std::string& key, + const std::string& ctx) +{ + toml::table* sub = tbl[key].as_table(); + if (sub == nullptr) + { + throw makeError(ctx, "missing section '" + key + "'"); + } + return *sub; +} + +TileVisuals parseTile(toml::table& tbl, const std::string& ctx) +{ + TileVisuals v; + v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill"); + return v; +} + +BuildingVisuals parseBuilding(toml::table& tbl, const std::string& ctx) +{ + BuildingVisuals v; + v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill"); + v.outline = parseColor(requireString(tbl, "outline", ctx), ctx + ".outline"); + v.glyph = QString::fromStdString(requireString(tbl, "glyph", ctx)); + return v; +} + +ItemVisuals parseItem(toml::table& tbl, const std::string& ctx) +{ + ItemVisuals v; + v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill"); + v.outline = parseColor(requireString(tbl, "outline", ctx), ctx + ".outline"); + return v; +} + +ShipVisuals parseShip(toml::table& tbl, const std::string& ctx) +{ + ShipVisuals v; + v.fill = parseColor(requireString(tbl, "fill", ctx), ctx + ".fill"); + v.outline = parseColor(requireString(tbl, "outline", ctx), ctx + ".outline"); + return v; +} + +struct BuildingEntry +{ + const char* key; + BuildingType type; +}; + +const BuildingEntry kBuildingEntries[] = { + { "hq", BuildingType::Hq }, + { "miner", BuildingType::Miner }, + { "smelter", BuildingType::Smelter }, + { "assembler", BuildingType::Assembler }, + { "reprocessing_plant",BuildingType::ReprocessingPlant }, + { "shipyard", BuildingType::Shipyard }, + { "salvage_bay", BuildingType::SalvageBay }, + { "belt", BuildingType::Belt }, + { "splitter", BuildingType::Splitter }, +}; + +} // namespace + + +VisualsConfig VisualsLoader::load(const std::string& path) +{ + toml::table tbl; + try + { + tbl = toml::parse_file(path); + } + catch (const toml::parse_error& e) + { + std::ostringstream oss; + oss << "visuals.toml: parse error: " << e.description() + << " at " << e.source().begin; + throw std::runtime_error(oss.str()); + } + + VisualsConfig cfg; + + // Tiles + { + toml::table& tiles = requireSubtable(tbl, "tiles", "root"); + cfg.asteroid = parseTile(requireSubtable(tiles, "asteroid", "tiles"), "tiles.asteroid"); + cfg.space = parseTile(requireSubtable(tiles, "space", "tiles"), "tiles.space"); + } + + // Buildings ([buildings.*] sections) + { + toml::table& bldgs = requireSubtable(tbl, "buildings", "root"); + for (const BuildingEntry& entry : kBuildingEntries) + { + std::string ctx = std::string("buildings.") + entry.key; + cfg.buildings[entry.type] = parseBuilding( + requireSubtable(bldgs, entry.key, "buildings"), ctx); + } + } + + // Stations ([stations.*] → mapped as PlayerDefenceStation / EnemyDefenceStation) + { + toml::table& stns = requireSubtable(tbl, "stations", "root"); + cfg.buildings[BuildingType::PlayerDefenceStation] = parseBuilding( + requireSubtable(stns, "player", "stations"), "stations.player"); + cfg.buildings[BuildingType::EnemyDefenceStation] = parseBuilding( + requireSubtable(stns, "enemy", "stations"), "stations.enemy"); + } + + // Items (iterate all keys in [items]) + { + toml::table& items = requireSubtable(tbl, "items", "root"); + for (toml::table::iterator it = items.begin(); it != items.end(); ++it) + { + std::string itemId = std::string(it->first.str()); + toml::table* sub = it->second.as_table(); + if (sub == nullptr) + { + throw std::runtime_error("visuals.toml: items." + itemId + " is not a table"); + } + cfg.items[itemId] = parseItem(*sub, "items." + itemId); + } + } + + // Ships + { + toml::table& ships = requireSubtable(tbl, "ships", "root"); + cfg.ships[ShipRole::PlayerCombat] = parseShip( + requireSubtable(ships, "player_combat", "ships"), "ships.player_combat"); + cfg.ships[ShipRole::Salvage] = parseShip( + requireSubtable(ships, "salvage", "ships"), "ships.salvage"); + cfg.ships[ShipRole::Repair] = parseShip( + requireSubtable(ships, "repair", "ships"), "ships.repair"); + cfg.ships[ShipRole::Enemy] = parseShip( + requireSubtable(ships, "enemy", "ships"), "ships.enemy"); + } + + // Beams + { + toml::table& beams = requireSubtable(tbl, "beams", "root"); + cfg.beams.color = parseColor(requireString(beams, "color", "beams"), "beams.color"); + cfg.beams.widthPx = requireInt(beams, "width_px", "beams"); + } + + // Overlays + { + toml::table& ov = requireSubtable(tbl, "overlays", "root"); + cfg.overlays.ghostValid = parseColor(requireString(ov, "ghost_valid", "overlays"), "overlays.ghost_valid"); + cfg.overlays.ghostInvalid = parseColor(requireString(ov, "ghost_invalid", "overlays"), "overlays.ghost_invalid"); + cfg.overlays.demolishTint = parseColor(requireString(ov, "demolish_tint", "overlays"), "overlays.demolish_tint"); + cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect"); + cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight"); + cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline"); + } + + // Toast + { + toml::table& t = requireSubtable(tbl, "toast", "root"); + cfg.toast.bg = parseColor(requireString(t, "bg", "toast"), "toast.bg"); + cfg.toast.fg = parseColor(requireString(t, "fg", "toast"), "toast.fg"); + cfg.toast.fontSize = requireInt(t, "font_size", "toast"); + } + + return cfg; +} diff --git a/src/ui/VisualsLoader.h b/src/ui/VisualsLoader.h new file mode 100644 index 0000000..cd680a9 --- /dev/null +++ b/src/ui/VisualsLoader.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include "VisualsConfig.h" + +class VisualsLoader +{ +public: + static VisualsConfig load(const std::string& path); +};