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}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
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 "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"
namespace
@@ -212,6 +213,7 @@ void GameWorldView::onFrame()
// Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md).
const bool commandsApplied = m_commandManager.hasPending();
m_commandManager.drain();
// A drained Reset reinitialized the simulation; reset the view to match.
@@ -221,6 +223,17 @@ void GameWorldView::onFrame()
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(
static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i)

View File

@@ -33,6 +33,7 @@
#include "ItemType.h"
#include "LayoutDialogRequestedEvent.h"
#include "ModulesConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h"
#include "Rotation.h"
@@ -294,38 +295,12 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
}
m_recipeSelectButton->show();
if (type == BuildingType::Shipyard && !recipeId.empty())
{
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();
}
updateShipyardLayoutWidgets(type, recipeId, shipLayout);
}
else
{
m_recipeSelectButton->hide();
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
updateShipyardLayoutWidgets(type, recipeId, shipLayout);
}
// Belt "Clear" removes items from a live belt tile; a construction site has
@@ -362,6 +337,16 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
}
if (m_singleIsSite)
{
refreshSiteProgress(s);
}
else
{
refreshBuffers(b);
}
}
void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
{
QString progress;
if (s->completesAt == 0)
@@ -390,11 +375,6 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
}
m_buffersLabel->setText(progress);
}
else
{
refreshBuffers(b);
}
}
void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
@@ -530,15 +510,38 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
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;
if (b->shipLayout.has_value())
if (shipLayout.has_value())
{
layout = *b->shipLayout;
layout = *shipLayout;
}
m_layoutPreview->setShipAndLayout(
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*/)
{
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())
{
@@ -586,8 +604,19 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent>
}
const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId);
if (s)
{
// 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;
}
buildEmpty();
@@ -647,9 +676,11 @@ void SelectedBuildingPanel::onSelectRecipeClicked()
return;
}
// The emit is synchronous: MainWindow pauses the game, runs the modal
// selection dialog, applies the chosen recipe/schematic, and restores the
// speed before this returns. rebuild() then refreshes the button caption,
// tooltip, preview, and buffers for the new selection.
// selection dialog, and restores the speed before this returns. The chosen
// recipe/schematic is only *enqueued* as a command, though, and drains on a
// 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(
std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId));
rebuild();

View File

@@ -16,6 +16,7 @@
#include "EntitySelectedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipesConfig.h"
#include "SelectionChangedEvent.h"
#include "ShipLayout.h"
@@ -33,6 +34,7 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectedEvent,
SelectionChangedEvent,
DebugDrawToggledEvent>
@@ -46,6 +48,7 @@ public:
private:
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 SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
@@ -56,7 +59,18 @@ private slots:
void onSplitterFilterChanged();
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 refreshSelectionDisplay(RefreshReason reason);
void rebuild();
void hideAllWidgets();
void clearContent();
@@ -64,6 +78,10 @@ private:
void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids);
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);
const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const;