From be475e2836cb27ee71a32b2aaaa9b705e5285198 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Mon, 20 Jul 2026 20:39:48 +0200 Subject: [PATCH] allow multi-select for ships/stations, mixable with scrap --- docs/requirements.md | 16 +- src/balancing/ArenaView.cpp | 10 +- src/balancing/ArenaView.h | 2 +- src/balancing/InspectWindow.cpp | 7 +- src/balancing/InspectWindow.h | 6 +- src/lib/eventsystem/event/CMakeLists.txt | 2 +- .../eventsystem/event/EntitySelectedEvent.h | 21 -- .../event/EntitySelectionChangedEvent.h | 25 ++ src/lib/sim/EntityHitTest.cpp | 44 +++ src/lib/sim/EntityHitTest.h | 7 + src/test/ScrapTest.cpp | 43 +++ src/ui/GameWorldView.cpp | 268 ++++++++++++------ src/ui/GameWorldView.h | 12 +- src/ui/SelectedBuildingPanel.cpp | 233 ++++++++++----- src/ui/SelectedBuildingPanel.h | 19 +- 15 files changed, 509 insertions(+), 206 deletions(-) delete mode 100644 src/lib/eventsystem/event/EntitySelectedEvent.h create mode 100644 src/lib/eventsystem/event/EntitySelectionChangedEvent.h diff --git a/docs/requirements.md b/docs/requirements.md index 2a7ce0a..a77592e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -483,9 +483,10 @@ The screen is divided into two columns: a main column (75% width) containing the ### Selected Building Panel - REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or scrap pile), the panel is empty. +- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with scrap piles). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus scrap piles, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident scrap pile (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-SCRAP-CLICK-SELECT). - REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG). - REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown. -- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. A box-drag that covers at least one building selects buildings (any scrap piles within the box are ignored); a box-drag that covers no buildings but does cover scrap piles selects those scrap piles instead (REQ-UI-SCRAP-MULTI-SELECT). +- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or scrap piles selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT). - REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress. - REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW). - REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). @@ -493,8 +494,8 @@ The screen is divided into two columns: a main column (75% width) containing the - For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item. - For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 ". - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. -- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking a scrap pile instead establishes a scrap selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection. -- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). +- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with scrap piles and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a scrap pile adds to or establishes a field selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection. +- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no scrap is selected, the selected building panel shows a **ship stats panel**. (If scrap is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). - REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are: - **Retreating** — the ship is retreating (REQ-SHP-RETREAT). - **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI). @@ -503,10 +504,11 @@ The screen is divided into two columns: a main column (75% width) containing the - **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY). - **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY). - **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid). -- REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. -- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap forms its own selection category: clicking a scrap pile clears any existing selection and establishes a scrap selection containing only that pile. Scrap piles cannot participate in a selection together with buildings, ships, or defence stations. Hit-testing prioritizes actors over scrap: when a building, ship, or defence station lies under the cursor at the same point as a scrap pile, that object is selected in preference to the scrap; a scrap pile is selected only when no building, ship, or defence station is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; once the last selected pile is gone, the selection becomes empty (REQ-UI-EMPTY-SELECTION). -- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them from the selection, mirroring building multi-select (REQ-UI-MULTI-SELECT). A scrap selection contains only scrap piles. Because scrap cannot mix with other object types (REQ-UI-SCRAP-CLICK-SELECT), Ctrl+clicking a scrap pile while a building, ship, or defence station selection is active first clears that selection and begins a scrap selection; conversely, selecting a building, ship, or defence station while a scrap selection is active clears the scrap selection. Box-drag disambiguation between buildings and scrap follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering scrap but no buildings selects the scrap). -- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap: 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT). +- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no scrap is selected, the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. (If scrap is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) +- REQ-UI-FIELD-MULTI-SELECTION: The full single-actor stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) is shown only when the field selection holds exactly one actor and no scrap. Whenever the selection holds more than one object — multiple actors, or a single actor together with scrap — the panel shows a **compact summary** instead: a count per actor type, one line per type rendered as " x " (the same `x`-count notation as the recipe tooltip and the building multi-selection, REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy. No per-actor detail and no total-actor-count header are shown (consistent with the building panel). If scrap piles are also part of the field selection (REQ-UI-SELECTION-CATEGORIES) their total is appended as a final line of the same summary (REQ-UI-SCRAP-PANEL), so all lines share uniform spacing. Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead. +- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap piles are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a scrap pile makes it the sole selection, clearing any previous selection; selecting a building clears any scrap (buildings win), and selecting a scrap pile clears any building selection. Hit-testing prefers a building over a coincident actor or scrap pile, and an actor (ship or defence station) over a coincident scrap pile: a scrap pile is selected only when no building or actor is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION). +- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Scrap shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold scrap piles and actors together. Ctrl+clicking a scrap pile while a field selection is active adds or removes that pile within the same selection; Ctrl+clicking a scrap pile while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and scrap piles within it). +- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap x 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT). When actors are also selected, this scrap total is shown as an additional line of the actor count summary rather than alongside a single-actor stats panel (REQ-UI-FIELD-MULTI-SELECTION). ### Build Button Grid diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index b9128eb..f781e19 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -15,7 +15,7 @@ #include "Building.h" #include "BuildingSystem.h" #include "EntityHitTest.h" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "EventManager.h" #include "FacingComponent.h" #include "FactionComponent.h" @@ -259,8 +259,14 @@ void ArenaView::mousePressEvent(QMouseEvent* event) m_selectedEntity = std::nullopt; } + // The arena is strictly single-select; emit a vector of size 0 or 1. + std::vector selection; + if (m_selectedEntity.has_value()) + { + selection.push_back(*m_selectedEntity); + } EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedEntity)); + std::make_shared(selection)); } QOpenGLWidget::mousePressEvent(event); diff --git a/src/balancing/ArenaView.h b/src/balancing/ArenaView.h index 5849b32..4bfc9f7 100644 --- a/src/balancing/ArenaView.h +++ b/src/balancing/ArenaView.h @@ -13,7 +13,7 @@ #include "BeamFiredEvent.h" #include "entt/entity/entity.hpp" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "Tick.h" #include "TickDriver.h" #include "VisualsConfig.h" diff --git a/src/balancing/InspectWindow.cpp b/src/balancing/InspectWindow.cpp index 234bd87..419ee60 100644 --- a/src/balancing/InspectWindow.cpp +++ b/src/balancing/InspectWindow.cpp @@ -250,11 +250,12 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status) } } -void InspectWindow::handleEvent(std::shared_ptr event) +void InspectWindow::handleEvent(std::shared_ptr event) { - if (event->entity.has_value()) + if (!event->entities.empty()) { - m_selectedEntity = event->entity; + // The arena is single-select, so only the first entity is inspected. + m_selectedEntity = event->entities.front(); EntityAdmin& admin = m_sim->getAdmin(); entt::entity entity = *m_selectedEntity; diff --git a/src/balancing/InspectWindow.h b/src/balancing/InspectWindow.h index ea3826b..008d2ea 100644 --- a/src/balancing/InspectWindow.h +++ b/src/balancing/InspectWindow.h @@ -12,7 +12,7 @@ #include "entt/entity/entity.hpp" #include "ArenaSimulation.h" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "EventHandler.h" #include "GameConfig.h" #include "GameSpeedChangedEvent.h" @@ -22,7 +22,7 @@ class ArenaView; class ShipStatsPanel; class InspectWindow : public QWidget, - public CombinedEventHandler { Q_OBJECT @@ -38,7 +38,7 @@ protected: void keyPressEvent(QKeyEvent* event) override; private: - void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; private slots: diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 327abab..784387d 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -4,7 +4,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h - ${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h diff --git a/src/lib/eventsystem/event/EntitySelectedEvent.h b/src/lib/eventsystem/event/EntitySelectedEvent.h deleted file mode 100644 index d54a9cd..0000000 --- a/src/lib/eventsystem/event/EntitySelectedEvent.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef ENTITY_SELECTED_EVENT_H -#define ENTITY_SELECTED_EVENT_H - -#include - -#include "entt/entity/entity.hpp" - -#include "Event.h" - -class EntitySelectedEvent : public Event -{ -public: - explicit EntitySelectedEvent(std::optional entity) - : entity(entity) - { - } - - const std::optional entity; -}; - -#endif // ENTITY_SELECTED_EVENT_H diff --git a/src/lib/eventsystem/event/EntitySelectionChangedEvent.h b/src/lib/eventsystem/event/EntitySelectionChangedEvent.h new file mode 100644 index 0000000..2419358 --- /dev/null +++ b/src/lib/eventsystem/event/EntitySelectionChangedEvent.h @@ -0,0 +1,25 @@ +#ifndef ENTITY_SELECTION_CHANGED_EVENT_H +#define ENTITY_SELECTION_CHANGED_EVENT_H + +#include + +#include "entt/entity/entity.hpp" + +#include "Event.h" + +// The set of currently selected ships and/or defence stations. An empty list means +// no actor is selected. Actors share the "field" selection category with scrap piles +// (REQ-UI-SELECTION-CATEGORIES): they can be selected together, but never together with +// buildings. +class EntitySelectionChangedEvent : public Event +{ +public: + explicit EntitySelectionChangedEvent(std::vector entities) + : entities(std::move(entities)) + { + } + + const std::vector entities; +}; + +#endif // ENTITY_SELECTION_CHANGED_EVENT_H diff --git a/src/lib/sim/EntityHitTest.cpp b/src/lib/sim/EntityHitTest.cpp index 948dbdf..77c7445 100644 --- a/src/lib/sim/EntityHitTest.cpp +++ b/src/lib/sim/EntityHitTest.cpp @@ -6,6 +6,7 @@ #include "EntityAdmin.h" #include "PositionComponent.h" #include "ScrapDataComponent.h" +#include "ShipIdentityComponent.h" #include "StationBodyComponent.h" #include "HealthComponent.h" @@ -101,3 +102,46 @@ std::vector scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint ti }); return result; } + +std::vector actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB) +{ + const int minX = std::min(tileA.x(), tileB.x()); + const int maxX = std::max(tileA.x(), tileB.x()); + const int minY = std::min(tileA.y(), tileB.y()); + const int maxY = std::max(tileA.y(), tileB.y()); + + std::vector result; + + // Stations: included when any occupied body cell lies in the box. + admin.forEach( + [&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h) + { + if (h.hp <= 0.0f) { return; } + for (const QPoint& cell : sb.bodyCells) + { + if (cell.x() >= minX && cell.x() <= maxX + && cell.y() >= minY && cell.y() <= maxY) + { + result.push_back(entity); + return; + } + } + }); + + // Ships: included when the floored position tile lies in the box. Requiring + // ShipIdentityComponent excludes the HQ proxy and any station bodies. + admin.forEach( + [&](entt::entity entity, const ShipIdentityComponent& /*id*/, + const PositionComponent& pos, const HealthComponent& h) + { + if (h.hp <= 0.0f) { return; } + const int tileX = static_cast(std::floor(pos.value.x())); + const int tileY = static_cast(std::floor(pos.value.y())); + if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY) + { + result.push_back(entity); + } + }); + + return result; +} diff --git a/src/lib/sim/EntityHitTest.h b/src/lib/sim/EntityHitTest.h index 5ca4f3f..ca473a5 100644 --- a/src/lib/sim/EntityHitTest.h +++ b/src/lib/sim/EntityHitTest.h @@ -19,3 +19,10 @@ entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos); // Returns every scrap pile whose position falls within the inclusive tile rectangle // spanned by tileA and tileB, in any corner order (REQ-UI-SCRAP-MULTI-SELECT). std::vector scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB); + +// Returns every living actor (ship or defence station, player or enemy) that falls +// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order +// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored +// position tile lies in the box; a station is included when any of its body cells does. +// Dead actors (hp <= 0) and the HQ proxy are excluded. +std::vector actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB); diff --git a/src/test/ScrapTest.cpp b/src/test/ScrapTest.cpp index 1fdb44b..35209bc 100644 --- a/src/test/ScrapTest.cpp +++ b/src/test/ScrapTest.cpp @@ -1,5 +1,6 @@ #include "catch.hpp" +#include #include #include @@ -226,3 +227,45 @@ TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[sc REQUIRE(contains(hit, inB)); REQUIRE_FALSE(contains(hit, outX)); } + +TEST_CASE("actorsInBox returns living ships and stations, excluding scrap and dead actors", + "[actor]") +{ + EntityAdmin admin; + + // Two living ships inside the box: one player, one enemy. + const entt::entity playerShip = admin.spawnShip( + QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, + "fighter", false); // tile (1,2) + const entt::entity enemyShip = admin.spawnShip( + QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, + "raider", true); // tile (4,5) + + // A dead ship inside the box is excluded. + const entt::entity deadShip = admin.spawnShip( + QVector2D(3.0f, 3.0f), 0.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, + "fighter", false); + + // A ship outside the box is excluded. + const entt::entity outsideShip = admin.spawnShip( + QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f, + "fighter", false); + + // A station is included when any body cell lies inside the box. + const std::vector stationCells{ QPoint(2, 2), QPoint(3, 2) }; + const entt::entity station = admin.spawnStation( + QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true); + + // Scrap and the HQ proxy are never actors. + admin.spawnScrap(QVector2D(1.0f, 1.0f), 5, Tick(1000)); + admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f); + + const std::vector hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0)); + + REQUIRE(hit.size() == 3); + REQUIRE(contains(hit, playerShip)); + REQUIRE(contains(hit, enemyShip)); + REQUIRE(contains(hit, station)); + REQUIRE_FALSE(contains(hit, deadShip)); + REQUIRE_FALSE(contains(hit, outsideShip)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 4ab98bb..a9601b5 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -37,7 +37,7 @@ #include "ReplayRecorder.h" #include "DemolishModeChangedEvent.h" #include "EntityHitTest.h" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "EventManager.h" #include "FacingComponent.h" #include "FactionComponent.h" @@ -284,6 +284,7 @@ void GameWorldView::onFrame() // Drop selected scrap piles that were collected or despawned this frame, so the // panel stops counting them and the selection empties out (REQ-UI-SCRAP-CLICK-SELECT). pruneDespawnedScrap(); + pruneDespawnedActors(); // Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the // flash plays for a fixed real duration regardless of game speed, including @@ -725,6 +726,43 @@ void GameWorldView::pruneDespawnedScrap() } } +void GameWorldView::clearEntitySelection() +{ + if (m_selectedEntities.empty()) { return; } + m_selectedEntities.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedEntities)); +} + +void GameWorldView::pruneDespawnedActors() +{ + if (m_selectedEntities.empty()) { return; } + + EntityAdmin& admin = m_sim->getAdmin(); + std::vector live; + for (entt::entity e : m_selectedEntities) + { + if (admin.isValid(e) && admin.hasAll(e) + && admin.get(e).hp > 0.0f) + { + live.push_back(e); + } + } + + if (live.size() != m_selectedEntities.size()) + { + m_selectedEntities = std::move(live); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedEntities)); + } +} + +bool GameWorldView::isEntitySelected(entt::entity entity) const +{ + return std::find(m_selectedEntities.begin(), m_selectedEntities.end(), entity) + != m_selectedEntities.end(); +} + void GameWorldView::stepSpeed(int delta) { const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; @@ -1305,7 +1343,7 @@ void GameWorldView::drawStations(QPainter& painter) painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); - if (m_selectedEntity.has_value() && *m_selectedEntity == e) + if (isEntitySelected(e)) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); @@ -1352,7 +1390,7 @@ void GameWorldView::drawShips(QPainter& painter) painter.setBrush(it->second.fill); painter.drawPolygon(tri); - if (m_selectedEntity.has_value() && *m_selectedEntity == e) + if (isEntitySelected(e)) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); @@ -2040,102 +2078,128 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) } } + const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0; const QVector2D worldPos = widgetToWorld(event->pos()); - const entt::entity hitEntity = entityAtWorldPos(m_sim->getAdmin(), worldPos); - if (hitEntity != entt::null) + // Point hit-test precedence: buildings win over actors, which win over scrap + // (REQ-UI-SELECTION-CATEGORIES). + std::optional buildingHit = buildingAtTile(tile); + if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); } + + if (buildingHit.has_value()) { - // Actors (ship/station) win over scrap and buildings (REQ-UI-SCRAP-CLICK-SELECT). + const BuildingId id = *buildingHit; + // A building selection is exclusive: it clears any field selection — + // actors and scrap — because buildings win (REQ-UI-SELECTION-CATEGORIES). + clearEntitySelection(); clearScrapSelection(); - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - m_selectedEntity = hitEntity; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(hitEntity)); - } - else - { - if (m_selectedEntity.has_value()) + if (ctrl) { - m_selectedEntity = std::nullopt; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(std::nullopt)); - } - - std::optional hit = buildingAtTile(tile); - if (!hit.has_value()) - { - hit = siteAtTile(tile); - } - if (hit.has_value()) - { - const BuildingId id = *hit; - // A building/construction site outranks scrap (REQ-UI-SCRAP-CLICK-SELECT). - clearScrapSelection(); - if (event->modifiers() & Qt::ControlModifier) + bool found = false; + std::vector newSel; + for (BuildingId sel : m_selectedBuildingIds) { - bool found = false; - std::vector newSel; - for (BuildingId sel : m_selectedBuildingIds) - { - if (sel == id) { found = true; } - else { newSel.push_back(sel); } - } - if (!found) { newSel.push_back(id); } - m_selectedBuildingIds = newSel; + if (sel == id) { found = true; } + else { newSel.push_back(sel); } } - else - { - m_selectedBuildingIds = { id }; - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - } - else if (const entt::entity scrapHit = - scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null) - { - // Scrap forms its own selection category; picking it clears any - // building selection (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT). - if (!m_selectedBuildingIds.empty()) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - } - if (event->modifiers() & Qt::ControlModifier) - { - bool found = false; - std::vector newSel; - for (entt::entity sel : m_selectedScrap) - { - if (sel == scrapHit) { found = true; } - else { newSel.push_back(sel); } - } - if (!found) { newSel.push_back(scrapHit); } - m_selectedScrap = newSel; - } - else - { - m_selectedScrap = { scrapHit }; - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedScrap)); + if (!found) { newSel.push_back(id); } + m_selectedBuildingIds = newSel; } else { - if (!(event->modifiers() & Qt::ControlModifier)) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - clearScrapSelection(); - } - m_boxSelecting = true; - m_boxStartTile = tile; - m_boxCurrentTile = tile; + m_selectedBuildingIds = { id }; } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + return; } + + // Selecting a field object (actor or scrap) clears any building selection but + // lets actors and scrap coexist (REQ-UI-SELECTION-CATEGORIES). + const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos); + if (actorHit != entt::null) + { + if (!m_selectedBuildingIds.empty()) + { + m_selectedBuildingIds.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + } + if (ctrl) + { + // Toggle this actor within the field selection, leaving scrap intact + // (REQ-UI-ENTITY-CLICK-SELECT). + bool found = false; + std::vector newSel; + for (entt::entity sel : m_selectedEntities) + { + if (sel == actorHit) { found = true; } + else { newSel.push_back(sel); } + } + if (!found) { newSel.push_back(actorHit); } + m_selectedEntities = newSel; + } + else + { + // A plain click makes this actor the sole selection. + m_selectedEntities = { actorHit }; + clearScrapSelection(); + } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedEntities)); + return; + } + + if (const entt::entity scrapHit = + scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null) + { + if (!m_selectedBuildingIds.empty()) + { + m_selectedBuildingIds.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + } + if (ctrl) + { + // Toggle this pile within the field selection, leaving actors intact + // (REQ-UI-SCRAP-MULTI-SELECT). + bool found = false; + std::vector newSel; + for (entt::entity sel : m_selectedScrap) + { + if (sel == scrapHit) { found = true; } + else { newSel.push_back(sel); } + } + if (!found) { newSel.push_back(scrapHit); } + m_selectedScrap = newSel; + } + else + { + // A plain click makes this pile the sole selection. + m_selectedScrap = { scrapHit }; + clearEntitySelection(); + } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedScrap)); + return; + } + + // Empty space: a plain click clears the whole selection and starts a box drag; + // Ctrl preserves the current selection for additive box-select. + if (!ctrl) + { + if (!m_selectedBuildingIds.empty()) + { + m_selectedBuildingIds.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + } + clearEntitySelection(); + clearScrapSelection(); + } + m_boxSelecting = true; + m_boxStartTile = tile; + m_boxCurrentTile = tile; } } @@ -2206,8 +2270,9 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) if (!boxIds.empty()) { - // A box covering any building selects buildings; scrap in the box is - // ignored (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT). + // A box covering any building selects buildings; field objects (actors and + // scrap) in the box are ignored — buildings win (REQ-UI-MULTI-SELECT). + clearEntitySelection(); clearScrapSelection(); if (!ctrl) { @@ -2230,11 +2295,13 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) return; } - // No buildings in the box: a scrap-only box selects the scrap it covers - // (REQ-UI-SCRAP-MULTI-SELECT). + // No buildings in the box: select the field objects it covers — ships, defence + // stations, and scrap together (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT). + const std::vector boxActors = + actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); const std::vector boxScrap = scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); - if (!boxScrap.empty()) + if (!boxActors.empty() || !boxScrap.empty()) { if (!m_selectedBuildingIds.empty()) { @@ -2244,10 +2311,20 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) } if (!ctrl) { - m_selectedScrap = boxScrap; + m_selectedEntities = boxActors; + m_selectedScrap = boxScrap; } else { + for (entt::entity e : boxActors) + { + bool found = false; + for (entt::entity sel : m_selectedEntities) + { + if (sel == e) { found = true; break; } + } + if (!found) { m_selectedEntities.push_back(e); } + } for (entt::entity e : boxScrap) { bool found = false; @@ -2258,6 +2335,8 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) if (!found) { m_selectedScrap.push_back(e); } } } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedEntities)); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedScrap)); return; @@ -2269,6 +2348,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); + clearEntitySelection(); clearScrapSelection(); } } @@ -2483,6 +2563,8 @@ void GameWorldView::resetForNewGame() EventManager::getInstance()->sendEventImmediately( std::make_shared(false)); m_selectedBuildingIds.clear(); + clearEntitySelection(); + clearScrapSelection(); m_copiedConfig = std::nullopt; m_copyConfigFlashes.clear(); m_boxSelecting = false; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 661d9da..6d612db 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -36,7 +36,7 @@ #include "entt/entity/entity.hpp" #include "CommandManager.h" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "GameConfig.h" #include "Rotation.h" #include "Tick.h" @@ -193,6 +193,14 @@ private: // Drops despawned or fully-collected piles from the scrap selection and re-emits // when it changed (REQ-UI-SCRAP-CLICK-SELECT). Called each frame from onFrame(). void pruneDespawnedScrap(); + // Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was + // non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over. + void clearEntitySelection(); + // Drops despawned or dead actors from the selection and re-emits when it changed + // (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame(). + void pruneDespawnedActors(); + // True if the given actor is part of the current actor selection. + bool isEntitySelected(entt::entity entity) const; void stepSpeed(int delta); void placeAtTile(QPoint tile); @@ -274,7 +282,7 @@ private: bool m_debugDraw; std::vector m_selectedBuildingIds; - std::optional m_selectedEntity; + std::vector m_selectedEntities; std::vector m_selectedScrap; bool m_boxSelecting; QPoint m_boxStartTile; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index f4c8693..9b035b7 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -9,14 +9,16 @@ #include #include #include +#include #include #include "BeltSystem.h" #include "Command.h" #include "CommandRequestedEvent.h" +#include "DisplayName.h" #include "DynamicBodyComponent.h" #include "EntityAdmin.h" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "EventManager.h" #include "FactionComponent.h" #include "HealthComponent.h" @@ -196,6 +198,11 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, 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(); @@ -215,8 +222,9 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector& id m_selectedBuildingIds = ids; if (!ids.empty()) { + // A building selection is exclusive: it supersedes any field selection — + // actors and scrap (REQ-UI-SELECTION-CATEGORIES). clearEntityDisplay(); - // A building selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT). m_selectedScrap.clear(); m_scrapLabel->hide(); } @@ -266,6 +274,7 @@ void SelectedBuildingPanel::buildEmpty() m_entityTitleLabel->hide(); m_entityStatsPanel->hide(); m_stationStatsLabel->hide(); + m_entitySummaryLabel->hide(); } void SelectedBuildingPanel::buildSingle(BuildingId id) @@ -658,16 +667,24 @@ void SelectedBuildingPanel::handleEvent( void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) { - if (!m_selectedScrap.empty()) + if (!m_selectedEntities.empty() || !m_selectedScrap.empty()) { - // The total shrinks live as piles are collected or despawn (REQ-UI-SCRAP-PANEL). - refreshScrapTotal(); - return; - } - - if (m_selectedEntity.has_value()) - { - refreshEntityStats(); + // 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; } @@ -737,7 +754,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector& ids) QString text; for (const std::pair& entry : counts) { - text += buildingTypeName(entry.first) + ": " + text += buildingTypeName(entry.first) + " x " + QString::number(entry.second) + "\n"; if (isBeltLike(entry.first)) { @@ -906,39 +923,139 @@ void SelectedBuildingPanel::onClearBelt() } } -void SelectedBuildingPanel::handleEvent(std::shared_ptr event) +void SelectedBuildingPanel::handleEvent(std::shared_ptr event) { - if (event->entity.has_value()) + m_selectedEntities = event->entities; + if (!m_selectedEntities.empty()) { - m_selectedEntity = event->entity; + // A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). m_selectedBuildingIds.clear(); - // An entity selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT). - m_selectedScrap.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(); - clearContent(); - - EntityAdmin& admin = m_sim->getAdmin(); - entt::entity entity = *m_selectedEntity; - - if (!admin.isValid(entity)) + if (m_selectedBuildingIds.empty()) { - clearEntityDisplay(); - return; + buildEmpty(); } + return; + } - if (admin.hasAll(entity)) + // 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.hasAll(entity)) + else if (admin.isValid(entity) && admin.hasAll(entity)) { buildEntityStation(entity); } + else + { + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + } + return; } - else + + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + + if (m_selectedEntities.empty()) { - clearEntityDisplay(); + // 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) @@ -1016,23 +1133,17 @@ void SelectedBuildingPanel::buildEntityStation(entt::entity entity) void SelectedBuildingPanel::refreshEntityStats() { - if (!m_selectedEntity.has_value()) { return; } + // 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(); - entt::entity entity = *m_selectedEntity; - - if (!admin.isValid(entity)) - { - clearEntityDisplay(); - return; - } + 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) - { - clearEntityDisplay(); - return; - } + if (health.hp <= 0.0f) { return; } if (admin.hasAll(entity)) { @@ -1049,10 +1160,11 @@ void SelectedBuildingPanel::refreshEntityStats() void SelectedBuildingPanel::clearEntityDisplay() { - m_selectedEntity = std::nullopt; + m_selectedEntities.clear(); m_entityTitleLabel->hide(); m_entityStatsPanel->hide(); m_stationStatsLabel->hide(); + m_entitySummaryLabel->hide(); } void SelectedBuildingPanel::handleEvent(std::shared_ptr event) @@ -1066,34 +1178,14 @@ void SelectedBuildingPanel::handleEvent( m_selectedScrap = event->scrap; if (!m_selectedScrap.empty()) { - // Scrap is its own selection category, mutually exclusive with buildings and - // entities (REQ-UI-SCRAP-CLICK-SELECT). + // Scrap is a field object: it supersedes any building selection but coexists + // with actors (REQ-UI-SELECTION-CATEGORIES). m_selectedBuildingIds.clear(); - clearContent(); - clearEntityDisplay(); - buildScrap(); - } - else - { - m_scrapLabel->hide(); - if (m_selectedBuildingIds.empty() && !m_selectedEntity.has_value()) - { - buildEmpty(); - } } + buildFieldSelection(); } -void SelectedBuildingPanel::buildScrap() -{ - clearContent(); - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - refreshScrapTotal(); - m_scrapLabel->show(); -} - -void SelectedBuildingPanel::refreshScrapTotal() +QString SelectedBuildingPanel::scrapTotalText() const { // Sum the remaining amounts of the still-living selected piles (REQ-UI-SCRAP-PANEL). int total = 0; @@ -1105,7 +1197,12 @@ void SelectedBuildingPanel::refreshScrapTotal() total += info.amount; } } - m_scrapLabel->setText(tr("Scrap: %1").arg(total)); + return tr("Scrap x %1").arg(total); +} + +void SelectedBuildingPanel::refreshScrapTotal() +{ + m_scrapLabel->setText(scrapTotalText()); } void SelectedBuildingPanel::handleEvent(std::shared_ptr event) diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index 2717c0a..71052db 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -13,7 +13,7 @@ #include "Building.h" #include "BuildingId.h" #include "DebugDrawToggledEvent.h" -#include "EntitySelectedEvent.h" +#include "EntitySelectionChangedEvent.h" #include "EventHandler.h" #include "GameConfig.h" #include "PlayerCommandsAppliedEvent.h" @@ -36,7 +36,7 @@ class QVBoxLayout; class SelectedBuildingPanel : public QWidget, public CombinedEventHandler @@ -51,7 +51,7 @@ public: private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; - void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; @@ -80,8 +80,9 @@ private: void buildEmpty(); void buildSingle(BuildingId id); void buildMulti(const std::vector& ids); - void buildScrap(); void refreshScrapTotal(); + // "Scrap: N" for the summed remaining amount of the selected piles (REQ-UI-SCRAP-PANEL). + QString scrapTotalText() const; void refreshBuffers(const Building* b); void refreshSiteProgress(const ConstructionSite* s); void updateShipyardLayoutWidgets(BuildingType type, @@ -115,16 +116,24 @@ private: std::string m_currentRecipeId; bool m_debugDraw = false; - std::optional m_selectedEntity; + // The selected ships/defence stations. Shares the "field" selection category with + // scrap (m_selectedScrap): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES). + std::vector m_selectedEntities; ShipStatsPanel* m_entityStatsPanel; QLabel* m_entityTitleLabel; QLabel* m_stationStatsLabel; + QLabel* m_entitySummaryLabel; std::vector m_selectedScrap; QLabel* m_scrapLabel; + // Renders the combined field selection (actors + scrap): a single-actor stats panel + // or a multi-actor summary, plus the scrap total when scrap is also selected + // (REQ-UI-FIELD-MULTI-SELECTION). + void buildFieldSelection(); void buildEntityShip(entt::entity entity); void buildEntityStation(entt::entity entity); + void buildEntitySummary(); void refreshEntityStats(); void clearEntityDisplay(); };