#include "MainWindow.h" #include "FactoryQueries.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include "BlueprintLibrary.h" #include "BlueprintSelectionDialog.h" #include "BuildButtonBar.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 "SelectionPanel.h" #include "ControlsPanel.h" #include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutDialog.h" #include "BuildingIconCache.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 and building icons live alongside the config (siblings of the config dir), // read from disk at runtime the same way visuals.toml is (REQ-UI-ITEM-ICON, // REQ-UI-BUILD-ICON). const QString configDirPath = QString::fromStdString(m_configDir); m_itemIcons = std::make_unique( QDir::cleanPath(configDirPath + "/../icons/items")); m_buildingIcons = std::make_unique( QDir::cleanPath(configDirPath + "/../icons/buildings")); 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); // Floats over the game world at its bottom center, sized to its buttons // (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so // building it after the world view puts it above the world and its vignettes, // and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM). // Its geometry comes from layoutPanels(). m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), m_buildingIcons.get(), m_itemIcons.get(), this); // The blueprints have no widget of their own: they are saved with Ctrl+C and picked // from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window // because only it can pause the game and raise the dim overlay. Built after the // world view because loading blueprints.toml may put a message box on screen. m_blueprintLibrary = std::make_unique(sim, &sim->getConfig(), this); // Two facts about blueprints decide what the world view offers the player // (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed // over here rather than passed to the constructor. m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get()); // Floats over the game world at its right edge rather than occupying a column of // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like // the build button bar it is a sibling of the world view built after it, which is // what puts it above the world and its vignettes and below the dim overlay. It // brings its own chrome; its geometry comes from layoutPanels(). m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals, m_itemIcons.get(), m_buildingIcons.get(), this); // Floats at the world view's opposite edge from the selection panel and reads the // world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built // after the view for the same stacking reason as the panels above it. m_controlsPanel = new ControlsPanel(m_gameWorldView, this); // 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; } // Header bar and game world view span the full window width; the two floating // widgets below are the only things over the world (REQ-UI-HEADER, // REQ-UI-WORLD-SIZE). const QRect worldRect(0, headerH, totalW, totalH - headerH); m_headerBar->setGeometry(0, 0, totalW, headerH); m_gameWorldView->setGeometry(worldRect); // Sizes itself to its buttons and centers along the bottom of the world view // (REQ-UI-BUILD-BAR). m_buildButtonBar->anchorTo(worldRect); // The panel confines itself to what the bar leaves free, so the bar never has to // move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR). m_selectionPanel->anchorTo( worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx())); // Same band, opposite edge: left and bottom-aligned, so it clears the bar's strip // too and never meets the selection panel (REQ-UI-CONTROLS-PANEL). m_controlsPanel->anchorTo( worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx())); 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 = findBuilding(m_sim->getFactoryState(), event->shipyardId); const ConstructionSite* s = b ? nullptr : findSite(m_sim->getFactoryState(), 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 = findBuilding(m_sim->getFactoryState(), event->buildingId); const ConstructionSite* s = b ? nullptr : findSite(m_sim->getFactoryState(), 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*/) { // Ctrl+C has effect only when something player-placeable is selected; otherwise no // dialog opens at all (REQ-UI-BLUEPRINT-CREATE). if (!m_blueprintLibrary->getCanCaptureSelection()) { return; } // Both scopes are held across the naming dialog and the selection dialog it hands // over to, so the dim never blinks off and the simulation is not resumed in between // (REQ-UI-MODAL-DIM). ModalPauseScope pause(*m_gameWorldView); ModalDimScope dim(*m_dimOverlay); bool ok = false; const QString name = QInputDialog::getText( this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, QString(), &ok); // Cancel, Escape, or an empty name: no blueprint, and no selection dialog. if (!ok || name.trimmed().isEmpty()) { return; } m_blueprintLibrary->saveSelectionAs(name.trimmed()); showBlueprintSelectionDialog(); } void MainWindow::handleEvent(std::shared_ptr /*event*/) { ModalPauseScope pause(*m_gameWorldView); ModalDimScope dim(*m_dimOverlay); showBlueprintSelectionDialog(); } void MainWindow::showBlueprintSelectionDialog() { BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this); if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value()) { // Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD // describes: clicking a card closes the dialog and enters placement mode. m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex()); } } 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(); } }