#include "SelectedBuildingPanel.h" #include #include #include #include #include #include #include #include #include #include #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(std::toupper(static_cast(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(*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& 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& 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 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 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( 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& 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 outCounts; for (const Item& item : b->outputBuffer.items) { outCounts[item.type.id]++; } for (const std::vector& 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::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& 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( 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& 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 /*event*/) { refreshSelectionDisplay(RefreshReason::PeriodicTick); } void SelectedBuildingPanel::handleEvent( std::shared_ptr /*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 values current: the single-actor stats panel, // the standalone scrap total, or the count summary (whose scrap line shrinks as // piles are collected) — matching the layout chosen by buildFieldSelection() // (REQ-UI-SHIP-STATS-PANEL, REQ-UI-SCRAP-PANEL). if (m_selectedEntities.size() == 1 && m_selectedScrap.empty()) { refreshEntityStats(); } else if (m_selectedEntities.empty()) { refreshScrapTotal(); } else { buildEntitySummary(); } 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& 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 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& entry : counts) { text += buildingTypeName(entry.first) + " x " + 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(*m_singleBuildingId)); rebuild(); } void SelectedBuildingPanel::buildSplitterFilters( const std::optional& info) { if (!info.has_value()) { m_filterALabel->hide(); m_filterAList->hide(); m_filterBLabel->hide(); m_filterBList->hide(); return; } const std::vector items = getAllItemIds(); auto populateList = [&](QListWidget* list, QLabel* label, const QString& dirLabel, const std::vector& 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 { std::vector 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 command = std::make_shared(); command->id = *m_singleBuildingId; command->filterA = collectFilter(m_filterAList); command->filterB = collectFilter(m_filterBList); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } else { std::shared_ptr command = std::make_shared(); command->tile = m_splitterTile; command->filterA = collectFilter(m_filterAList); command->filterB = collectFilter(m_filterBList); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } } std::vector SelectedBuildingPanel::getAllItemIds() const { std::set 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(seen.begin(), seen.end()); } void SelectedBuildingPanel::onClearBelt() { std::vector 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 command = std::make_shared(); command->tiles = std::move(tiles); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } } void SelectedBuildingPanel::handleEvent(std::shared_ptr 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(); // Full single-actor stats are shown only for a lone actor with no scrap. As soon as // the selection holds more than one object (multiple actors, or an actor plus scrap), // the panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION). if (m_selectedEntities.size() == 1 && m_selectedScrap.empty()) { m_entitySummaryLabel->hide(); m_scrapLabel->hide(); const entt::entity entity = m_selectedEntities.front(); if (admin.isValid(entity) && admin.hasAll(entity)) { buildEntityShip(entity); } else if (admin.isValid(entity) && admin.hasAll(entity)) { buildEntityStation(entity); } else { m_entityTitleLabel->hide(); m_entityStatsPanel->hide(); m_stationStatsLabel->hide(); } return; } m_entityTitleLabel->hide(); m_entityStatsPanel->hide(); m_stationStatsLabel->hide(); if (m_selectedEntities.empty()) { // Scrap only: a single "Scrap: N" line. m_entitySummaryLabel->hide(); refreshScrapTotal(); m_scrapLabel->show(); return; } // Actor counts, with the scrap total appended into the same label so every line // shares the same spacing. m_scrapLabel->hide(); buildEntitySummary(); } 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 keys; std::map counts; std::map labels; for (entt::entity entity : m_selectedEntities) { if (!admin.isValid(entity)) { continue; } const bool isEnemy = admin.hasAll(entity) && admin.get(entity).isEnemy; QString key; QString label; if (admin.hasAll(entity)) { const std::string& id = admin.get(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(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; } // One " x " line per group (matching the recipe tooltip and the building // multi-selection). No total-count header, consistent with the building panel. The // scrap total, when present, is appended as another line in the same label so the // line spacing is uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-SCRAP-PANEL). QStringList lines; for (const QString& key : keys) { lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]); } if (!m_selectedScrap.empty()) { lines << scrapTotalText(); } m_entitySummaryLabel->setText(lines.join('\n')); m_entitySummaryLabel->show(); } void SelectedBuildingPanel::buildEntityShip(entt::entity entity) { EntityAdmin& admin = m_sim->getAdmin(); const ShipIdentityComponent& identity = admin.get(entity); const HealthComponent& health = admin.get(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(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(entity); const bool isEnemy = admin.hasAll(entity) && admin.get(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( [&](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(health.hp + 0.5f)) .arg(static_cast(health.maxHp + 0.5f)); if (hasWeapons) { statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(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(entity)) { return; } const HealthComponent& health = admin.get(entity); if (health.hp <= 0.0f) { return; } if (admin.hasAll(entity)) { const ShipStats stats = buildShipStatsFromEntity(admin, entity); m_entityStatsPanel->refreshFromLive(stats, health.hp); m_entityStatsPanel->setBehavior( admin.get(entity).winner); } else if (admin.hasAll(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 event) { onSelectionChanged(event->ids); } void SelectedBuildingPanel::handleEvent( std::shared_ptr 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(); } QString SelectedBuildingPanel::scrapTotalText() const { // 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; } } return tr("Scrap x %1").arg(total); } void SelectedBuildingPanel::refreshScrapTotal() { m_scrapLabel->setText(scrapTotalText()); } void SelectedBuildingPanel::handleEvent(std::shared_ptr event) { m_debugDraw = event->active; m_entityStatsPanel->setDebugDrawEnabled(event->active); }