add ModalPauseScope for the pause-around-modal idiom

Four MainWindow sites hand-rolled snapshot speed / setGameSpeed(0) / modal /
restore + resetFrameTimer, with the restore duplicated on early-return paths.
ModalPauseScope does it via RAII, with restore()/release() for the two sites
that must restore early or not at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
This commit is contained in:
2026-08-02 21:12:53 +02:00
parent 59fde8dbbc
commit 28d0416458
3 changed files with 67 additions and 27 deletions

View File

@@ -4,6 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h

View File

@@ -28,6 +28,7 @@
#include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "ModalPauseScope.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "VisualsLoader.h" #include "VisualsLoader.h"
@@ -167,8 +168,7 @@ void MainWindow::layoutPanels()
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event) void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay); ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this); SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
@@ -179,15 +179,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
command->choiceIndex = dialog.getChosenIndex(); command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/) void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay); ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this); QMessageBox box(this);
@@ -204,12 +200,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
std::optional<GameConfig> newConfig = reloadConfig(); std::optional<GameConfig> newConfig = reloadConfig();
if (!newConfig.has_value()) if (!newConfig.has_value())
{ {
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return; return;
} }
// Restart is a command boundary; the view resets when the drain applies // Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). A fresh random seed starts a new run. // 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>(); std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::make_shared<GameConfig>(std::move(*newConfig)); command->config = std::make_shared<GameConfig>(std::move(*newConfig));
command->seed = std::random_device{}(); command->seed = std::random_device{}();
@@ -218,13 +215,9 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
} }
else if (clicked == quitBtn) else if (clicked == quitBtn)
{ {
pause.release();
close(); close();
} }
else
{
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
} }
std::optional<GameConfig> MainWindow::reloadConfig() std::optional<GameConfig> MainWindow::reloadConfig()
@@ -252,8 +245,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId, const std::string& schematicId,
const ShipLayoutConfig& currentLayout) const ShipLayoutConfig& currentLayout)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
std::set<std::string> unlockedModuleIds; std::set<std::string> unlockedModuleIds;
for (const ModuleDef& def : m_sim->getConfig().modules.modules) for (const ModuleDef& def : m_sim->getConfig().modules.modules)
@@ -279,9 +271,6 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
} }
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
@@ -311,8 +300,7 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
{ {
const double prevSpeed = m_gameWorldView->getGameSpeed(); ModalPauseScope pause(*m_gameWorldView);
m_gameWorldView->setGameSpeed(0.0);
// A construction site has no Building yet; fall back to its site record so // 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). // the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
@@ -321,8 +309,6 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
b ? nullptr : m_sim->getBuildings().findSite(event->buildingId); b ? nullptr : m_sim->getBuildings().findSite(event->buildingId);
if (!b && !s) if (!b && !s)
{ {
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return; return;
} }
@@ -361,13 +347,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
} }
} }
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
// The SetRecipeCommand above is queued (drains on a later frame) and clears // 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 // 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 // empty layout rather than reading the not-yet-updated building state. Speed
// is already restored so the helper snapshots the real speed to restore. // is restored first so the helper snapshots the real speed to restore.
pause.restore();
if (autoOpenLayout) if (autoOpenLayout)
{ {
openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{}); openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{});

55
src/ui/ModalPauseScope.h Normal file
View File

@@ -0,0 +1,55 @@
#pragma once
#include "GameWorldView.h"
// RAII guard for the "pause the game while a modal is open" idiom (REQ-UI-SPEED).
// Constructing it snapshots the current game speed and pauses the game; on scope
// exit it restores the snapshotted speed and rebases the render frame timer, so the
// wall time the player spent in the dialog is not converted into simulation ticks.
//
// Pairs with ModalDimScope, which the same call sites use for the dim overlay.
//
// Two escape hatches for the paths that must not simply restore at scope exit:
// restore() — restore now instead of at scope exit, for when more work has to run
// at the player's real speed before the scope ends (e.g. a follow-up
// dialog that snapshots the speed itself).
// release() — abandon the restore entirely, for when the game is about to be
// reset or the window closed and the old speed is meaningless.
// Both are idempotent and the destructor does nothing once either has run.
class ModalPauseScope
{
public:
explicit ModalPauseScope(GameWorldView& view)
: m_view(view)
, m_previousGameSpeed(view.getGameSpeed())
, m_restorePending(true)
{
m_view.setGameSpeed(0.0);
}
~ModalPauseScope()
{
restore();
}
void restore()
{
if (!m_restorePending) { return; }
m_restorePending = false;
m_view.setGameSpeed(m_previousGameSpeed);
m_view.resetFrameTimer();
}
void release()
{
m_restorePending = false;
}
ModalPauseScope(const ModalPauseScope&) = delete;
ModalPauseScope& operator=(const ModalPauseScope&) = delete;
private:
GameWorldView& m_view;
double m_previousGameSpeed;
bool m_restorePending;
};