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

@@ -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));
}
}