Files
dota_factory/src/ui/MainWindow.cpp

576 lines
23 KiB
C++

#include "MainWindow.h"
#include "FactoryQueries.h"
#include <map>
#include <random>
#include <set>
#include <QApplication>
#include <QCloseEvent>
#include <QDialog>
#include <QDir>
#include <QFile>
#include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QResizeEvent>
#include <QVBoxLayout>
#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<ParsedReplay> 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<ItemIconCache>(
QDir::cleanPath(configDirPath + "/../icons/items"), &m_visuals);
m_buildingIcons = std::make_unique<BuildingIconCache>(
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<BlueprintLibrary>(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);
m_dimOverlay->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<QWidget*> floatingWidgets = {
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
const std::vector<FloatingPanel*> floatingPanels = {
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
std::vector<QRect> 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());
}
}
m_layingOut = false;
}
void MainWindow::handleEvent(
std::shared_ptr<const FloatingLayoutInvalidatedEvent> /*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<const SchematicChoicesAvailableEvent> event)
{
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes,
m_itemIcons.get(), m_buildingIcons.get(), this);
dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command =
std::make_shared<ApplySchematicChoiceCommand>();
command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*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<GameConfig> 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<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else if (clicked == quitBtn)
{
pause.release();
close();
}
}
std::optional<GameConfig> 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);
// 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<std::string> 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(),
m_itemIcons.get(), this);
// 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.
placeOnSelectionPanel(dialog);
if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = shipyardId;
command->layout = *dialog.getResult();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
}
void MainWindow::placeOnSelectionPanel(QDialog& dialog) 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 Qt's own centering on
// this window stands.
if (!m_selectionPanel->isVisible()) { return; }
// The dialog has never been shown, so it is still at its default size until its
// layout has run; centering it before that would use the wrong extent.
dialog.adjustSize();
const QSize dialogSize = dialog.size();
// The panel's live geometry, so a panel the player has dragged
// (REQ-UI-SELECTION-PANEL-DRAG) carries the modal with it.
const QRect panelRect(m_selectionPanel->mapToGlobal(QPoint(0, 0)),
m_selectionPanel->size());
const QRect windowRect(mapToGlobal(QPoint(0, 0)), size());
QPoint topLeft(panelRect.center().x() - dialogSize.width() / 2,
panelRect.center().y() - dialogSize.height() / 2);
// Pushed back inside the window, never resized to fit (REQ-UI-PANEL-MODAL). The far
// edge is clamped first and the near edge second, which is what aligns a dialog too
// large for the window with the window's top-left corner rather than pushing it off
// the opposite edge.
topLeft.setX(qMax(windowRect.left(),
qMin(topLeft.x(), windowRect.right() - dialogSize.width() + 1)));
topLeft.setY(qMax(windowRect.top(),
qMin(topLeft.y(), windowRect.bottom() - dialogSize.height() + 1)));
// Positions the dialog's frame, whose size is not known until it is first shown, so
// the result sits low by the title bar height against a true center -- measuring it
// would mean showing the dialog at the wrong place first. The move also marks the
// dialog as positioned, which is what stops QDialog from centering it on this window
// when it is shown.
dialog.move(topLeft);
}
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> 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<ShipLayoutConfig>& 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<const RecipeSelectionRequestedEvent> 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<RecipeSelectionOption> 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(),
m_buildingIcons.get(), this);
placeOnSelectionPanel(dialog);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = event->buildingId;
command->recipeId = *dialog.getChosenId();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(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<const BlueprintSaveRequestedEvent> /*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<const BlueprintSelectionRequestedEvent> /*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<const GameOverEvent> /*event*/)
{
const Tick tick = m_sim->getCurrentTick();
const int totalSeconds = static_cast<int>(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<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value())
{
return;
}
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{
close();
}
}
void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
{
const Tick tick = m_sim->getCurrentTick();
const int totalSeconds = static_cast<int>(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<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value())
{
return;
}
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{
close();
}
}