Files
dota_factory/src/ui/SelectedBuildingPanel.cpp
Malte Langkabel db1b7b617f Rename EntitySelectedEvent to EntitySelectionChangedEvent
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
2026-07-20 20:32:35 +02:00

1196 lines
37 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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);
}