3 Commits

Author SHA1 Message Date
92ab1dab54 Fix recipe button unclickable on construction site during play
A selected construction site rebuilt the entire panel on every
TickAdvancedEvent (~30x/s at 1x), because refreshSelectionDisplay() called
rebuild() for sites. Each rebuild runs buildSingle() -> hideAllWidgets(),
which hides and re-shows the recipe-select button. Hiding a QPushButton
mid-press clears its pressed state, so a rebuild landing between the user's
mouse press and release cancelled the click. While paused no tick advances,
so no rebuild occurred and the button worked -- matching the report.

Give sites a lightweight per-tick refresh that updates only the progress
label, mirroring refreshBuffers() for live buildings. The site progress
block is extracted from buildSingle() into refreshSiteProgress() (no
duplicated arithmetic). refreshSelectionDisplay() now takes a RefreshReason:
a PeriodicTick updates progress only, while a CommandApplied still rebuilds
so a site's newly chosen recipe/layout is reflected. The site -> completed
building transition remains handled by the existing "(Building) " title
branch.

No UI test added: the test target links only lib (no QtWidgets), per the
simulation/presentation split, so a widget-level test does not fit the
harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-08 20:36:42 +02:00
94b5d941ba Refresh selected-building panel when paused player commands drain
Option A made the per-tick refresh authoritative for the shipyard layout
preview and Configure Layout button, but that path is driven by
TickAdvancedEvent, which only fires while the game is running. Choosing a
schematic while paused therefore still left the widgets hidden until the
game was unpaused or the building re-selected, because the queued
SetRecipeCommand drains on the next frame but no tick advances.

Emit a new PlayerCommandsAppliedEvent from GameWorldView::onFrame once per
frame when queued commands were drained, and have SelectedBuildingPanel
refresh its selection display in response. This is a presentation-only
notification: it is emitted only on the live path (not during replay
playback), touches neither the command queue nor the simulation, and its
only handler never enqueues commands -- so replay recording and
determinism are unaffected.

Factor the former TickAdvancedEvent handler body into
refreshSelectionDisplay() and call it from both handlers to avoid
duplication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-08 20:36:41 +02:00
53af44db04 Fix shipyard layout preview/button not showing until re-selection
Selecting a ship schematic in the shipyard left the layout preview and
Configure Layout button hidden until the building was deselected and
re-selected.

The recipe change is applied via a queued command that only drains on a
later frame, so the immediate rebuild() in onSelectRecipeClicked() still
saw the old (empty) recipe and hid both widgets. The per-tick
refreshBuffers() path then updated the preview's data but never set its
visibility -- that was only ever done in buildSingle() -- so the widgets
stayed hidden until a re-selection re-ran buildSingle().

Extract the shipyard preview/button show-hide-and-populate logic into
updateShipyardLayoutWidgets() and call it from both buildSingle() and
refreshBuffers(), so the per-tick refresh becomes authoritative for
visibility and the widgets appear on the first tick after the command
drains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-08 20:36:41 +02:00
5 changed files with 137 additions and 61 deletions

View File

@@ -31,6 +31,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,13 @@
#pragma once
#include "Event.h"
// Emitted by GameWorldView once per frame after queued player commands have been
// drained and applied to the simulation. It lets presentation widgets refresh
// even while the game is paused (no tick advances, so no TickAdvancedEvent), for
// example so a shipyard's layout preview appears immediately after its schematic
// is chosen. It is a UI notification only and never feeds back into the command
// queue, so it has no effect on replay recording or determinism.
class PlayerCommandsAppliedEvent : public Event
{
};

View File

