Implement REQ-UI-MODAL-DIM: dim game behind modals
Add a window-wide semi-transparent black scrim drawn over the entire game window (header bar, game world view, side panel column) while any auto-pausing modal or end-game screen is open, behind that modal. - New ModalDimOverlay widget: a full-window, mouse-transparent child of MainWindow that paints the dim color. Visibility is reference-counted (pushModal/popModal) so nested or back-to-back modals show a single dim, not stacked layers; a ModalDimScope RAII guard drives it. - Wire a guard around every modal opened from MainWindow: schematic choice, escape menu, layout dialog, recipe/schematic selection (spanning the auto-opened layout dialog), game-over, and win. The nested Create Blueprint dialog is covered by the layout-dialog dim. - Config-driven color via visuals.toml [overlays].modal_dim, following the existing OverlayVisuals/parseColor pattern; refreshed on the Restart config-reload paths (REQ-CFG-RELOAD). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<const BuildingBlocksChangedEvent> event)
|
||||
@@ -156,6 +161,7 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
||||
const double prevSpeed = m_gameWorldView->gameSpeed();
|
||||
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<const EscapeMenuRequestedEvent> /*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<const EscapeMenuRequestedEvent> /*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_ptr<const RecipeSelectionRequestedEvent
|
||||
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
|
||||
@@ -348,6 +361,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*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<const GameOverEvent> /*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<const WinEvent> /*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<const WinEvent> /*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)
|
||||
|
||||
@@ -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<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
||||
|
||||
46
src/ui/ModalDimOverlay.cpp
Normal file
46
src/ui/ModalDimOverlay.cpp
Normal file
@@ -0,0 +1,46 @@
|
||||
#include "ModalDimOverlay.h"
|
||||
|
||||
#include <QPainter>
|
||||
|
||||
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);
|
||||
}
|
||||
59
src/ui/ModalDimOverlay.h
Normal file
59
src/ui/ModalDimOverlay.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QWidget>
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -50,6 +50,7 @@ struct OverlayVisuals
|
||||
QColor selectedOutline;
|
||||
QColor copyConfig;
|
||||
QColor lockedAsteroid;
|
||||
QColor modalDim;
|
||||
};
|
||||
|
||||
struct ToastVisuals
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user