allow multi-select for ships/stations, mixable with scrap

This commit is contained in:
2026-07-20 20:39:48 +02:00
parent 9622fa4345
commit be475e2836
15 changed files with 509 additions and 206 deletions

View File

@@ -483,9 +483,10 @@ The screen is divided into two columns: a main column (75% width) containing the
### Selected Building Panel ### 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-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-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-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-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-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). - 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 **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 <ship display name>". - 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 <ship display name>".
- 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-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-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 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-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: - 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). - **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). - **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). - **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). - **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). - **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-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-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-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 "<type> x <count>" (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-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-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-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-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 ### Build Button Grid

View File

@@ -15,7 +15,7 @@
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "EntityHitTest.h" #include "EntityHitTest.h"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "FacingComponent.h" #include "FacingComponent.h"
#include "FactionComponent.h" #include "FactionComponent.h"
@@ -259,8 +259,14 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
m_selectedEntity = std::nullopt; m_selectedEntity = std::nullopt;
} }
// The arena is strictly single-select; emit a vector of size 0 or 1.
std::vector<entt::entity> selection;
if (m_selectedEntity.has_value())
{
selection.push_back(*m_selectedEntity);
}
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(m_selectedEntity)); std::make_shared<EntitySelectionChangedEvent>(selection));
} }
QOpenGLWidget::mousePressEvent(event); QOpenGLWidget::mousePressEvent(event);

View File

@@ -13,7 +13,7 @@
#include "BeamFiredEvent.h" #include "BeamFiredEvent.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "Tick.h" #include "Tick.h"
#include "TickDriver.h" #include "TickDriver.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"

View File

@@ -250,11 +250,12 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
} }
} }
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event) void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> 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(); EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity; entt::entity entity = *m_selectedEntity;

View File

@@ -12,7 +12,7 @@
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "ArenaSimulation.h" #include "ArenaSimulation.h"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
@@ -22,7 +22,7 @@ class ArenaView;
class ShipStatsPanel; class ShipStatsPanel;
class InspectWindow : public QWidget, class InspectWindow : public QWidget,
public CombinedEventHandler<EntitySelectedEvent, public CombinedEventHandler<EntitySelectionChangedEvent,
GameSpeedChangedEvent> GameSpeedChangedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -38,7 +38,7 @@ protected:
void keyPressEvent(QKeyEvent* event) override; void keyPressEvent(QKeyEvent* event) override;
private: private:
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override; void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
private slots: private slots:

View File

@@ -4,7 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.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}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h

View File

@@ -1,21 +0,0 @@
#ifndef ENTITY_SELECTED_EVENT_H
#define ENTITY_SELECTED_EVENT_H
#include <optional>
#include "entt/entity/entity.hpp"
#include "Event.h"
class EntitySelectedEvent : public Event
{
public:
explicit EntitySelectedEvent(std::optional<entt::entity> entity)
: entity(entity)
{
}
const std::optional<entt::entity> entity;
};
#endif // ENTITY_SELECTED_EVENT_H

View File

@@ -0,0 +1,25 @@
#ifndef ENTITY_SELECTION_CHANGED_EVENT_H
#define ENTITY_SELECTION_CHANGED_EVENT_H
#include <vector>
#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<entt::entity> entities)
: entities(std::move(entities))
{
}
const std::vector<entt::entity> entities;
};
#endif // ENTITY_SELECTION_CHANGED_EVENT_H

View File