@@ -62,6 +62,7 @@
#include "ExpansionCostChangedEvent.h" #include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h" #include "SchematicChoicesAvailableEvent.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
namespace namespace
@@ -212,6 +213,7 @@ void GameWorldView::onFrame()
// Drain queued player commands once per frame, before the tick batch. This // Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites // runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md). // immediately, while staying deterministic (see docs/replay_design.md).
const bool commandsApplied = m_commandManager.hasPending();
m_commandManager.drain(); m_commandManager.drain();
// A drained Reset reinitialized the simulation; reset the view to match. // A drained Reset reinitialized the simulation; reset the view to match.
@@ -221,6 +223,17 @@ void GameWorldView::onFrame()
resetForNewGame(); resetForNewGame();
} }
// Notify presentation widgets that queued commands were applied, so a
// paused player still sees the effect (e.g. a shipyard's layout preview
// after picking a schematic) even though no tick advances. UI-only: this
// does not touch the command queue or simulation, so replay recording
// and determinism are unaffected.
if (commandsApplied)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PlayerCommandsAppliedEvent>());
}
const int ticks = m_tickDriver.advance( const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier); static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i) for (int i = 0; i < ticks; ++i)

View File

@@ -33,6 +33,7 @@
#include "ItemType.h" #include "ItemType.h"
#include "LayoutDialogRequestedEvent.h" #include "LayoutDialogRequestedEvent.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h" #include "RecipeSelectionRequestedEvent.h"
#include "Rotation.h" #include "Rotation.h"
@@ -294,38 +295,12 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
} }
m_recipeSelectButton->show(); m_recipeSelectButton->show();
if (type == BuildingType::Shipyard && !recipeId.empty()) updateShipyardLayoutWidgets(type, recipeId, shipLayout);
{
const ShipDef* sDef = findShipDef(recipeId);
if (sDef && !sDef->layout.empty())
{
ShipLayoutConfig layout;
if (shipLayout.has_value())
{
layout = *shipLayout;
}
m_layoutPreview->setShipAndLayout(
sDef->layout, layout, &m_config->modules.modules);
m_layoutPreview->show();
m_configureLayoutBtn->show();
}
else
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
}
}
else
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
}
} }
else else
{ {
m_recipeSelectButton->hide(); m_recipeSelectButton->hide();
m_layoutPreview->hide(); updateShipyardLayoutWidgets(type, recipeId, shipLayout);
m_configureLayoutBtn->hide();
} }
// Belt "Clear" removes items from a live belt tile; a construction site has // Belt "Clear" removes items from a live belt tile; a construction site has
@@ -363,32 +338,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
if (m_singleIsSite) if (m_singleIsSite)
{ {
QString progress; refreshSiteProgress(s);
if (s->completesAt == 0)
{
progress = tr("Queued");
}
else
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : m_config->buildings.buildings)
{
if (d.type == s->type) { def = &d; break; }
}
if (def && def->constructionTimeSeconds > 0)
{
const Tick duration = secondsToTicks(def->constructionTimeSeconds);
const Tick elapsed = m_sim->currentTick() - (s->completesAt - duration);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration);
progress = tr("%1% complete").arg(pct);
}
else
{
progress = tr("Building...");
}
}
m_buffersLabel->setText(progress);
} }
else else
{ {
@@ -396,6 +346,36 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
} }
} }
void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
{
QString progress;
if (s->completesAt == 0)
{
progress = tr("Queued");
}
else
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : m_config->buildings.buildings)
{
if (d.type == s->type) { def = &d; break; }
}
if (def && def->constructionTimeSeconds > 0)
{
const Tick duration = secondsToTicks(def->constructionTimeSeconds);
const Tick elapsed = m_sim->currentTick() - (s->completesAt - duration);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration);
progress = tr("%1% complete").arg(pct);
}
else
{
progress = tr("Building...");
}
}
m_buffersLabel->setText(progress);
}
void SelectedBuildingPanel::refreshBuffers(const Building* b) void SelectedBuildingPanel::refreshBuffers(const Building* b)
{ {
const RecipeDef* recipe = findRecipe(b); const RecipeDef* recipe = findRecipe(b);
@@ -530,15 +510,38 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
m_buffersLabel->setText(bufText); m_buffersLabel->setText(bufText);
if (b->type == BuildingType::Shipyard && shipDef && !shipDef->layout.empty()) // The recipe/schematic is applied via a queued command that only drains on a
// later frame, so the per-tick refresh must own the shipyard preview and the
// Configure Layout button's visibility; otherwise they stay hidden until the
// building is re-selected (which re-runs buildSingle).
updateShipyardLayoutWidgets(b->type, b->recipeId, b->shipLayout);
}
void SelectedBuildingPanel::updateShipyardLayoutWidgets(
BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout)
{
const ShipDef* shipDef = (type == BuildingType::Shipyard)
? findShipDef(recipeId)
: nullptr;
if (shipDef && !shipDef->layout.empty())
{ {
ShipLayoutConfig layout; ShipLayoutConfig layout;
if (b->shipLayout.has_value()) if (shipLayout.has_value())
{ {
layout = *b->shipLayout; layout = *shipLayout;
} }
m_layoutPreview->setShipAndLayout( m_layoutPreview->setShipAndLayout(
shipDef->layout, layout, &m_config->modules.modules); shipDef->layout, layout, &m_config->modules.modules);
m_layoutPreview->show();
m_configureLayoutBtn->show();
}
else
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
} }
} }
@@ -563,6 +566,21 @@ const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshSelectionDisplay(RefreshReason::PeriodicTick);
}
void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands (e.g. choosing a shipyard schematic) are applied by a
// queued drain, not synchronously. When the game is paused no tick advances,
// so TickAdvancedEvent never fires; refresh here too, otherwise the panel
// would not reflect the change until the next tick or a re-selection.
refreshSelectionDisplay(RefreshReason::CommandApplied);
}
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{ {
if (m_selectedEntity.has_value()) if (m_selectedEntity.has_value())
{ {
@@ -587,7 +605,18 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent>
const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId); const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId);
if (s) if (s)
{ {
rebuild(); // A periodic tick only advances construction progress, so update just the
// progress label. Rebuilding every tick would hide/re-show all widgets and
// cancel any in-progress click on the recipe button. An applied command
// may have changed the site's recipe/layout, so rebuild in that case.
if (reason == RefreshReason::CommandApplied)
{
rebuild();
}
else
{
refreshSiteProgress(s);
}
return; return;
} }
buildEmpty(); buildEmpty();
@@ -647,9 +676,11 @@ void SelectedBuildingPanel::onSelectRecipeClicked()
return; return;
} }
// The emit is synchronous: MainWindow pauses the game, runs the modal // The emit is synchronous: MainWindow pauses the game, runs the modal
// selection dialog, applies the chosen recipe/schematic, and restores the // selection dialog, and restores the speed before this returns. The chosen
// speed before this returns. rebuild() then refreshes the button caption, // recipe/schematic is only *enqueued* as a command, though, and drains on a
// tooltip, preview, and buffers for the new selection. // later frame -- so this rebuild() still sees the old recipe. The per-tick
// refreshBuffers() path picks up the new schematic (and shows the layout
// preview + Configure Layout button) once the command has been applied.
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId)); std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId));
rebuild(); rebuild();

