2 Commits

Author SHA1 Message Date
4b149d97a7 dim the game behind dialogs and escape menu 2026-07-13 22:11:21 +02:00
6a8b6acd3b make panning faster 2026-07-13 21:44:12 +02:00
10 changed files with 132 additions and 2 deletions

View File

@@ -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)

View File

@@ -21,8 +21,8 @@ enemy_buffer_width_tiles = 20
[scroll]
# View pan speed (REQ-UI-SCROLL-SPEED): slow near the asteroid, fast across the
# contest zone, with a linear ramp of the given width straddling each boundary.
pan_speed_slow_tiles_per_second = 8.0
pan_speed_fast_tiles_per_second = 24.0
pan_speed_slow_tiles_per_second = 16.0
pan_speed_fast_tiles_per_second = 32.0
pan_ramp_band_width_tiles = 16
[expansion]

View File

@@ -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: <x> Blocks`, where `<x>` 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View 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
View 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;
};

View File

@@ -50,6 +50,7 @@ struct OverlayVisuals
QColor selectedOutline;
QColor copyConfig;
QColor lockedAsteroid;
QColor modalDim;
};
struct ToastVisuals

View File

@@ -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