The event also fires on deselection and now carries a set of entities rather than a single one, so the "SelectionChanged" name (matching SelectionChangedEvent and ScrapSelectionChangedEvent) is more accurate. Pure rename: header + include guard, CMake entry, and all usages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
1196 lines
37 KiB
C++
1196 lines
37 KiB
C++
#include "SelectedBuildingPanel.h"
|
||
|
||
#include <algorithm>
|
||
#include <cctype>
|
||
#include <map>
|
||
#include <set>
|
||
#include <string>
|
||
|
||
#include <QLabel>
|
||
#include <QListWidget>
|
||
#include <QPushButton>
|
||
#include <QVBoxLayout>
|
||
|
||
#include "BeltSystem.h"
|
||
#include "Command.h"
|
||
#include "CommandRequestedEvent.h"
|
||
#include "DisplayName.h"
|
||
#include "DynamicBodyComponent.h"
|
||
#include "EntityAdmin.h"
|
||
#include "EntitySelectionChangedEvent.h"
|
||
#include "EventManager.h"
|
||
#include "FactionComponent.h"
|
||
#include "HealthComponent.h"
|
||
#include "ModuleOwnerComponent.h"
|
||
#include "SelectedBehaviorComponent.h"
|
||
#include "ShipIdentityComponent.h"
|
||
#include "ShipStatsCalculator.h"
|
||
#include "ShipStatsPanel.h"
|
||
#include "ThreatCostCalculator.h"
|
||
#include "StationBodyComponent.h"
|
||
#include "TickAdvancedEvent.h"
|
||
#include "Building.h"
|
||
#include "BuildingSystem.h"
|
||
#include "BuildingType.h"
|
||
#include "ItemType.h"
|
||
#include "LayoutDialogRequestedEvent.h"
|
||
#include "ModulesConfig.h"
|
||
#include "PlayerCommandsAppliedEvent.h"
|
||
#include "RecipeSelectionDialog.h"
|
||
#include "RecipeSelectionRequestedEvent.h"
|
||
#include "Rotation.h"
|
||
#include "ScrapSystem.h"
|
||
#include "ShipLayoutPreview.h"
|
||
#include "Simulation.h"
|
||
#include "WeaponComponent.h"
|
||
|
||
namespace
|
||
{
|
||
|
||
QString buildingTypeName(BuildingType type)
|
||
{
|
||
if (type == BuildingType::Hq)
|
||
{
|
||
return QObject::tr("Player HQ");
|
||
}
|
||
|
||
const std::string id = buildingTypeId(type);
|
||
QString result;
|
||
bool nextUpper = true;
|
||
for (char c : id)
|
||
{
|
||
if (c == '_')
|
||
{
|
||
result += ' ';
|
||
nextUpper = true;
|
||
}
|
||
else if (nextUpper)
|
||
{
|
||
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||
nextUpper = false;
|
||
}
|
||
else
|
||
{
|
||
result += c;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
bool isProductionBuilding(BuildingType type)
|
||
{
|
||
return type == BuildingType::Miner
|
||
|| type == BuildingType::Smelter
|
||
|| type == BuildingType::Assembler
|
||
|| type == BuildingType::ReprocessingPlant
|
||
|| type == BuildingType::Shipyard;
|
||
}
|
||
|
||
// Buildings that expose a player recipe/schematic selection control
|
||
// (REQ-UI-SELECT-BUTTON): Miner ore type, Assembler recipe, Shipyard schematic.
|
||
// The Smelter and Reprocessing Plant auto-process and offer no selection
|
||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
||
bool hasRecipeSelection(BuildingType type)
|
||
{
|
||
return type == BuildingType::Miner
|
||
|| type == BuildingType::Assembler
|
||
|| type == BuildingType::Shipyard;
|
||
}
|
||
|
||
// Auto-recipe buildings have no selected recipe; their production is driven by
|
||
// whatever inputs they receive.
|
||
bool isAutoRecipeBuilding(BuildingType type)
|
||
{
|
||
return type == BuildingType::Smelter
|
||
|| type == BuildingType::ReprocessingPlant;
|
||
}
|
||
|
||
bool isBeltLike(BuildingType type)
|
||
{
|
||
return type == BuildingType::Belt || type == BuildingType::Splitter
|
||
|| type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit;
|
||
}
|
||
|
||
QString rotationLabel(Rotation r)
|
||
{
|
||
switch (r)
|
||
{
|
||
case Rotation::North: return QObject::tr("North (↑)");
|
||
case Rotation::East: return QObject::tr("East (→)");
|
||
case Rotation::South: return QObject::tr("South (↓)");
|
||
case Rotation::West: return QObject::tr("West (←)");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
} // namespace
|
||
|
||
|
||
SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||
const GameConfig* config,
|
||
QWidget* parent)
|
||
: QWidget(parent)
|
||
, m_sim(sim)
|
||
, m_config(config)
|
||
, m_splitterTile(0, 0)
|
||
{
|
||
m_layout = new QVBoxLayout(this);
|
||
m_layout->setContentsMargins(8, 8, 8, 8);
|
||
m_layout->setSpacing(4);
|
||
m_layout->setAlignment(Qt::AlignTop);
|
||
|
||
m_titleLabel = new QLabel(this);
|
||
m_recipeSelectButton = new QPushButton(this);
|
||
m_clearBeltBtn = new QPushButton(tr("Clear Items"), this);
|
||
m_filterALabel = new QLabel(this);
|
||
m_filterAList = new QListWidget(this);
|
||
m_filterBLabel = new QLabel(this);
|
||
m_filterBList = new QListWidget(this);
|
||
m_layoutPreview = new ShipLayoutPreview(this);
|
||
m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), this);
|
||
m_buffersLabel = new QLabel(this);
|
||
m_buffersLabel->setWordWrap(true);
|
||
|
||
m_filterAList->setMaximumHeight(100);
|
||
m_filterBList->setMaximumHeight(100);
|
||
|
||
m_layout->addWidget(m_titleLabel);
|
||
m_layout->addWidget(m_recipeSelectButton);
|
||
m_layout->addWidget(m_layoutPreview);
|
||
m_layout->addWidget(m_configureLayoutBtn);
|
||
m_layout->addWidget(m_clearBeltBtn);
|
||
m_layout->addWidget(m_filterALabel);
|
||
m_layout->addWidget(m_filterAList);
|
||
m_layout->addWidget(m_filterBLabel);
|
||
m_layout->addWidget(m_filterBList);
|
||
m_layout->addWidget(m_buffersLabel);
|
||
|
||
connect(m_recipeSelectButton, &QPushButton::clicked,
|
||
this, &SelectedBuildingPanel::onSelectRecipeClicked);
|
||
connect(m_clearBeltBtn, &QPushButton::clicked,
|
||
this, &SelectedBuildingPanel::onClearBelt);
|
||
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
|
||
if (m_singleBuildingId.has_value())
|
||
{
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<LayoutDialogRequestedEvent>(*m_singleBuildingId));
|
||
}
|
||
});
|
||
connect(m_filterAList, &QListWidget::itemChanged,
|
||
this, &SelectedBuildingPanel::onSplitterFilterChanged);
|
||
connect(m_filterBList, &QListWidget::itemChanged,
|
||
this, &SelectedBuildingPanel::onSplitterFilterChanged);
|
||
|
||
m_entityTitleLabel = new QLabel(this);
|
||
QFont titleFont = m_entityTitleLabel->font();
|
||
titleFont.setBold(true);
|
||
m_entityTitleLabel->setFont(titleFont);
|
||
m_layout->addWidget(m_entityTitleLabel);
|
||
m_entityTitleLabel->hide();
|
||
|
||
m_entityStatsPanel = new ShipStatsPanel(config, this);
|
||
m_layout->addWidget(m_entityStatsPanel);
|
||
m_entityStatsPanel->hide();
|
||
|
||
m_stationStatsLabel = new QLabel(this);
|
||
m_stationStatsLabel->setWordWrap(true);
|
||
m_layout->addWidget(m_stationStatsLabel);
|
||
m_stationStatsLabel->hide();
|
||
|
||
m_entitySummaryLabel = new QLabel(this);
|
||
m_entitySummaryLabel->setWordWrap(true);
|
||
m_layout->addWidget(m_entitySummaryLabel);
|
||
m_entitySummaryLabel->hide();
|
||
|
||
m_scrapLabel = new QLabel(this);
|
||
m_layout->addWidget(m_scrapLabel);
|
||
m_scrapLabel->hide();
|
||
|
||
buildEmpty();
|
||
|
||
registerForEvents();
|
||
}
|
||
|
||
SelectedBuildingPanel::~SelectedBuildingPanel()
|
||
{
|
||
unregisterForEvents();
|
||
}
|
||
|
||
void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
|
||
{
|
||
m_selectedBuildingIds = ids;
|
||
if (!ids.empty())
|
||
{
|
||
// A building selection is exclusive: it supersedes any field selection —
|
||
// actors and scrap (REQ-UI-SELECTION-CATEGORIES).
|
||
clearEntityDisplay();
|
||
m_selectedScrap.clear();
|
||
m_scrapLabel->hide();
|
||
}
|
||
rebuild();
|
||
}
|
||
|
||
void SelectedBuildingPanel::rebuild()
|
||
{
|
||
if (m_selectedBuildingIds.empty())
|
||
{
|
||
buildEmpty();
|
||
}
|
||
else if (m_selectedBuildingIds.size() == 1)
|
||
{
|
||
buildSingle(m_selectedBuildingIds[0]);
|
||
}
|
||
else
|
||
{
|
||
buildMulti(m_selectedBuildingIds);
|
||
}
|
||
}
|
||
|
||
void SelectedBuildingPanel::hideAllWidgets()
|
||
{
|
||
m_titleLabel->hide();
|
||
m_recipeSelectButton->hide();
|
||
m_layoutPreview->hide();
|
||
m_configureLayoutBtn->hide();
|
||
m_clearBeltBtn->hide();
|
||
m_filterALabel->hide();
|
||
m_filterAList->hide();
|
||
m_filterBLabel->hide();
|
||
m_filterBList->hide();
|
||
m_buffersLabel->hide();
|
||
m_scrapLabel->hide();
|
||
}
|
||
|
||
void SelectedBuildingPanel::clearContent()
|
||
{
|
||
m_singleBuildingId = std::nullopt;
|
||
hideAllWidgets();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildEmpty()
|
||
{
|
||
clearContent();
|
||
m_entityTitleLabel->hide();
|
||
m_entityStatsPanel->hide();
|
||
m_stationStatsLabel->hide();
|
||
m_entitySummaryLabel->hide();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildSingle(BuildingId id)
|
||
{
|
||
m_singleBuildingId = id;
|
||
hideAllWidgets();
|
||
|
||
const Building* b = m_sim->getBuildings().findBuilding(id);
|
||
const ConstructionSite* s = b ? nullptr : m_sim->getBuildings().findSite(id);
|
||
if (!b && !s)
|
||
{
|
||
buildEmpty();
|
||
return;
|
||
}
|
||
m_singleIsSite = (s != nullptr);
|
||
|
||
// A construction site exposes the same configuration as the operational
|
||
// building it will become (REQ-BLD-SITE-CONFIG). The only difference is
|
||
// that its buffer/production rows are replaced by a construction-progress
|
||
// line, since a site has no buffers and runs no production cycle.
|
||
const BuildingType type = b ? b->type : s->type;
|
||
const std::string& recipeId = b ? b->recipeId : s->recipeId;
|
||
const std::optional<ShipLayoutConfig>& shipLayout =
|
||
b ? b->shipLayout : s->shipLayout;
|
||
const QPoint anchor = b ? b->anchor : s->anchor;
|
||
|
||
m_titleLabel->setText(m_singleIsSite
|
||
? tr("(Building) %1").arg(buildingTypeName(type))
|
||
: buildingTypeName(type));
|
||
m_titleLabel->show();
|
||
m_buffersLabel->show();
|
||
|
||
if (hasRecipeSelection(type))
|
||
{
|
||
const std::vector<RecipeSelectionOption> options =
|
||
buildRecipeSelectionOptions(type, *m_sim, *m_config);
|
||
|
||
const RecipeSelectionOption* current = nullptr;
|
||
for (const RecipeSelectionOption& option : options)
|
||
{
|
||
if (option.id == recipeId)
|
||
{
|
||
current = &option;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (current && !current->id.empty())
|
||
{
|
||
m_recipeSelectButton->setText(current->caption);
|
||
m_recipeSelectButton->setToolTip(current->tooltip);
|
||
}
|
||
else
|
||
{
|
||
const QString placeholder = (type == BuildingType::Shipyard)
|
||
? tr("Select schematic")
|
||
: tr("Select recipe");
|
||
m_recipeSelectButton->setText(placeholder);
|
||
m_recipeSelectButton->setToolTip(QString());
|
||
}
|
||
m_recipeSelectButton->show();
|
||
|
||
updateShipyardLayoutWidgets(type, recipeId, shipLayout);
|
||
}
|
||
else
|
||
{
|
||
m_recipeSelectButton->hide();
|
||
updateShipyardLayoutWidgets(type, recipeId, shipLayout);
|
||
}
|
||
|
||
// Belt "Clear" removes items from a live belt tile; a construction site has
|
||
// none and is not registered with BeltSystem yet, so hide it for sites.
|
||
if (isBeltLike(type) && !m_singleIsSite)
|
||
{
|
||
m_clearBeltBtn->show();
|
||
}
|
||
else
|
||
{
|
||
m_clearBeltBtn->hide();
|
||
}
|
||
|
||
if (type == BuildingType::Splitter)
|
||
{
|
||
std::optional<BeltSystem::SplitterInfo> info;
|
||
if (m_singleIsSite)
|
||
{
|
||
info = m_sim->getBuildings().getSiteSplitterInfo(id);
|
||
}
|
||
else
|
||
{
|
||
m_splitterTile = anchor;
|
||
info = m_sim->getBelts().getSplitterInfo(m_splitterTile);
|
||
}
|
||
buildSplitterFilters(info);
|
||
}
|
||
else
|
||
{
|
||
m_filterALabel->hide();
|
||
m_filterAList->hide();
|
||
m_filterBLabel->hide();
|
||
m_filterBList->hide();
|
||
}
|
||
|
||
if (m_singleIsSite)
|
||
{
|
||
refreshSiteProgress(s);
|
||
}
|
||
else
|
||
{
|
||
refreshBuffers(b);
|
||
}
|
||
}
|
||
|
||
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->getCurrentTick() - (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)
|
||
{
|
||
const RecipeDef* recipe = findRecipe(b);
|
||
const ShipDef* shipDef = (b->type == BuildingType::Shipyard)
|
||
? findShipDef(b->recipeId)
|
||
: nullptr;
|
||
|
||
// Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected
|
||
// recipe; while a cycle runs, resolve the recipe actually in production so
|
||
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
|
||
if (!recipe && isAutoRecipeBuilding(b->type) && b->production.has_value())
|
||
{
|
||
for (const RecipeDef& r : m_config->recipes.recipes)
|
||
{
|
||
if (r.id == b->production->recipeId && r.building == b->type)
|
||
{
|
||
recipe = &r;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
QString bufText;
|
||
|
||
if (!b->inputBuffer.counts.empty())
|
||
{
|
||
bufText += tr("Input: ");
|
||
for (const std::pair<const ItemType, int>& entry : b->inputBuffer.counts)
|
||
{
|
||
int perCycle = 0;
|
||
if (recipe)
|
||
{
|
||
for (const RecipeIngredient& ing : recipe->inputs)
|
||
{
|
||
if (ing.item == entry.first.id) { perCycle = ing.amount; break; }
|
||
}
|
||
}
|
||
else if (shipDef)
|
||
{
|
||
for (const RecipeIngredient& mat : shipDef->schematic.materials)
|
||
{
|
||
if (mat.item == entry.first.id) { perCycle = mat.amount; break; }
|
||
}
|
||
if (b->shipLayout.has_value())
|
||
{
|
||
for (const PlacedModule& pm : b->shipLayout->placedModules)
|
||
{
|
||
for (const ModuleDef& modDef : m_config->modules.modules)
|
||
{
|
||
if (modDef.id == pm.moduleId)
|
||
{
|
||
for (const RecipeIngredient& ing : modDef.materials)
|
||
{
|
||
if (ing.item == entry.first.id)
|
||
{
|
||
perCycle += ing.amount;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
bufText += QString::fromStdString(entry.first.id)
|
||
+ ": " + QString::number(entry.second);
|
||
if (perCycle > 0)
|
||
{
|
||
bufText += "/" + QString::number(perCycle);
|
||
}
|
||
bufText += " ";
|
||
}
|
||
bufText += "\n";
|
||
}
|
||
|
||
// Count output-side items: buffered plus still-emerging on the output belts.
|
||
// An emerging item still belongs to the output buffer (REQ-MAT-OUTPUT-EMERGE),
|
||
// so it must be included here or it would vanish from the panel while animating.
|
||
std::map<std::string, int> outCounts;
|
||
for (const Item& item : b->outputBuffer.items)
|
||
{
|
||
outCounts[item.type.id]++;
|
||
}
|
||
for (const std::vector<BeltItemSlot>& lane : b->emergingItems)
|
||
{
|
||
for (const BeltItemSlot& slot : lane)
|
||
{
|
||
outCounts[slot.item.type.id]++;
|
||
}
|
||
}
|
||
|
||
if (recipe && !recipe->outputs.empty())
|
||
{
|
||
bufText += tr("Output: ");
|
||
for (const RecipeOutput& out : recipe->outputs)
|
||
{
|
||
const std::map<std::string, int>::const_iterator it =
|
||
outCounts.find(out.item);
|
||
const int count = (it != outCounts.end()) ? it->second : 0;
|
||
bufText += QString::fromStdString(out.item)
|
||
+ ": " + QString::number(count)
|
||
+ "/" + QString::number(out.amount) + " ";
|
||
}
|
||
}
|
||
else if (!outCounts.empty())
|
||
{
|
||
bufText += tr("Output: ");
|
||
for (const std::pair<const std::string, int>& entry : outCounts)
|
||
{
|
||
bufText += QString::fromStdString(entry.first)
|
||
+ ": " + QString::number(entry.second) + " ";
|
||
}
|
||
}
|
||
|
||
if (isProductionBuilding(b->type)
|
||
&& (recipe || shipDef || isAutoRecipeBuilding(b->type)))
|
||
{
|
||
if (recipe || shipDef)
|
||
{
|
||
double durationSeconds = recipe
|
||
? recipe->durationSeconds
|
||
: shipDef->schematic.productionTimeSeconds;
|
||
|
||
if (shipDef && b->shipLayout.has_value())
|
||
{
|
||
for (const PlacedModule& pm : b->shipLayout->placedModules)
|
||
{
|
||
for (const ModuleDef& modDef : m_config->modules.modules)
|
||
{
|
||
if (modDef.id == pm.moduleId)
|
||
{
|
||
durationSeconds += modDef.productionTimeSeconds;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);
|
||
|
||
if (b->production.has_value())
|
||
{
|
||
const Tick cycleTicks = secondsToTicks(durationSeconds);
|
||
const Tick completesAt = b->production->completesAt;
|
||
const Tick currentTick = m_sim->getCurrentTick();
|
||
const Tick elapsed = currentTick - (completesAt - cycleTicks);
|
||
const int pct = static_cast<int>(
|
||
std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks);
|
||
bufText += tr("Progress: %1%\n").arg(pct);
|
||
}
|
||
else
|
||
{
|
||
bufText += tr("Progress: idle\n");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Auto-recipe building with no active cycle: no single recipe to
|
||
// show a cycle time for.
|
||
bufText += tr("Progress: idle\n");
|
||
}
|
||
}
|
||
|
||
m_buffersLabel->setText(bufText);
|
||
|
||
// 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)
|
||
{
|
||
// The preview and Configure button are shipyard-only controls; hide them
|
||
// entirely for other building types.
|
||
if (type != BuildingType::Shipyard)
|
||
{
|
||
m_layoutPreview->hide();
|
||
m_configureLayoutBtn->hide();
|
||
return;
|
||
}
|
||
|
||
const ShipDef* shipDef = findShipDef(recipeId);
|
||
const bool hasSchematic = shipDef && !shipDef->layout.empty();
|
||
|
||
// Always show the preview and Configure button for a shipyard; they are only
|
||
// enabled once a schematic is selected (REQ-MOD-UI-PREVIEW).
|
||
if (hasSchematic)
|
||
{
|
||
ShipLayoutConfig layout;
|
||
if (shipLayout.has_value())
|
||
{
|
||
layout = *shipLayout;
|
||
}
|
||
m_layoutPreview->setShipAndLayout(
|
||
shipDef->layout, layout, &m_config->modules.modules);
|
||
}
|
||
else
|
||
{
|
||
m_layoutPreview->showPlaceholder();
|
||
}
|
||
|
||
m_layoutPreview->setEnabled(hasSchematic);
|
||
m_configureLayoutBtn->setEnabled(hasSchematic);
|
||
m_layoutPreview->show();
|
||
m_configureLayoutBtn->show();
|
||
}
|
||
|
||
const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const
|
||
{
|
||
if (b->recipeId.empty()) { return nullptr; }
|
||
for (const RecipeDef& r : m_config->recipes.recipes)
|
||
{
|
||
if (r.id == b->recipeId && r.building == b->type) { return &r; }
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
|
||
{
|
||
if (id.empty()) { return nullptr; }
|
||
for (const ShipDef& s : m_config->ships.ships)
|
||
{
|
||
if (s.id == id) { return &s; }
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
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_selectedEntities.empty() || !m_selectedScrap.empty())
|
||
{
|
||
// Field selection: keep the live single-actor stats current, and refresh the
|
||
// scrap total, which shrinks live as piles are collected or despawn
|
||
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-SCRAP-PANEL).
|
||
refreshEntityStats();
|
||
if (!m_selectedScrap.empty())
|
||
{
|
||
refreshScrapTotal();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!m_singleBuildingId.has_value()) { return; }
|
||
const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId);
|
||
if (b)
|
||
{
|
||
if (m_titleLabel->text().startsWith(tr("(Building) ")))
|
||
{
|
||
rebuild();
|
||
}
|
||
else
|
||
{
|
||
refreshBuffers(b);
|
||
}
|
||
return;
|
||
}
|
||
const ConstructionSite* s = m_sim->getBuildings().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();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
|
||
{
|
||
m_singleBuildingId = std::nullopt;
|
||
m_recipeSelectButton->hide();
|
||
m_clearBeltBtn->hide();
|
||
m_filterALabel->hide();
|
||
m_filterAList->hide();
|
||
m_filterBLabel->hide();
|
||
m_filterBList->hide();
|
||
m_buffersLabel->hide();
|
||
|
||
std::map<BuildingType, int> counts;
|
||
for (BuildingId id : ids)
|
||
{
|
||
const Building* b = m_sim->getBuildings().findBuilding(id);
|
||
if (b)
|
||
{
|
||
counts[b->type]++;
|
||
continue;
|
||
}
|
||
const ConstructionSite* s = m_sim->getBuildings().findSite(id);
|
||
if (s)
|
||
{
|
||
counts[s->type]++;
|
||
}
|
||
}
|
||
|
||
bool hasBelt = false;
|
||
int totalCost = 0;
|
||
QString text;
|
||
for (const std::pair<const BuildingType, int>& entry : counts)
|
||
{
|
||
text += buildingTypeName(entry.first) + ": "
|
||
+ QString::number(entry.second) + "\n";
|
||
if (isBeltLike(entry.first))
|
||
{
|
||
hasBelt = true;
|
||
}
|
||
// Total placement cost counts only player-placeable buildings; the HQ
|
||
// and defence stations are excluded (REQ-UI-MULTI-SELECTION).
|
||
const BuildingDef* def = m_config->buildings.findBuildingDef(entry.first);
|
||
if (def && def->playerPlaceable)
|
||
{
|
||
totalCost += def->cost * entry.second;
|
||
}
|
||
}
|
||
text += tr("Total: %1 Building Blocks").arg(totalCost);
|
||
m_titleLabel->setText(text.trimmed());
|
||
m_titleLabel->show();
|
||
|
||
if (hasBelt)
|
||
{
|
||
m_clearBeltBtn->show();
|
||
}
|
||
}
|
||
|
||
void SelectedBuildingPanel::onSelectRecipeClicked()
|
||
{
|
||
if (!m_singleBuildingId.has_value())
|
||
{
|
||
return;
|
||
}
|
||
// The emit is synchronous: MainWindow pauses the game, runs the modal
|
||
// 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();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildSplitterFilters(
|
||
const std::optional<BeltSystem::SplitterInfo>& info)
|
||
{
|
||
if (!info.has_value())
|
||
{
|
||
m_filterALabel->hide();
|
||
m_filterAList->hide();
|
||
m_filterBLabel->hide();
|
||
m_filterBList->hide();
|
||
return;
|
||
}
|
||
|
||
const std::vector<std::string> items = getAllItemIds();
|
||
|
||
auto populateList = [&](QListWidget* list, QLabel* label,
|
||
const QString& dirLabel,
|
||
const std::vector<ItemType>& filter)
|
||
{
|
||
label->setText(tr("%1 filter (empty = all):").arg(dirLabel));
|
||
list->blockSignals(true);
|
||
list->clear();
|
||
for (const std::string& itemId : items)
|
||
{
|
||
if (!m_sim->isItemUnlocked(itemId)) { continue; }
|
||
QListWidgetItem* row = new QListWidgetItem(
|
||
QString::fromStdString(itemId), list);
|
||
const bool checked = filter.empty()
|
||
? false
|
||
: std::find(filter.begin(), filter.end(),
|
||
ItemType{itemId}) != filter.end();
|
||
row->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
|
||
row->setFlags(row->flags() | Qt::ItemIsUserCheckable);
|
||
}
|
||
list->blockSignals(false);
|
||
label->show();
|
||
list->show();
|
||
};
|
||
|
||
populateList(m_filterAList, m_filterALabel,
|
||
rotationLabel(info->outputA), info->filterA);
|
||
populateList(m_filterBList, m_filterBLabel,
|
||
rotationLabel(info->outputB), info->filterB);
|
||
}
|
||
|
||
void SelectedBuildingPanel::onSplitterFilterChanged()
|
||
{
|
||
if (!m_singleBuildingId.has_value())
|
||
{
|
||
return;
|
||
}
|
||
|
||
auto collectFilter = [](QListWidget* list) -> std::vector<ItemType>
|
||
{
|
||
std::vector<ItemType> filter;
|
||
for (int i = 0; i < list->count(); ++i)
|
||
{
|
||
const QListWidgetItem* row = list->item(i);
|
||
if (row->checkState() == Qt::Checked)
|
||
{
|
||
filter.push_back(ItemType{row->text().toStdString()});
|
||
}
|
||
}
|
||
return filter;
|
||
};
|
||
|
||
if (m_singleIsSite)
|
||
{
|
||
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
|
||
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||
command->id = *m_singleBuildingId;
|
||
command->filterA = collectFilter(m_filterAList);
|
||
command->filterB = collectFilter(m_filterBList);
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<CommandRequestedEvent>(command));
|
||
}
|
||
else
|
||
{
|
||
std::shared_ptr<SetSplitterFiltersCommand> command =
|
||
std::make_shared<SetSplitterFiltersCommand>();
|
||
command->tile = m_splitterTile;
|
||
command->filterA = collectFilter(m_filterAList);
|
||
command->filterB = collectFilter(m_filterBList);
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<CommandRequestedEvent>(command));
|
||
}
|
||
}
|
||
|
||
std::vector<std::string> SelectedBuildingPanel::getAllItemIds() const
|
||
{
|
||
std::set<std::string> seen;
|
||
for (const RecipeDef& recipe : m_config->recipes.recipes)
|
||
{
|
||
for (const RecipeIngredient& ing : recipe.inputs)
|
||
{
|
||
seen.insert(ing.item);
|
||
}
|
||
for (const RecipeOutput& out : recipe.outputs)
|
||
{
|
||
seen.insert(out.item);
|
||
}
|
||
}
|
||
return std::vector<std::string>(seen.begin(), seen.end());
|
||
}
|
||
|
||
void SelectedBuildingPanel::onClearBelt()
|
||
{
|
||
std::vector<QPoint> tiles;
|
||
for (BuildingId id : m_selectedBuildingIds)
|
||
{
|
||
const Building* b = m_sim->getBuildings().findBuilding(id);
|
||
if (b && isBeltLike(b->type))
|
||
{
|
||
for (const QPoint& cell : b->bodyCells)
|
||
{
|
||
tiles.push_back(cell);
|
||
}
|
||
}
|
||
}
|
||
if (!tiles.empty())
|
||
{
|
||
std::shared_ptr<ClearBeltTilesCommand> command =
|
||
std::make_shared<ClearBeltTilesCommand>();
|
||
command->tiles = std::move(tiles);
|
||
EventManager::getInstance()->sendEventImmediately(
|
||
std::make_shared<CommandRequestedEvent>(command));
|
||
}
|
||
}
|
||
|
||
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
|
||
{
|
||
m_selectedEntities = event->entities;
|
||
if (!m_selectedEntities.empty())
|
||
{
|
||
// A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES).
|
||
m_selectedBuildingIds.clear();
|
||
}
|
||
buildFieldSelection();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildFieldSelection()
|
||
{
|
||
if (m_selectedEntities.empty() && m_selectedScrap.empty())
|
||
{
|
||
// Nothing in the field category. Fall back to empty unless buildings own the panel.
|
||
clearEntityDisplay();
|
||
m_scrapLabel->hide();
|
||
if (m_selectedBuildingIds.empty())
|
||
{
|
||
buildEmpty();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A field selection owns the panel: drop any building content.
|
||
clearContent();
|
||
|
||
EntityAdmin& admin = m_sim->getAdmin();
|
||
|
||
// Actor section: single-actor stats panel, multi-actor summary, or nothing.
|
||
if (m_selectedEntities.empty())
|
||
{
|
||
m_entityTitleLabel->hide();
|
||
m_entityStatsPanel->hide();
|
||
m_stationStatsLabel->hide();
|
||
m_entitySummaryLabel->hide();
|
||
}
|
||
else if (m_selectedEntities.size() == 1)
|
||
{
|
||
m_entitySummaryLabel->hide();
|
||
const entt::entity entity = m_selectedEntities.front();
|
||
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
|
||
{
|
||
buildEntityShip(entity);
|
||
}
|
||
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
|
||
{
|
||
buildEntityStation(entity);
|
||
}
|
||
else
|
||
{
|
||
m_entityTitleLabel->hide();
|
||
m_entityStatsPanel->hide();
|
||
m_stationStatsLabel->hide();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
m_entityTitleLabel->hide();
|
||
m_entityStatsPanel->hide();
|
||
m_stationStatsLabel->hide();
|
||
buildEntitySummary();
|
||
}
|
||
|
||
// Scrap section, appended below the actor section (REQ-UI-SCRAP-PANEL).
|
||
if (!m_selectedScrap.empty())
|
||
{
|
||
refreshScrapTotal();
|
||
m_scrapLabel->show();
|
||
}
|
||
else
|
||
{
|
||
m_scrapLabel->hide();
|
||
}
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildEntitySummary()
|
||
{
|
||
EntityAdmin& admin = m_sim->getAdmin();
|
||
|
||
// Group actors by faction + kind + ship schematic, preserving first-seen order
|
||
// (REQ-UI-FIELD-MULTI-SELECTION).
|
||
std::vector<QString> keys;
|
||
std::map<QString, int> counts;
|
||
std::map<QString, QString> labels;
|
||
int shown = 0;
|
||
|
||
for (entt::entity entity : m_selectedEntities)
|
||
{
|
||
if (!admin.isValid(entity)) { continue; }
|
||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
||
&& admin.get<FactionComponent>(entity).isEnemy;
|
||
|
||
QString key;
|
||
QString label;
|
||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||
{
|
||
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
|
||
const QString name = QString::fromStdString(toDisplayName(id));
|
||
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
|
||
+ QString::fromStdString(id);
|
||
label = isEnemy ? tr("Enemy %1").arg(name) : name;
|
||
}
|
||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||
{
|
||
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
|
||
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
|
||
}
|
||
else
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (counts.find(key) == counts.end())
|
||
{
|
||
keys.push_back(key);
|
||
labels[key] = label;
|
||
}
|
||
counts[key] += 1;
|
||
++shown;
|
||
}
|
||
|
||
QString text = tr("%1 selected").arg(shown);
|
||
for (const QString& key : keys)
|
||
{
|
||
text += tr("\n%1 ×%2").arg(labels[key]).arg(counts[key]);
|
||
}
|
||
m_entitySummaryLabel->setText(text);
|
||
m_entitySummaryLabel->show();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
|
||
{
|
||
EntityAdmin& admin = m_sim->getAdmin();
|
||
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
|
||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||
|
||
m_entityTitleLabel->setText(tr("Ship: %1")
|
||
.arg(QString::fromStdString(identity.schematicId)));
|
||
m_entityTitleLabel->show();
|
||
|
||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||
m_entityStatsPanel->setBehavior(
|
||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
|
||
|
||
for (const ShipDef& def : m_config->ships.ships)
|
||
{
|
||
if (def.id == identity.schematicId)
|
||
{
|
||
double threat = calculateShipThreatCost(
|
||
m_config->threatCosts, *m_config, def.id, def.defaultModules);
|
||
m_entityStatsPanel->setThreatCost(threat);
|
||
break;
|
||
}
|
||
}
|
||
|
||
m_entityStatsPanel->show();
|
||
|
||
m_stationStatsLabel->hide();
|
||
}
|
||
|
||
void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
|
||
{
|
||
EntityAdmin& admin = m_sim->getAdmin();
|
||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||
|
||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
||
&& admin.get<FactionComponent>(entity).isEnemy;
|
||
m_entityTitleLabel->setText(isEnemy
|
||
? tr("Enemy Defence Station")
|
||
: tr("Player Defence Station"));
|
||
m_entityTitleLabel->show();
|
||
|
||
float totalDps = 0.0f;
|
||
float maxRange = 0.0f;
|
||
bool hasWeapons = false;
|
||
|
||
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
|
||
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
|
||
{
|
||
if (owner.owner != entity) { return; }
|
||
hasWeapons = true;
|
||
totalDps += w.damage * w.fireRateHz;
|
||
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
|
||
});
|
||
|
||
QString statsText = tr("HP: %1 / %2")
|
||
.arg(static_cast<int>(health.hp + 0.5f))
|
||
.arg(static_cast<int>(health.maxHp + 0.5f));
|
||
|
||
if (hasWeapons)
|
||
{
|
||
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
|
||
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
|
||
}
|
||
|
||
m_stationStatsLabel->setText(statsText);
|
||
m_stationStatsLabel->show();
|
||
|
||
m_entityStatsPanel->hide();
|
||
}
|
||
|
||
void SelectedBuildingPanel::refreshEntityStats()
|
||
{
|
||
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
|
||
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
|
||
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
|
||
if (m_selectedEntities.size() != 1) { return; }
|
||
|
||
EntityAdmin& admin = m_sim->getAdmin();
|
||
const entt::entity entity = m_selectedEntities.front();
|
||
|
||
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
|
||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||
if (health.hp <= 0.0f) { return; }
|
||
|
||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||
{
|
||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||
m_entityStatsPanel->refreshFromLive(stats, health.hp);
|
||
m_entityStatsPanel->setBehavior(
|
||
admin.get<SelectedBehaviorComponent>(entity).winner);
|
||
}
|
||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||
{
|
||
buildEntityStation(entity);
|
||
}
|
||
}
|
||
|
||
void SelectedBuildingPanel::clearEntityDisplay()
|
||
{
|
||
m_selectedEntities.clear();
|
||
m_entityTitleLabel->hide();
|
||
m_entityStatsPanel->hide();
|
||
m_stationStatsLabel->hide();
|
||
m_entitySummaryLabel->hide();
|
||
}
|
||
|
||
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
||
{
|
||
onSelectionChanged(event->ids);
|
||
}
|
||
|
||
void SelectedBuildingPanel::handleEvent(
|
||
std::shared_ptr<const ScrapSelectionChangedEvent> event)
|
||
{
|
||
m_selectedScrap = event->scrap;
|
||
if (!m_selectedScrap.empty())
|
||
{
|
||
// Scrap is a field object: it supersedes any building selection but coexists
|
||
// with actors (REQ-UI-SELECTION-CATEGORIES).
|
||
m_selectedBuildingIds.clear();
|
||
}
|
||
buildFieldSelection();
|
||
}
|
||
|
||
void SelectedBuildingPanel::refreshScrapTotal()
|
||
{
|
||
// Sum the remaining amounts of the still-living selected piles (REQ-UI-SCRAP-PANEL).
|
||
int total = 0;
|
||
for (const ScrapInfo& info : m_sim->getScraps().getAllScrapInfo())
|
||
{
|
||
if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), info.entity)
|
||
!= m_selectedScrap.end())
|
||
{
|
||
total += info.amount;
|
||
}
|
||
}
|
||
m_scrapLabel->setText(tr("Scrap: %1").arg(total));
|
||
}
|
||
|
||
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
|
||
{
|
||
m_debugDraw = event->active;
|
||
m_entityStatsPanel->setDebugDrawEnabled(event->active);
|
||
}
|