View File

@@ -16,6 +16,7 @@
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "SelectionChangedEvent.h" #include "SelectionChangedEvent.h"
#include "ShipLayout.h" #include "ShipLayout.h"
@@ -33,6 +34,7 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectedEvent, EntitySelectedEvent,
SelectionChangedEvent, SelectionChangedEvent,
DebugDrawToggledEvent> DebugDrawToggledEvent>
@@ -46,6 +48,7 @@ public:
private: private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override; void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
@@ -56,7 +59,18 @@ private slots:
void onSplitterFilterChanged(); void onSplitterFilterChanged();
private: private:
// Why the selection display is being refreshed. A periodic tick only needs a
// lightweight content update (e.g. a construction site's progress label),
// whereas an applied player command may have changed the configuration and
// needs a full structural rebuild.
enum class RefreshReason
{
PeriodicTick,
CommandApplied
};
void onSelectionChanged(const std::vector<BuildingId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
void refreshSelectionDisplay(RefreshReason reason);
void rebuild(); void rebuild();
void hideAllWidgets(); void hideAllWidgets();
void clearContent(); void clearContent();
@@ -64,6 +78,10 @@ private:
void buildSingle(BuildingId id); void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids); void buildMulti(const std::vector<BuildingId>& ids);
void refreshBuffers(const Building* b); void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info); void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info);
const RecipeDef* findRecipe(const Building* b) const; const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const; const ShipDef* findShipDef(const std::string& id) const;