diff --git a/docs/requirements.md b/docs/requirements.md index 94c2797..9d0bdf5 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -459,10 +459,10 @@ The screen is divided into two columns: a main column (75% width) containing the ### Selected Building Panel -- REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty. +- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or scrap pile), the panel is empty. - 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. +- 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-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). @@ -470,9 +470,12 @@ 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 empty world space (no building, ship, or defence station) clears the selection. +- 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-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). ### Build Button Grid diff --git a/src/lib/ecs/system/ScrapSystem.cpp b/src/lib/ecs/system/ScrapSystem.cpp index 0a31713..f6c1463 100644 --- a/src/lib/ecs/system/ScrapSystem.cpp +++ b/src/lib/ecs/system/ScrapSystem.cpp @@ -69,9 +69,9 @@ std::vector ScrapSystem::allScrapInfo() const { std::vector result; m_admin.forEach( - [&result, this](entt::entity e, const ScrapDataComponent& /*sd*/) + [&result, this](entt::entity e, const ScrapDataComponent& sd) { - result.push_back(ScrapInfo{e, m_admin.get(e).value}); + result.push_back(ScrapInfo{e, m_admin.get(e).value, sd.amount}); }); return result; } diff --git a/src/lib/ecs/system/ScrapSystem.h b/src/lib/ecs/system/ScrapSystem.h index da91236..4f75b9e 100644 --- a/src/lib/ecs/system/ScrapSystem.h +++ b/src/lib/ecs/system/ScrapSystem.h @@ -15,6 +15,7 @@ struct ScrapInfo { entt::entity entity; QVector2D position; + int amount; }; class ScrapSystem diff --git a/src/lib/eventsystem/event/ScrapSelectionChangedEvent.h b/src/lib/eventsystem/event/ScrapSelectionChangedEvent.h new file mode 100644 index 0000000..36bc7fd --- /dev/null +++ b/src/lib/eventsystem/event/ScrapSelectionChangedEvent.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include "entt/entity/entity.hpp" + +#include "Event.h" + +// The set of currently selected scrap piles (REQ-UI-SCRAP-CLICK-SELECT, +// REQ-UI-SCRAP-MULTI-SELECT). An empty list means no scrap is selected. Scrap forms +// its own selection category, mutually exclusive with buildings and entities. +class ScrapSelectionChangedEvent : public Event +{ +public: + explicit ScrapSelectionChangedEvent(std::vector scrap) + : scrap(std::move(scrap)) {} + const std::vector scrap; +}; diff --git a/src/lib/sim/EntityHitTest.cpp b/src/lib/sim/EntityHitTest.cpp index da84e28..948dbdf 100644 --- a/src/lib/sim/EntityHitTest.cpp +++ b/src/lib/sim/EntityHitTest.cpp @@ -1,9 +1,11 @@ #include "EntityHitTest.h" +#include #include #include "EntityAdmin.h" #include "PositionComponent.h" +#include "ScrapDataComponent.h" #include "StationBodyComponent.h" #include "HealthComponent.h" @@ -54,3 +56,48 @@ entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos) return bestShip; } + +entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos) +{ + // Slightly larger than the scrap's rendered radius (0.2 tiles) so small piles + // remain easy to click; tunable. + constexpr float kScrapHitRadiusSquared = 0.35f * 0.35f; + entt::entity bestScrap = entt::null; + float bestDistSquared = kScrapHitRadiusSquared; + + admin.forEach( + [&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos) + { + const float dx = pos.value.x() - worldPos.x(); + const float dy = pos.value.y() - worldPos.y(); + const float distSquared = dx * dx + dy * dy; + if (distSquared < bestDistSquared) + { + bestDistSquared = distSquared; + bestScrap = entity; + } + }); + + return bestScrap; +} + +std::vector scrapInBox(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; + admin.forEach( + [&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos) + { + 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 716611d..5ca4f3f 100644 --- a/src/lib/sim/EntityHitTest.h +++ b/src/lib/sim/EntityHitTest.h @@ -1,5 +1,8 @@ #pragma once +#include + +#include #include #include "entt/entity/entity.hpp" @@ -7,3 +10,12 @@ class EntityAdmin; entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos); + +// Returns the nearest scrap pile whose center is within the scrap pick radius of +// worldPos, or entt::null if none (REQ-UI-SCRAP-CLICK-SELECT). Scrap is picked only +// after actors: entityAtWorldPos never returns scrap (scrap has no HealthComponent). +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); diff --git a/src/test/ScrapTest.cpp b/src/test/ScrapTest.cpp index fce5d47..dcc4f14 100644 --- a/src/test/ScrapTest.cpp +++ b/src/test/ScrapTest.cpp @@ -2,11 +2,22 @@ #include +#include + #include "DespawnAtComponent.h" #include "EntityAdmin.h" +#include "EntityHitTest.h" #include "ScrapDataComponent.h" #include "ScrapSystem.h" +namespace +{ +bool contains(const std::vector& v, entt::entity e) +{ + return std::find(v.begin(), v.end(), e) != v.end(); +} +} // namespace + // --------------------------------------------------------------------------- // Spawn // --------------------------------------------------------------------------- @@ -140,3 +151,78 @@ TEST_CASE("ScrapSystem: allScrapInfo returns all spawned scrap", "[scrap]") const std::vector info = ss.allScrapInfo(); REQUIRE(info.size() == 2); } + +TEST_CASE("ScrapSystem: allScrapInfo reports each pile's remaining amount", "[scrap]") +{ + EntityAdmin admin; + ScrapSystem ss(admin); + + const entt::entity a = ss.spawn(QVector2D(1.0f, 2.0f), 3, 100); + const entt::entity b = ss.spawn(QVector2D(4.0f, 5.0f), 6, 200); + + const std::vector info = ss.allScrapInfo(); + REQUIRE(info.size() == 2); + for (const ScrapInfo& i : info) + { + if (i.entity == a) { REQUIRE(i.amount == 3); } + else if (i.entity == b) { REQUIRE(i.amount == 6); } + else { FAIL("unexpected scrap entity"); } + } +} + +// --------------------------------------------------------------------------- +// Selection hit-testing (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT) +// --------------------------------------------------------------------------- + +TEST_CASE("scrapAtWorldPos returns the pile near a point and null when far", "[scrap]") +{ + EntityAdmin admin; + ScrapSystem ss(admin); + + const entt::entity e = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100); + + // Extra parens keep Catch from decomposing the comparison, which is ambiguous + // between Catch's expression templates and entt's entity operator==. + REQUIRE((scrapAtWorldPos(admin, QVector2D(3.1f, 4.0f)) == e)); + REQUIRE((scrapAtWorldPos(admin, QVector2D(10.0f, 10.0f)) == entt::null)); +} + +TEST_CASE("scrapAtWorldPos returns the nearest of several piles", "[scrap]") +{ + EntityAdmin admin; + ScrapSystem ss(admin); + + const entt::entity near = ss.spawn(QVector2D(2.0f, 2.0f), 1, 100); + ss.spawn(QVector2D(2.4f, 2.0f), 1, 100); + + REQUIRE((scrapAtWorldPos(admin, QVector2D(2.05f, 2.0f)) == near)); +} + +TEST_CASE("entityAtWorldPos never returns a scrap pile", "[scrap]") +{ + EntityAdmin admin; + ScrapSystem ss(admin); + + ss.spawn(QVector2D(3.0f, 4.0f), 5, 100); + + // Scrap has no HealthComponent, so the actor hit-test ignores it entirely. + REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null)); +} + +TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[scrap]") +{ + EntityAdmin admin; + ScrapSystem ss(admin); + + const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100); // tile (1,2) + const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100); // tile (4,5) + const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100); + + // Box given in reversed corner order to confirm normalization. + const std::vector hit = scrapInBox(admin, QPoint(5, 5), QPoint(0, 0)); + + REQUIRE(hit.size() == 2); + REQUIRE(contains(hit, inA)); + REQUIRE(contains(hit, inB)); + REQUIRE_FALSE(contains(hit, outX)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index c424eeb..d681bad 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -43,6 +43,7 @@ #include "PositionComponent.h" #include "RepairBehavior.h" #include "SalvageScrapBehavior.h" +#include "ScrapSelectionChangedEvent.h" #include "ScrapSystem.h" #include "SelectionChangedEvent.h" #include "SensorRangeComponent.h" @@ -271,6 +272,10 @@ void GameWorldView::onFrame() m_activeBeams = std::move(live); } + // 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(); + // 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 // while the game is paused (REQ-BLD-COPY-CONFIG-FEEDBACK). @@ -666,6 +671,36 @@ std::optional GameWorldView::entityPosition(entt::entity entity) cons return m_sim->admin().get(entity).value; } +void GameWorldView::clearScrapSelection() +{ + if (m_selectedScrap.empty()) { return; } + m_selectedScrap.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedScrap)); +} + +void GameWorldView::pruneDespawnedScrap() +{ + if (m_selectedScrap.empty()) { return; } + + std::vector live; + for (const ScrapInfo& info : m_sim->scraps().allScrapInfo()) + { + if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), info.entity) + != m_selectedScrap.end()) + { + live.push_back(info.entity); + } + } + + if (live.size() != m_selectedScrap.size()) + { + m_selectedScrap = std::move(live); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedScrap)); + } +} + void GameWorldView::stepSpeed(int delta) { const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; @@ -1048,6 +1083,19 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter) // Outline sits 1px outside the footprint (into adjacent tiles). painter.drawRect(rect->adjusted(-1, -1, 1, 1)); } + + // A ring around each selected scrap pile, sitting just outside the pile's + // rendered circle (radius tilePx()*0.2, matching drawScrap) (REQ-UI-SCRAP-CLICK-SELECT). + if (!m_selectedScrap.empty()) + { + const qreal outlineRadius = static_cast(tilePx() * 0.2f) + 3.0; + for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo()) + { + if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), scrap.entity) + == m_selectedScrap.end()) { continue; } + painter.drawEllipse(worldToWidget(scrap.position), outlineRadius, outlineRadius); + } + } } void GameWorldView::drawCopyConfigFeedback(QPainter& painter) @@ -1752,6 +1800,8 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) if (hitEntity != entt::null) { + // Actors (ship/station) win over scrap and buildings (REQ-UI-SCRAP-CLICK-SELECT). + clearScrapSelection(); m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); @@ -1775,6 +1825,8 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) } if (id != kInvalidBuildingId) { + // A building/construction site outranks scrap (REQ-UI-SCRAP-CLICK-SELECT). + clearScrapSelection(); if (event->modifiers() & Qt::ControlModifier) { bool found = false; @@ -1794,6 +1846,36 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); } + else if (const entt::entity scrapHit = + scrapAtWorldPos(m_sim->admin(), 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)); + } else { if (!(event->modifiers() & Qt::ControlModifier)) @@ -1801,6 +1883,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); + clearScrapSelection(); } m_boxSelecting = true; m_boxStartTile = tile; @@ -1873,24 +1956,75 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) return; } - if (!(event->modifiers() & Qt::ControlModifier)) + const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0; + + if (!boxIds.empty()) { - m_selectedBuildingIds = boxIds; - } - else - { - for (BuildingId id : boxIds) + // A box covering any building selects buildings; scrap in the box is + // ignored (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT). + clearScrapSelection(); + if (!ctrl) { - bool found = false; - for (BuildingId sel : m_selectedBuildingIds) - { - if (sel == id) { found = true; break; } - } - if (!found) { m_selectedBuildingIds.push_back(id); } + m_selectedBuildingIds = boxIds; } + else + { + for (BuildingId id : boxIds) + { + bool found = false; + for (BuildingId sel : m_selectedBuildingIds) + { + if (sel == id) { found = true; break; } + } + if (!found) { m_selectedBuildingIds.push_back(id); } + } + } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + return; + } + + // No buildings in the box: a scrap-only box selects the scrap it covers + // (REQ-UI-SCRAP-MULTI-SELECT). + const std::vector boxScrap = + scrapInBox(m_sim->admin(), m_boxStartTile, m_boxCurrentTile); + if (!boxScrap.empty()) + { + if (!m_selectedBuildingIds.empty()) + { + m_selectedBuildingIds.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + } + if (!ctrl) + { + m_selectedScrap = boxScrap; + } + else + { + for (entt::entity e : boxScrap) + { + bool found = false; + for (entt::entity sel : m_selectedScrap) + { + if (sel == e) { found = true; break; } + } + if (!found) { m_selectedScrap.push_back(e); } + } + } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedScrap)); + return; + } + + // Empty box: a plain (non-additive) drag clears the current selection. + if (!ctrl) + { + m_selectedBuildingIds.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedBuildingIds)); + clearScrapSelection(); } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); } } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 5e84f04..ead933c 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -168,6 +168,13 @@ private: void placeBlueprintAtTile(QPoint center); std::optional entityPosition(entt::entity entity) const; + // Clears the scrap selection, emitting an empty ScrapSelectionChangedEvent when + // it was non-empty (REQ-UI-SCRAP-CLICK-SELECT). Used when another selection + // category takes over. + void clearScrapSelection(); + // 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(); void stepSpeed(int delta); void placeAtTile(QPoint tile); @@ -249,6 +256,7 @@ private: std::vector m_selectedBuildingIds; std::optional m_selectedEntity; + std::vector m_selectedScrap; bool m_boxSelecting; QPoint m_boxStartTile; QPoint m_boxCurrentTile; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 926b8f8..1d6ca72 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -37,6 +37,7 @@ #include "RecipeSelectionDialog.h" #include "RecipeSelectionRequestedEvent.h" #include "Rotation.h" +#include "ScrapSystem.h" #include "ShipLayoutPreview.h" #include "Simulation.h" #include "WeaponComponent.h" @@ -190,6 +191,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, m_layout->addWidget(m_stationStatsLabel); m_stationStatsLabel->hide(); + m_scrapLabel = new QLabel(this); + m_layout->addWidget(m_scrapLabel); + m_scrapLabel->hide(); + buildEmpty(); registerForEvents(); @@ -206,6 +211,9 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector& id if (!ids.empty()) { clearEntityDisplay(); + // A building selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT). + m_selectedScrap.clear(); + m_scrapLabel->hide(); } rebuild(); } @@ -238,6 +246,7 @@ void SelectedBuildingPanel::hideAllWidgets() m_filterBLabel->hide(); m_filterBList->hide(); m_buffersLabel->hide(); + m_scrapLabel->hide(); } void SelectedBuildingPanel::clearContent() @@ -638,6 +647,13 @@ void SelectedBuildingPanel::handleEvent( void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) { + if (!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(); @@ -885,6 +901,9 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptrentity; m_selectedBuildingIds.clear(); + // An entity selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT). + m_selectedScrap.clear(); + m_scrapLabel->hide(); clearContent(); EntityAdmin& admin = m_sim->admin(); @@ -1022,6 +1041,54 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptrids); } +void SelectedBuildingPanel::handleEvent( + std::shared_ptr event) +{ + 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). + m_selectedBuildingIds.clear(); + clearContent(); + clearEntityDisplay(); + buildScrap(); + } + else + { + m_scrapLabel->hide(); + if (m_selectedBuildingIds.empty() && !m_selectedEntity.has_value()) + { + buildEmpty(); + } + } +} + +void SelectedBuildingPanel::buildScrap() +{ + clearContent(); + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + refreshScrapTotal(); + m_scrapLabel->show(); +} + +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->scraps().allScrapInfo()) + { + 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 event) { m_debugDraw = event->active; diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index 1aac58a..342da51 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -18,6 +18,7 @@ #include "GameConfig.h" #include "PlayerCommandsAppliedEvent.h" #include "RecipesConfig.h" +#include "ScrapSelectionChangedEvent.h" #include "SelectionChangedEvent.h" #include "ShipLayout.h" #include "ShipsConfig.h" @@ -37,6 +38,7 @@ class SelectedBuildingPanel : public QWidget, PlayerCommandsAppliedEvent, EntitySelectedEvent, SelectionChangedEvent, + ScrapSelectionChangedEvent, DebugDrawToggledEvent> { Q_OBJECT @@ -51,6 +53,7 @@ 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; private slots: @@ -77,6 +80,8 @@ private: void buildEmpty(); void buildSingle(BuildingId id); void buildMulti(const std::vector& ids); + void buildScrap(); + void refreshScrapTotal(); void refreshBuffers(const Building* b); void refreshSiteProgress(const ConstructionSite* s); void updateShipyardLayoutWidgets(BuildingType type, @@ -115,6 +120,9 @@ private: QLabel* m_entityTitleLabel; QLabel* m_stationStatsLabel; + std::vector m_selectedScrap; + QLabel* m_scrapLabel; + void buildEntityShip(entt::entity entity); void buildEntityStation(entt::entity entity); void refreshEntityStats();