#include "MainWindow.h" #include #include #include #include #include #include #include #include #include #include #include #include "BlueprintPanel.h" #include "BuildButtonGrid.h" #include "BuildingSystem.h" #include "Command.h" #include "CommandRequestedEvent.h" #include "ConfigLoader.h" #include "EventManager.h" #include "GameWorldView.h" #include "RecipeSelectionDialog.h" #include "SchematicChoiceDialog.h" #include "HeaderBar.h" #include "SelectedBuildingPanel.h" #include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutDialog.h" #include "ItemIconCache.h" #include "ModalPauseScope.h" #include "Simulation.h" #include "Tick.h" #include "VisualsLoader.h" MainWindow::MainWindow(Simulation* sim, const std::string& configDir, std::shared_ptr replay, QWidget* parent) : QWidget(parent) , m_configDir(configDir) , m_visuals(VisualsLoader::load(configDir + "/visuals.toml")) , m_sim(sim) , m_replay(std::move(replay)) { setWindowTitle(tr("Dota Factory")); resize(1280, 768); // Item icons live alongside the config (a sibling of the config dir), read from // disk at runtime like the building icons and visuals.toml (REQ-UI-ITEM-ICON). const std::string itemsIconDir = QDir::cleanPath( QString::fromStdString(m_configDir) + "/../icons/items").toStdString(); m_itemIcons = std::make_unique( QString::fromStdString(itemsIconDir)); m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this); m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir, m_itemIcons.get(), m_replay.get(), this); m_sidePanel = new QWidget(this); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); sideLayout->setContentsMargins(1, 1, 1, 1); sideLayout->setSpacing(1); // Building icons live alongside the config (a sibling of the config dir), read // from disk at runtime the same way visuals.toml is. const std::string iconDir = QDir::cleanPath( QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString(); m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel); m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel); sideLayout->addWidget(m_selectedBuildingPanel, 1); sideLayout->addWidget(m_buildButtonGrid, 1); sideLayout->addWidget(m_blueprintPanel, 1); // Draw a thin border around each of the three side-panel sections. The class // scoped selectors keep the border on the panels themselves rather than // cascading onto their child widgets; WA_StyledBackground lets the plain // QWidget subclasses honor the stylesheet box (border/background). for (QWidget* panel : { static_cast(m_selectedBuildingPanel), static_cast(m_buildButtonGrid), static_cast(m_blueprintPanel) }) { panel->setAttribute(Qt::WA_StyledBackground, true); } m_sidePanel->setStyleSheet(QStringLiteral( "SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {" " border: 1px solid palette(mid); }")); // Created last so it stacks above the other children; covers the whole window and // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM). m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this); m_gameWorldView->setFocus(); connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) { if (newWidget && newWidget != m_gameWorldView && !QApplication::activeModalWidget()) { m_gameWorldView->setFocus(); } }); // Load layout blueprints from disk. Missing file is silently ignored. const QString bpPath = QCoreApplication::applicationDirPath() + "/ship_layouts.toml"; QFile bpFile(bpPath); if (bpFile.open(QIODevice::ReadOnly | QIODevice::Text)) { try { m_layoutBlueprints = ShipLayoutBlueprintSerializer::deserialize( bpFile.readAll().toStdString()); } catch (const std::exception& e) { QMessageBox::critical(this, tr("Load Error"), tr("Failed to load ship_layouts.toml:\n%1").arg(e.what())); m_layoutBlueprints.clear(); } } registerForEvents(); } MainWindow::~MainWindow() { unregisterForEvents(); } void MainWindow::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); layoutPanels(); } void MainWindow::closeEvent(QCloseEvent* event) { const QString path = QCoreApplication::applicationDirPath() + "/ship_layouts.toml"; QFile file(path); if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { try { const std::string content = ShipLayoutBlueprintSerializer::serialize(m_layoutBlueprints); file.write(QByteArray::fromStdString(content)); } catch (...) {} } QWidget::closeEvent(event); } void MainWindow::layoutPanels() { const int totalW = width(); const int totalH = height(); const int headerH = m_headerBar->sizeHint().height(); if (headerH <= 0) { return; } const int mainW = totalW * 75 / 100; const int sideW = totalW - mainW; m_headerBar->setGeometry(0, 0, mainW, headerH); m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH); m_sidePanel->setGeometry(mainW, 0, sideW, totalH); m_dimOverlay->setGeometry(0, 0, totalW, totalH); } void MainWindow::handleEvent(std::shared_ptr event) { ModalPauseScope pause(*m_gameWorldView); ModalDimScope dim(*m_dimOverlay); SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this); dialog.exec(); std::shared_ptr command = std::make_shared(); command->choiceIndex = dialog.getChosenIndex(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } void MainWindow::handleEvent(std::shared_ptr /*event*/) { ModalPauseScope pause(*m_gameWorldView); ModalDimScope dim(*m_dimOverlay); QMessageBox box(this); box.setWindowTitle(tr("Paused")); QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole); QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::ResetRole); QPushButton* quitBtn = box.addButton(tr("Quit"), QMessageBox::DestructiveRole); box.setEscapeButton(continueBtn); box.exec(); QAbstractButton* clicked = box.clickedButton(); if (clicked == restartBtn) { std::optional newConfig = reloadConfig(); if (!newConfig.has_value()) { return; } // Restart is a command boundary; the view resets when the drain applies // it (see GameWorldView::onFrame). A fresh random seed starts a new run. // resetForNewGame() sets the speed for the new run, so the pre-restart // speed is deliberately not restored here. pause.release(); std::shared_ptr command = std::make_shared(); command->config = std::make_shared(std::move(*newConfig)); command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } else if (clicked == quitBtn) { pause.release(); close(); } } std::optional MainWindow::reloadConfig() { // Config is reloaded from disk on every restart (REQ-CFG-RELOAD); a malformed // file must not leave the window half-updated, so the visuals are only applied // once both files have parsed. try { GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); m_dimOverlay->setDimColor(m_visuals.overlays.modalDim); return newConfig; } catch (const std::exception& e) { QMessageBox::critical(this, tr("Config Error"), tr("Failed to reload config:\n%1").arg(e.what())); return std::nullopt; } } void MainWindow::openShipLayoutDialog(BuildingId shipyardId, const std::string& schematicId, const ShipLayoutConfig& currentLayout) { ModalPauseScope pause(*m_gameWorldView); std::set unlockedModuleIds; for (const ModuleDef& def : m_sim->getConfig().modules.modules) { if (m_sim->isModuleSchematicUnlocked(def.id)) { unlockedModuleIds.insert(def.id); } } ModalDimScope dim(*m_dimOverlay); ShipLayoutDialog dialog(&m_sim->getConfig(), schematicId, currentLayout, m_layoutBlueprints, std::move(unlockedModuleIds), m_gameWorldView->isDebugDrawEnabled(), this); if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value()) { std::shared_ptr command = std::make_shared(); command->id = shipyardId; command->layout = *dialog.getResult(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } } void MainWindow::handleEvent(std::shared_ptr event) { // A construction site has no Building yet; fall back to its site record so // the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG). const Building* b = m_sim->getBuildings().findBuilding(event->shipyardId); const ConstructionSite* s = b ? nullptr : m_sim->getBuildings().findSite(event->shipyardId); if (!b && !s) { return; } const std::string& schematicId = b ? b->recipeId : s->recipeId; const std::optional& layoutOpt = b ? b->shipLayout : s->shipLayout; ShipLayoutConfig currentLayout; if (layoutOpt.has_value()) { currentLayout = *layoutOpt; } openShipLayoutDialog(event->shipyardId, schematicId, currentLayout); } void MainWindow::handleEvent(std::shared_ptr event) { ModalPauseScope pause(*m_gameWorldView); // A construction site has no Building yet; fall back to its site record so // the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG). const Building* b = m_sim->getBuildings().findBuilding(event->buildingId); const ConstructionSite* s = b ? nullptr : m_sim->getBuildings().findSite(event->buildingId); if (!b && !s) { return; } // Held across both the selection dialog and any auto-opened layout dialog so the // dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM). ModalDimScope dim(*m_dimOverlay); const BuildingType type = b ? b->type : s->type; // Captured as a copy: a queued command may drain during the modal dialog's // event loop and reallocate the building vectors, so b/s must not be // dereferenced after dialog.exec() returns. const std::string oldSchematic = b ? b->recipeId : s->recipeId; const std::vector options = buildRecipeSelectionOptions(type, *m_sim, m_sim->getConfig()); const QString title = (type == BuildingType::Shipyard) ? tr("Select Schematic") : tr("Select Recipe"); bool autoOpenLayout = false; std::string chosenSchematic; RecipeSelectionDialog dialog(options, title, m_itemIcons.get(), this); if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value()) { std::shared_ptr command = std::make_shared(); command->id = event->buildingId; command->recipeId = *dialog.getChosenId(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); // REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the // layout configuration dialog immediately. Only on an actual change. if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic) { autoOpenLayout = true; chosenSchematic = *dialog.getChosenId(); } } // The SetRecipeCommand above is queued (drains on a later frame) and clears // the shipyard's layout, so open the dialog with the chosen schematic and an // empty layout rather than reading the not-yet-updated building state. Speed // is restored first so the helper snapshots the real speed to restore. pause.restore(); if (autoOpenLayout) { openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{}); } } void MainWindow::handleEvent(std::shared_ptr /*event*/) { const Tick tick = m_sim->getCurrentTick(); const int totalSeconds = static_cast(ticksToSeconds(tick)); const int minutes = totalSeconds / 60; const int seconds = totalSeconds % 60; ModalDimScope dim(*m_dimOverlay); QMessageBox box(this); box.setWindowTitle(tr("Game Over")); box.setText(tr("HQ destroyed!\nSurvival time: %1:%2") .arg(minutes, 2, 10, QChar('0')) .arg(seconds, 2, 10, QChar('0'))); QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); box.addButton(tr("Quit"), QMessageBox::RejectRole); box.exec(); if (box.clickedButton() == restartBtn) { std::optional newConfig = reloadConfig(); if (!newConfig.has_value()) { return; } // Restart is a command boundary; the view resets when the drain applies it. std::shared_ptr command = std::make_shared(); command->config = std::make_shared(std::move(*newConfig)); command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } else { close(); } } void MainWindow::handleEvent(std::shared_ptr /*event*/) { const Tick tick = m_sim->getCurrentTick(); const int totalSeconds = static_cast(ticksToSeconds(tick)); const int minutes = totalSeconds / 60; const int seconds = totalSeconds % 60; ModalDimScope dim(*m_dimOverlay); QMessageBox box(this); box.setWindowTitle(tr("Won!")); box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2") .arg(minutes, 2, 10, QChar('0')) .arg(seconds, 2, 10, QChar('0'))); QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); box.addButton(tr("Quit"), QMessageBox::RejectRole); box.exec(); if (box.clickedButton() == restartBtn) { std::optional newConfig = reloadConfig(); if (!newConfig.has_value()) { return; } // Restart is a command boundary; the view resets when the drain applies it. std::shared_ptr command = std::make_shared(); command->config = std::make_shared(std::move(*newConfig)); command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } else { close(); } }