From 4b149d97a78cc61c4c4f08dff6fb3595f41cbc1e Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Mon, 13 Jul 2026 22:11:21 +0200 Subject: [PATCH] dim the game behind dialogs and escape menu --- bin/app/data/config/visuals.toml | 1 + docs/requirements.md | 1 + src/ui/CMakeLists.txt | 2 ++ src/ui/MainWindow.cpp | 17 +++++++++ src/ui/MainWindow.h | 2 ++ src/ui/ModalDimOverlay.cpp | 46 +++++++++++++++++++++++++ src/ui/ModalDimOverlay.h | 59 ++++++++++++++++++++++++++++++++ src/ui/VisualsConfig.h | 1 + src/ui/VisualsLoader.cpp | 1 + 9 files changed, 130 insertions(+) create mode 100644 src/ui/ModalDimOverlay.cpp create mode 100644 src/ui/ModalDimOverlay.h diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index 45380b6..487fb74 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -350,6 +350,7 @@ tile_highlight = "#ffffff22" # tile under cursor selected_outline = "#ffff00" # outline drawn around currently-selected building(s) copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK) locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion) +modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM) # ----------------------------------------------------------------------------- # Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST) diff --git a/docs/requirements.md b/docs/requirements.md index 9d0bdf5..d24c1de 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -411,6 +411,7 @@ The screen is divided into two columns: a main column (75% width) containing the - REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: Blocks`, where `` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion. - REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width). - REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom). +- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation. ### Game World diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index d04e11c..33a09c8 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -3,6 +3,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h @@ -21,6 +22,7 @@ SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 4f8b448..009a164 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -74,6 +74,10 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, "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) { @@ -144,6 +148,7 @@ void MainWindow::layoutPanels() 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) @@ -156,6 +161,7 @@ void MainWindow::handleEvent(std::shared_ptrgameSpeed(); m_gameWorldView->setGameSpeed(0.0); + ModalDimScope dim(*m_dimOverlay); SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this); dialog.exec(); @@ -174,6 +180,7 @@ void MainWindow::handleEvent(std::shared_ptr /*e const double prevSpeed = m_gameWorldView->gameSpeed(); m_gameWorldView->setGameSpeed(0.0); + ModalDimScope dim(*m_dimOverlay); QMessageBox box(this); box.setWindowTitle(tr("Paused")); QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole); @@ -192,6 +199,7 @@ void MainWindow::handleEvent(std::shared_ptr /*e ConfigLoader::loadFromDirectory(m_configDir)); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); + m_dimOverlay->setDimColor(m_visuals.overlays.modalDim); } catch (const std::exception& e) { @@ -236,6 +244,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId, } } + ModalDimScope dim(*m_dimOverlay); ShipLayoutDialog dialog(&m_sim->config(), schematicId, currentLayout, m_layoutBlueprints, std::move(unlockedModuleIds), @@ -297,6 +306,10 @@ void MainWindow::handleEvent(std::shared_ptrtype : 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 @@ -348,6 +361,7 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) 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") @@ -366,6 +380,7 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) ConfigLoader::loadFromDirectory(m_configDir)); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); + m_dimOverlay->setDimColor(m_visuals.overlays.modalDim); } catch (const std::exception& e) { @@ -393,6 +408,7 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) 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") @@ -409,6 +425,7 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) 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); m_sim->reset(std::move(newConfig)); } catch (const std::exception& e) diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 4719192..dfa5632 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -12,6 +12,7 @@ #include "EventHandler.h" #include "GameOverEvent.h" #include "LayoutDialogRequestedEvent.h" +#include "ModalDimOverlay.h" #include "WinEvent.h" #include "RecipeSelectionRequestedEvent.h" #include "SchematicChoicesAvailableEvent.h" @@ -76,6 +77,7 @@ private: BuildButtonGrid* m_buildButtonGrid; BlueprintPanel* m_blueprintPanel; QWidget* m_sidePanel; + ModalDimOverlay* m_dimOverlay = nullptr; std::vector m_layoutBlueprints; std::shared_ptr m_replay; // non-null => view-only playback diff --git a/src/ui/ModalDimOverlay.cpp b/src/ui/ModalDimOverlay.cpp new file mode 100644 index 0000000..31c2c2c --- /dev/null +++ b/src/ui/ModalDimOverlay.cpp @@ -0,0 +1,46 @@ +#include "ModalDimOverlay.h" + +#include + +ModalDimOverlay::ModalDimOverlay(const QColor& dimColor, QWidget* parent) + : QWidget(parent) + , m_dimColor(dimColor) +{ + setAttribute(Qt::WA_TransparentForMouseEvents, true); + hide(); +} + +void ModalDimOverlay::pushModal() +{ + if (m_modalDepth++ == 0) + { + raise(); + show(); + // Force an immediate synchronous paint so the scrim is visible before the + // caller enters a blocking dialog exec() (no undimmed frame flashes through). + repaint(); + } +} + +void ModalDimOverlay::popModal() +{ + if (m_modalDepth > 0 && --m_modalDepth == 0) + { + hide(); + } +} + +void ModalDimOverlay::setDimColor(const QColor& dimColor) +{ + m_dimColor = dimColor; + if (isVisible()) + { + update(); + } +} + +void ModalDimOverlay::paintEvent(QPaintEvent* /*event*/) +{ + QPainter painter(this); + painter.fillRect(rect(), m_dimColor); +} diff --git a/src/ui/ModalDimOverlay.h b/src/ui/ModalDimOverlay.h new file mode 100644 index 0000000..591af52 --- /dev/null +++ b/src/ui/ModalDimOverlay.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +class QPaintEvent; + +// A window-wide, semi-transparent scrim drawn over the entire game window while a +// modal dialog or menu is open, behind that modal (REQ-UI-MODAL-DIM). It is a child +// of the main window covering its full rect and is transparent to mouse events, so it +// only dims the game presentation and never intercepts input. +// +// Visibility is reference-counted via pushModal()/popModal() so that a single dim is +// shown across nested or back-to-back modals (e.g. the recipe selection dialog that +// immediately opens the layout dialog) rather than flickering or stacking overlays. +class ModalDimOverlay : public QWidget +{ + Q_OBJECT + +public: + ModalDimOverlay(const QColor& dimColor, QWidget* parent); + + // Raise + show on the first active modal; hide when the last one closes. + void pushModal(); + void popModal(); + + // Update the dim color (e.g. after a config reload on Restart, REQ-CFG-RELOAD). + void setDimColor(const QColor& dimColor); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + QColor m_dimColor; + int m_modalDepth = 0; +}; + +// RAII guard: shows the dim overlay for the duration of a scope (typically around a +// blocking dialog exec()) and hides it (via reference count) on scope exit. +class ModalDimScope +{ +public: + explicit ModalDimScope(ModalDimOverlay& overlay) + : m_overlay(overlay) + { + m_overlay.pushModal(); + } + + ~ModalDimScope() + { + m_overlay.popModal(); + } + + ModalDimScope(const ModalDimScope&) = delete; + ModalDimScope& operator=(const ModalDimScope&) = delete; + +private: + ModalDimOverlay& m_overlay; +}; diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h index 3c40c7f..8d9a666 100644 --- a/src/ui/VisualsConfig.h +++ b/src/ui/VisualsConfig.h @@ -50,6 +50,7 @@ struct OverlayVisuals QColor selectedOutline; QColor copyConfig; QColor lockedAsteroid; + QColor modalDim; }; struct ToastVisuals diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index 5833419..2d956bd 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -226,6 +226,7 @@ VisualsConfig VisualsLoader::load(const std::string& path) cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline"); cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config"); cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid"); + cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim"); } // Toast