@@ -6,6 +6,7 @@
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "ScrapDataComponent.h" #include "ScrapDataComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
@@ -101,3 +102,46 @@ std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint ti
}); });
return result; return result;
} }
std::vector<entt::entity> 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<entt::entity> result;
// Stations: included when any occupied body cell lies in the box.
admin.forEach<StationBodyComponent, HealthComponent>(
[&](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<ShipIdentityComponent, PositionComponent, HealthComponent>(
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
const PositionComponent& pos, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
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

@@ -19,3 +19,10 @@ entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns every scrap pile whose position falls within the inclusive tile rectangle // 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). // 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); std::vector<entt::entity> 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<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

@@ -1,5 +1,6 @@
#include "catch.hpp" #include "catch.hpp"
#include <QSize>
#include <QVector2D> #include <QVector2D>
#include <algorithm> #include <algorithm>
@@ -226,3 +227,45 @@ TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[sc
REQUIRE(contains(hit, inB)); REQUIRE(contains(hit, inB));
REQUIRE_FALSE(contains(hit, outX)); 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<QPoint> 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<entt::entity> 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));
}

View File

@@ -37,7 +37,7 @@
#include "ReplayRecorder.h" #include "ReplayRecorder.h"
#include "DemolishModeChangedEvent.h" #include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h" #include "EntityHitTest.h"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "FacingComponent.h" #include "FacingComponent.h"
#include "FactionComponent.h" #include "FactionComponent.h"
@@ -284,6 +284,7 @@ void GameWorldView::onFrame()
// Drop selected scrap piles that were collected or despawned this frame, so the // 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). // panel stops counting them and the selection empties out (REQ-UI-SCRAP-CLICK-SELECT).
pruneDespawnedScrap(); pruneDespawnedScrap();
pruneDespawnedActors();
// Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the // 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 // 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<EntitySelectionChangedEvent>(m_selectedEntities));
}
void GameWorldView::pruneDespawnedActors()
{
if (m_selectedEntities.empty()) { return; }
EntityAdmin& admin = m_sim->getAdmin();
std::vector<entt::entity> live;
for (entt::entity e : m_selectedEntities)
{
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
&& admin.get<HealthComponent>(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<EntitySelectionChangedEvent>(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) void GameWorldView::stepSpeed(int delta)
{ {
const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; 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.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect); painter.drawRect(bboxRect);
if (m_selectedEntity.has_value() && *m_selectedEntity == e) if (isEntitySelected(e))
{ {
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
@@ -1352,7 +1390,7 @@ void GameWorldView::drawShips(QPainter& painter)
painter.setBrush(it->second.fill); painter.setBrush(it->second.fill);
painter.drawPolygon(tri); painter.drawPolygon(tri);
if (m_selectedEntity.has_value() && *m_selectedEntity == e) if (isEntitySelected(e))
{ {
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush); 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 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<BuildingId> 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(); clearScrapSelection();
m_selectedBuildingIds.clear(); if (ctrl)
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
m_selectedEntity = hitEntity;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(hitEntity));
}
else
{
if (m_selectedEntity.has_value())
{ {
m_selectedEntity = std::nullopt; bool found = false;
EventManager::getInstance()->sendEventImmediately( std::vector<BuildingId> newSel;
std::make_shared<EntitySelectedEvent>(std::nullopt)); for (BuildingId sel : m_selectedBuildingIds)
}
std::optional<BuildingId> 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; if (sel == id) { found = true; }
std::vector<BuildingId> newSel; else { newSel.push_back(sel); }
for (BuildingId sel : m_selectedBuildingIds)
{
if (sel == id) { found = true; }
else { newSel.push_back(sel); }
}
if (!found) { newSel.push_back(id); }
m_selectedBuildingIds = newSel;
} }
else if (!found) { newSel.push_back(id); }
{ m_selectedBuildingIds = newSel;
m_selectedBuildingIds = { id };
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(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<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 else
{ {
if (!(event->modifiers() & Qt::ControlModifier)) m_selectedBuildingIds = { id };
{
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
clearScrapSelection();
}
m_boxSelecting = true;
m_boxStartTile = tile;
m_boxCurrentTile = tile;
} }
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(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<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (ctrl)
{
// Toggle this actor within the field selection, leaving scrap intact
// (REQ-UI-ENTITY-CLICK-SELECT).
bool found = false;
std::vector<entt::entity> 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<EntitySelectionChangedEvent>(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<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (ctrl)
{
// Toggle this pile within the field selection, leaving actors intact
// (REQ-UI-SCRAP-MULTI-SELECT).
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
{
// A plain click makes this pile the sole selection.
m_selectedScrap = { scrapHit };
clearEntitySelection();
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ScrapSelectionChangedEvent>(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<SelectionChangedEvent>(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()) if (!boxIds.empty())
{ {
// A box covering any building selects buildings; scrap in the box is // A box covering any building selects buildings; field objects (actors and
// ignored (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT). // scrap) in the box are ignored — buildings win (REQ-UI-MULTI-SELECT).
clearEntitySelection();
clearScrapSelection(); clearScrapSelection();
if (!ctrl) if (!ctrl)
{ {
@@ -2230,11 +2295,13 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
return; return;
} }
// No buildings in the box: a scrap-only box selects the scrap it covers // No buildings in the box: select the field objects it covers — ships, defence
// (REQ-UI-SCRAP-MULTI-SELECT). // stations, and scrap together (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
const std::vector<entt::entity> boxActors =
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> boxScrap = const std::vector<entt::entity> boxScrap =
scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
if (!boxScrap.empty()) if (!boxActors.empty() || !boxScrap.empty())
{ {
if (!m_selectedBuildingIds.empty()) if (!m_selectedBuildingIds.empty())
{ {
@@ -2244,10 +2311,20 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
} }
if (!ctrl) if (!ctrl)
{ {
m_selectedScrap = boxScrap; m_selectedEntities = boxActors;
m_selectedScrap = boxScrap;
} }
else 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) for (entt::entity e : boxScrap)
{ {
bool found = false; bool found = false;
@@ -2258,6 +2335,8 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
if (!found) { m_selectedScrap.push_back(e); } if (!found) { m_selectedScrap.push_back(e); }
} }
} }
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap)); std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
return; return;
@@ -2269,6 +2348,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds)); std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
clearEntitySelection();
clearScrapSelection(); clearScrapSelection();
} }
} }
@@ -2483,6 +2563,8 @@ void GameWorldView::resetForNewGame()
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(false)); std::make_shared<DemolishModeChangedEvent>(false));
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
clearEntitySelection();
clearScrapSelection();
m_copiedConfig = std::nullopt; m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear(); m_copyConfigFlashes.clear();
m_boxSelecting = false; m_boxSelecting = false;

View File

@@ -36,7 +36,7 @@
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "CommandManager.h" #include "CommandManager.h"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
@@ -193,6 +193,14 @@ private:
// Drops despawned or fully-collected piles from the scrap selection and re-emits // 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(). // when it changed (REQ-UI-SCRAP-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedScrap(); 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 stepSpeed(int delta);
void placeAtTile(QPoint tile); void placeAtTile(QPoint tile);
@@ -274,7 +282,7 @@ private:
bool m_debugDraw; bool m_debugDraw;
std::vector<BuildingId> m_selectedBuildingIds; std::vector<BuildingId> m_selectedBuildingIds;
std::optional<entt::entity> m_selectedEntity; std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedScrap; std::vector<entt::entity> m_selectedScrap;
bool m_boxSelecting; bool m_boxSelecting;
QPoint m_boxStartTile; QPoint m_boxStartTile;

View File

@@ -9,14 +9,16 @@
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QPushButton> #include <QPushButton>
#include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Command.h" #include "Command.h"
#include "CommandRequestedEvent.h" #include "CommandRequestedEvent.h"
#include "DisplayName.h"
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
@@ -196,6 +198,11 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
m_layout->addWidget(m_stationStatsLabel); m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide(); 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_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel); m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide(); m_scrapLabel->hide();
@@ -215,8 +222,9 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
m_selectedBuildingIds = ids; m_selectedBuildingIds = ids;
if (!ids.empty()) if (!ids.empty())
{ {
// A building selection is exclusive: it supersedes any field selection —
// actors and scrap (REQ-UI-SELECTION-CATEGORIES).
clearEntityDisplay(); clearEntityDisplay();
// A building selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
m_selectedScrap.clear(); m_selectedScrap.clear();
m_scrapLabel->hide(); m_scrapLabel->hide();
} }
@@ -266,6 +274,7 @@ void SelectedBuildingPanel::buildEmpty()
m_entityTitleLabel->hide(); m_entityTitleLabel->hide();
m_entityStatsPanel->hide(); m_entityStatsPanel->hide();
m_stationStatsLabel->hide(); m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
} }
void SelectedBuildingPanel::buildSingle(BuildingId id) void SelectedBuildingPanel::buildSingle(BuildingId id)
@@ -658,16 +667,24 @@ void SelectedBuildingPanel::handleEvent(
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) 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). // Field selection. Keep the live values current: the single-actor stats panel,
refreshScrapTotal(); // the standalone scrap total, or the count summary (whose scrap line shrinks as
return; // 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())
if (m_selectedEntity.has_value()) {
{ refreshEntityStats();
refreshEntityStats(); }
else if (m_selectedEntities.empty())
{
refreshScrapTotal();
}
else
{
buildEntitySummary();
}
return; return;
} }
@@ -737,7 +754,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
QString text; QString text;
for (const std::pair<const BuildingType, int>& entry : counts) for (const std::pair<const BuildingType, int>& entry : counts)
{ {
text += buildingTypeName(entry.first) + ": " text += buildingTypeName(entry.first) + " x "
+ QString::number(entry.second) + "\n"; + QString::number(entry.second) + "\n";
if (isBeltLike(entry.first)) if (isBeltLike(entry.first))
{ {
@@ -906,39 +923,139 @@ void SelectedBuildingPanel::onClearBelt()
} }
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectedEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> 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(); 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(); m_scrapLabel->hide();
clearContent(); if (m_selectedBuildingIds.empty())
EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
{ {
clearEntityDisplay(); buildEmpty();
return;
} }
return;
}
if (admin.hasAll<ShipIdentityComponent>(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<ShipIdentityComponent>(entity))
{ {
buildEntityShip(entity); buildEntityShip(entity);
} }
else if (admin.hasAll<StationBodyComponent>(entity)) else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{ {
buildEntityStation(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<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(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<StationBodyComponent>(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 "<type> x <count>" 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) void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
@@ -1016,23 +1133,17 @@ void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
void SelectedBuildingPanel::refreshEntityStats() 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(); EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity; const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity))
{
clearEntityDisplay();
return;
}
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity); const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f) if (health.hp <= 0.0f) { return; }
{
clearEntityDisplay();
return;
}
if (admin.hasAll<ShipIdentityComponent>(entity)) if (admin.hasAll<ShipIdentityComponent>(entity))
{ {
@@ -1049,10 +1160,11 @@ void SelectedBuildingPanel::refreshEntityStats()
void SelectedBuildingPanel::clearEntityDisplay() void SelectedBuildingPanel::clearEntityDisplay()
{ {
m_selectedEntity = std::nullopt; m_selectedEntities.clear();
m_entityTitleLabel->hide(); m_entityTitleLabel->hide();
m_entityStatsPanel->hide(); m_entityStatsPanel->hide();
m_stationStatsLabel->hide(); m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
@@ -1066,34 +1178,14 @@ void SelectedBuildingPanel::handleEvent(
m_selectedScrap = event->scrap; m_selectedScrap = event->scrap;
if (!m_selectedScrap.empty()) if (!m_selectedScrap.empty())
{ {
// Scrap is its own selection category, mutually exclusive with buildings and // Scrap is a field object: it supersedes any building selection but coexists
// entities (REQ-UI-SCRAP-CLICK-SELECT). // with actors (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
clearContent();
clearEntityDisplay();
buildScrap();
}
else
{
m_scrapLabel->hide();
if (m_selectedBuildingIds.empty() && !m_selectedEntity.has_value())
{
buildEmpty();
}
} }
buildFieldSelection();
} }
void SelectedBuildingPanel::buildScrap() QString SelectedBuildingPanel::scrapTotalText() const
{
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). // Sum the remaining amounts of the still-living selected piles (REQ-UI-SCRAP-PANEL).
int total = 0; int total = 0;
@@ -1105,7 +1197,12 @@ void SelectedBuildingPanel::refreshScrapTotal()
total += info.amount; 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<const DebugDrawToggledEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)

View File

@@ -13,7 +13,7 @@
#include "Building.h" #include "Building.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "DebugDrawToggledEvent.h" #include "DebugDrawToggledEvent.h"
#include "EntitySelectedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h" #include "PlayerCommandsAppliedEvent.h"
@@ -36,7 +36,7 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent, PlayerCommandsAppliedEvent,
EntitySelectedEvent, EntitySelectionChangedEvent,
SelectionChangedEvent, SelectionChangedEvent,
ScrapSelectionChangedEvent, ScrapSelectionChangedEvent,
DebugDrawToggledEvent> DebugDrawToggledEvent>
@@ -51,7 +51,7 @@ public:
private: private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override; void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> 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 ScrapSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override; void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
@@ -80,8 +80,9 @@ private:
void buildEmpty(); void buildEmpty();
void buildSingle(BuildingId id); void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids); void buildMulti(const std::vector<BuildingId>& ids);
void buildScrap();
void refreshScrapTotal(); 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 refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s); void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type, void updateShipyardLayoutWidgets(BuildingType type,
@@ -115,16 +116,24 @@ private:
std::string m_currentRecipeId; std::string m_currentRecipeId;
bool m_debugDraw = false; bool m_debugDraw = false;
std::optional<entt::entity> 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<entt::entity> m_selectedEntities;
ShipStatsPanel* m_entityStatsPanel; ShipStatsPanel* m_entityStatsPanel;
QLabel* m_entityTitleLabel; QLabel* m_entityTitleLabel;
QLabel* m_stationStatsLabel; QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
std::vector<entt::entity> m_selectedScrap; std::vector<entt::entity> m_selectedScrap;
QLabel* m_scrapLabel; 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 buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity); void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void refreshEntityStats(); void refreshEntityStats();
void clearEntityDisplay(); void clearEntityDisplay();
}; };