#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 "ExpandButton.h" #include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutDialog.h" #include "BuildingIconCache.h" #include "ItemIconCache.h" #include "MessageDialog.h" #include "ModalLayer.h" #include "ModalPauseScope.h" #include "NameInputDialog.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_visuals); m_buildingIcons = std::make_unique( QDir::cleanPath(configDirPath + "/../icons/buildings")); m_headerBar = new HeaderBar(getItemTooltipContext(), 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); // Stands in the world rather than on the screen, on the columns the next expansion // unlocks (REQ-UI-EXPAND-BUTTON). A sibling of the world view like the panels, which // is what keeps the builder-mode ghost off the tile beneath it and the click off the // world (REQ-BLD-GHOST): the view's hover follows underMouse(), false while the // cursor rests on a sibling. m_expandButton = new ExpandButton(getItemTooltipContext(), 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, // dims the game behind every modal, and is what those modals are drawn on // (REQ-UI-MODAL-DIM, REQ-UI-MODAL-CHROME). m_modalLayer = new ModalLayer(m_visuals.overlays.modalDim, this); m_gameWorldView->setFocus(); connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) { // A modal holds the focus while it is open, whether it is one of ours on the // layer or a system message box (REQ-UI-MODAL-CHROME). A null widget -- the focus // going nowhere at all -- is caught too: the world view is what answers keys when // no modal is up, so leaving the window with no focus widget would leave the // hotkeys dead (REQ-UI-HOTKEYS). if (newWidget != m_gameWorldView && !QApplication::activeModalWidget() && !m_modalLayer->isActive()) { 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) { // A modal on the layer runs a nested event loop over widgets this window owns, so // the window must outlive it. A modal window used to block the close outright; this // does the same for a modal that is only a widget (REQ-UI-MODAL-CHROME). if (m_modalLayer->isActive()) { event->ignore(); return; } 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); m_modalLayer->setGeometry(0, 0, totalW, totalH); // The floating widgets are placed in one ordered pass, each into the space the // earlier ones have not taken (FloatingPanel.h). The order is the priority the // requirements state: the build button bar takes what it wants and never moves for // anyone (REQ-UI-BUILD-BAR), the controls panel steps around the bar // (REQ-UI-CONTROLS-PANEL), and the selection panel keeps clear of both // (REQ-UI-SELECTION-PANEL). A widget with nothing to show hides itself in placeIn() // and takes no space. // // Re-entry is refused rather than queued: setGeometry() on a widget in the pass can // reach code that asks for another pass, and the one already running is about to // produce the same answer. if (m_layingOut) { return; } m_layingOut = true; const std::vector floatingWidgets = { m_buildButtonBar, m_controlsPanel, m_selectionPanel }; const std::vector floatingPanels = { m_buildButtonBar, m_controlsPanel, m_selectionPanel }; std::vector occupiedRects; for (std::size_t i = 0; i < floatingPanels.size(); ++i) { floatingPanels[i]->placeIn(worldRect, occupiedRects); if (floatingWidgets[i]->isVisible()) { occupiedRects.push_back(floatingWidgets[i]->geometry()); } } // Last, and never added to occupiedRects: it marks a place in the world, so the // panels do not step around it and it does not step around them // (REQ-UI-EXPAND-BUTTON). placeExpandButton(); m_layingOut = false; } void MainWindow::handleEvent( std::shared_ptr /*event*/) { // One of the floating widgets changed size or visibility. What each of them may take // depends on the ones placed before it, so the answer is the whole pass rather than // that one widget re-placing itself. layoutPanels(); } void MainWindow::handleEvent(std::shared_ptr event) { ModalPauseScope pause(*m_gameWorldView); SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, getItemTooltipContext(), m_modalLayer); m_modalLayer->execute(dialog); // The command goes out unconditionally because the dialog cannot be dismissed: it // returns only once an option was clicked, so the index always names that option // (REQ-DEF-SCHEMATIC-DROP). It is also the only thing that resolves the drop -- the // poll that opened this dialog will not open it again while the choices stay pending // (GameWorldView::onFrame) -- so a path that skipped the command would strand it. 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); MessageDialog box(tr("Paused"), QString(), m_modalLayer); const int continueIndex = box.addButton(tr("Continue")); const int restartIndex = box.addButton(tr("Restart")); const int quitIndex = box.addButton(tr("Quit")); // Escape stands for Continue, as it did when this was a system box // (REQ-UI-GAME-MENU). box.setEscapeButtonIndex(continueIndex); m_modalLayer->execute(box); const std::optional clicked = box.getClickedButtonIndex(); if (clicked == restartIndex) { 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 == quitIndex) { 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_modalLayer->setDimColor(m_visuals.overlays.modalDim); // The composed item squares carry the colors they were painted with, so they // are dropped for the new ones to take effect (REQ-UI-ITEM-ICON). m_itemIcons->clearPixmapCache(); 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); } } ShipLayoutDialog dialog(schematicId, currentLayout, m_layoutBlueprints, std::move(unlockedModuleIds), m_gameWorldView->isDebugDrawEnabled(), getItemTooltipContext(), m_modalLayer); // Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after // a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way. if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == 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)); } } QRect MainWindow::getSelectionPanelAnchor() const { // The panel is up whenever one of these modals opens -- they are opened from its own // controls, and it is shown whenever anything is selected (REQ-UI-EMPTY-SELECTION). // Were it not, there would be no rectangle to center on and the layer's own centering // stands (REQ-UI-PANEL-MODAL). if (!m_selectionPanel->isVisible()) { return QRect(); } // The panel's live geometry, so a panel the player has dragged // (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it. Panel and layer are both // children of this window, so the panel's geometry needs no mapping. return m_selectionPanel->geometry(); } 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; // Nothing to configure without a schematic and a grid to place modules on. The // Configure button that publishes this is already disabled then (REQ-MOD-UI-PREVIEW), // so this guards the event rather than the button: the dialog would otherwise open // over a grid of no cells. if (!m_sim->getConfig().ships.findLayoutShipDef(schematicId)) { return; } 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). ModalLayerHold dim(*m_modalLayer); 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, getItemTooltipContext(), m_modalLayer); if (m_modalLayer->execute(dialog, getSelectionPanelAnchor()) == 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, and only // for an actual schematic: "(None)" clears the shipyard and carries the empty // id (REQ-UI-SELECT-OPTIONS), which differs from whatever was set but is not a // schematic to configure. if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic && m_sim->getConfig().ships.findLayoutShipDef(*dialog.getChosenId())) { 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); ModalLayerHold dim(*m_modalLayer); NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"), m_modalLayer); const int result = m_modalLayer->execute(nameDialog); // Cancel, Escape, or an empty name: no blueprint, and no selection dialog. if (result != QDialog::Accepted || nameDialog.getName().isEmpty()) { return; } m_blueprintLibrary->saveSelectionAs(nameDialog.getName()); showBlueprintSelectionDialog(); } void MainWindow::handleEvent(std::shared_ptr /*event*/) { ModalPauseScope pause(*m_gameWorldView); showBlueprintSelectionDialog(); } void MainWindow::placeExpandButton() { // Null while the button is still being built: refreshing its cost asks for a // placement pass, and the first refresh happens inside its own constructor. if (m_expandButton == nullptr) { return; } // The columns the next purchase unlocks: the ones immediately left of the buildable // edge (REQ-GW-ASTEROID-EXPAND, REQ-UI-LOCKED-ASTEROID). Their middle, and the // world's middle vertically, is where the button goes (REQ-UI-EXPAND-BUTTON). const int asteroidWidth_tiles = m_sim->getCurrentAsteroidWidth_tiles(); const float columns_tiles = static_cast( m_sim->getConfig().world.expansion.columnsPerExpansion_tiles); const QVector2D center_tiles( -static_cast(asteroidWidth_tiles) - columns_tiles / 2.0f, static_cast(m_sim->getConfig().world.heightTiles) / 2.0f); // The transform is the view's, so the point it yields is in the view's coordinates; // the button is a sibling of the view, so it is lifted into this window's. const QPointF centerInView_px = m_gameWorldView->getCoordinates().worldToWidget(center_tiles); const QPoint center_px = m_gameWorldView->geometry().topLeft() + QPoint(static_cast(centerInView_px.x()), static_cast(centerInView_px.y())); // Neither clamped nor hidden: the button belongs to that ground and leaves the view // with it, which the window's own edge takes care of (REQ-UI-EXPAND-BUTTON). const QSize buttonSize = m_expandButton->sizeHint(); m_expandButton->setGeometry(QRect( center_px - QPoint(buttonSize.width() / 2, buttonSize.height() / 2), buttonSize)); } void MainWindow::handleEvent(std::shared_ptr /*event*/) { // The ground moved under the button; nothing else about the layout changed, so this // is the one widget to re-place (REQ-UI-EXPAND-BUTTON). placeExpandButton(); } ItemTooltipContext MainWindow::getItemTooltipContext() const { return ItemTooltipContext{ m_sim, &m_sim->getConfig(), m_itemIcons.get(), m_buildingIcons.get() }; } void MainWindow::showBlueprintSelectionDialog() { BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), getItemTooltipContext(), m_modalLayer); if (m_modalLayer->execute(dialog) == 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; MessageDialog box(tr("Game Over"), tr("HQ destroyed!\nSurvival time: %1:%2") .arg(minutes, 2, 10, QChar('0')) .arg(seconds, 2, 10, QChar('0')), m_modalLayer); const int restartIndex = box.addButton(tr("Restart")); const int quitIndex = box.addButton(tr("Quit")); // Escape quits, which is where the system box's reject role sent it. box.setEscapeButtonIndex(quitIndex); m_modalLayer->execute(box); if (box.getClickedButtonIndex() == restartIndex) { 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; MessageDialog box(tr("Won!"), tr("You collected all artifacts!\nSurvival time: %1:%2") .arg(minutes, 2, 10, QChar('0')) .arg(seconds, 2, 10, QChar('0')), m_modalLayer); const int restartIndex = box.addButton(tr("Restart")); const int quitIndex = box.addButton(tr("Quit")); // Escape quits, which is where the system box's reject role sent it. box.setEscapeButtonIndex(quitIndex); m_modalLayer->execute(box); if (box.getClickedButtonIndex() == restartIndex) { 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(); } }