Implement scrap pile selection

Scrap piles become a third selection category: click to select (actors
win on overlap), box-drag / Ctrl+click multi-select, and the selected-
object panel shows the summed remaining amount, updating live and
dropping piles as they are collected or despawn.

- ScrapSystem: expose per-pile amount via ScrapInfo.
- EntityHitTest: add scrapAtWorldPos and scrapInBox (with tests).
- New header-only ScrapSelectionChangedEvent.
- GameWorldView: scrap selection member, click/box handling with the
  building-wins disambiguation, and per-frame pruning of gone piles.
- SelectedBuildingPanel: scrap event, "Scrap: N" label, live total;
  scrap and building/entity selections clear each other.

Implements REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT,
REQ-UI-SCRAP-PANEL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
2026-07-13 20:58:28 +02:00
parent 1c2f57e364
commit b97ab1edaa
10 changed files with 384 additions and 16 deletions

View File

@@ -69,9 +69,9 @@ std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{
std::vector<ScrapInfo> result;
m_admin.forEach<ScrapDataComponent>(
[&result, this](entt::entity e, const ScrapDataComponent& /*sd*/)
[&result, this](entt::entity e, const ScrapDataComponent& sd)
{
result.push_back(ScrapInfo{e, m_admin.get<PositionComponent>(e).value});
result.push_back(ScrapInfo{e, m_admin.get<PositionComponent>(e).value, sd.amount});
});
return result;
}

View File

@@ -15,6 +15,7 @@ struct ScrapInfo
{
entt::entity entity;
QVector2D position;
int amount;
};
class ScrapSystem

View File

@@ -0,0 +1,18 @@
#pragma once
#include <vector>
#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<entt::entity> scrap)
: scrap(std::move(scrap)) {}
const std::vector<entt::entity> scrap;
};

View File

@@ -1,9 +1,11 @@
#include "EntityHitTest.h"
#include <algorithm>
#include <cmath>
#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<ScrapDataComponent, PositionComponent>(
[&](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<entt::entity> 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<entt::entity> result;
admin.forEach<ScrapDataComponent, PositionComponent>(
[&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos)
{
const int tileX = static_cast<int>(std::floor(pos.value.x()));
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
});
return result;
}

View File

@@ -1,5 +1,8 @@
#pragma once
#include <vector>
#include <QPoint>
#include <QVector2D>
#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<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

@@ -2,11 +2,22 @@
#include <QVector2D>
#include <algorithm>
#include "DespawnAtComponent.h"
#include "EntityAdmin.h"
#include "EntityHitTest.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
namespace
{
bool contains(const std::vector<entt::entity>& 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<ScrapInfo> 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<ScrapInfo> 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<entt::entity> 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));
}

View File

@@ -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<QVector2D> GameWorldView::entityPosition(entt::entity entity) cons
return m_sim->admin().get<PositionComponent>(entity).value;
}
void GameWorldView::clearScrapSelection()
{
if (m_selectedScrap.empty()) { return; }
m_selectedScrap.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
}
void GameWorldView::pruneDespawnedScrap()
{
if (m_selectedScrap.empty()) { return; }
std::vector<entt::entity> 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<ScrapSelectionChangedEvent>(m_selectedScrap));
}
}
void GameWorldView::stepSpeed(int delta)
{
const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
@@ -1752,6 +1787,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<SelectionChangedEvent>(m_selectedBuildingIds));
@@ -1775,6 +1812,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 +1833,36 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(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<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (event->modifiers() & Qt::ControlModifier)
{
bool found = false;
std::vector<entt::entity> 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<ScrapSelectionChangedEvent>(m_selectedScrap));
}
else
{
if (!(event->modifiers() & Qt::ControlModifier))
@@ -1801,6 +1870,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
clearScrapSelection();
}
m_boxSelecting = true;
m_boxStartTile = tile;
@@ -1873,24 +1943,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<SelectionChangedEvent>(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<entt::entity> 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<SelectionChangedEvent>(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<ScrapSelectionChangedEvent>(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<SelectionChangedEvent>(m_selectedBuildingIds));
clearScrapSelection();
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
}
}

View File

@@ -168,6 +168,13 @@ private:
void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> 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<BuildingId> m_selectedBuildingIds;
std::optional<entt::entity> m_selectedEntity;
std::vector<entt::entity> m_selectedScrap;
bool m_boxSelecting;
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;

View File

@@ -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<BuildingId>& 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_ptr<const EntitySelectedEven
{
m_selectedEntity = event->entity;
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_ptr<const SelectionChangedEv
onSelectionChanged(event->ids);
}
void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const ScrapSelectionChangedEvent> 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<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;

View File

@@ -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<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const ScrapSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
private slots:
@@ -77,6 +80,8 @@ private:
void buildEmpty();
void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& 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<entt::entity> m_selectedScrap;
QLabel* m_scrapLabel;
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void refreshEntityStats();