diff --git a/docs/requirements.md b/docs/requirements.md index 44c90ba..f26ae6b 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -366,6 +366,9 @@ The screen is divided into three vertical sections: - 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. - REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic dropdown (REQ-MOD-UI-PREVIEW). - 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-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 at the ship's actual level with 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. +- 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. ### Build Button Grid diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index 3ee13b4..a886270 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -4,12 +4,16 @@ #include #include +#include #include #include #include "ArenaSimulation.h" #include "Building.h" #include "BuildingSystem.h" +#include "EntityHitTest.h" +#include "EntitySelectedEvent.h" +#include "EventManager.h" #include "FacingComponent.h" #include "FactionComponent.h" #include "HealthComponent.h" @@ -198,6 +202,37 @@ std::optional ArenaView::entityPosition(entt::entity entity) const return m_sim->admin().get(entity).value; } +QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const +{ + const float px = tilePx(); + if (px < 0.001f) { return QVector2D(0.0f, 0.0f); } + return QVector2D(static_cast(widgetPt.x()) / px, + static_cast(widgetPt.y()) / px); +} + +void ArenaView::mousePressEvent(QMouseEvent* event) +{ + if (event->button() == Qt::LeftButton) + { + const QVector2D worldPos = widgetToWorld(event->pos()); + entt::entity hit = entityAtWorldPos(m_sim->admin(), worldPos); + + if (hit != entt::null) + { + m_selectedEntity = hit; + } + else + { + m_selectedEntity = std::nullopt; + } + + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_selectedEntity)); + } + + QOpenGLWidget::mousePressEvent(event); +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- @@ -268,7 +303,7 @@ void ArenaView::drawScrap(QPainter& painter) void ArenaView::drawStations(QPainter& painter) { m_sim->admin().forEach( - [&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h) + [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h) { const BuildingType visType = f.isEnemy ? BuildingType::EnemyDefenceStation @@ -304,6 +339,13 @@ void ArenaView::drawStations(QPainter& painter) painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast(fraction), barH), f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); } + + if (m_selectedEntity.has_value() && *m_selectedEntity == e) + { + painter.setPen(QPen(QColor(255, 255, 0), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2)); + } }); } @@ -311,7 +353,7 @@ void ArenaView::drawShips(QPainter& painter) { m_sim->admin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& si, + [&](entt::entity e, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& facing, const FactionComponent& fac, const HealthComponent& h) { @@ -349,6 +391,14 @@ void ArenaView::drawShips(QPainter& painter) painter.fillRect(QRectF(barX, barY, barW * static_cast(fraction), barH), fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60)); } + + if (m_selectedEntity.has_value() && *m_selectedEntity == e) + { + const qreal radius = static_cast(tilePx()) * 0.55; + painter.setPen(QPen(QColor(255, 255, 0), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(center, radius, radius); + } }); } diff --git a/src/balancing/ArenaView.h b/src/balancing/ArenaView.h index c99da40..f6c598e 100644 --- a/src/balancing/ArenaView.h +++ b/src/balancing/ArenaView.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -11,6 +12,7 @@ #include "FireEvent.h" #include "entt/entity/entity.hpp" +#include "EntitySelectedEvent.h" #include "Tick.h" #include "TickDriver.h" #include "VisualsConfig.h" @@ -37,6 +39,7 @@ signals: protected: void paintGL() override; + void mousePressEvent(QMouseEvent* event) override; private slots: void onFrame(); @@ -55,6 +58,7 @@ private: QRectF tileRect(QPoint tile) const; std::optional entityPosition(entt::entity entity) const; + QVector2D widgetToWorld(QPoint widgetPt) const; struct ActiveBeam { @@ -79,4 +83,6 @@ private: std::vector m_activeBeams; bool m_finishedEmitted; + + std::optional m_selectedEntity; }; diff --git a/src/balancing/BalancingWindow.cpp b/src/balancing/BalancingWindow.cpp index 47602f6..39eac62 100644 --- a/src/balancing/BalancingWindow.cpp +++ b/src/balancing/BalancingWindow.cpp @@ -209,7 +209,7 @@ void BalancingWindow::inspectArena(int index) entry.widget->updateStatus(m_inspectedSim->status()); m_inspectWindow = new InspectWindow( - m_inspectedSim.get(), &m_visuals, entry.config.name, nullptr); + m_inspectedSim.get(), &m_gameConfig, &m_visuals, entry.config.name, nullptr); connect(m_inspectWindow, &InspectWindow::closed, this, &BalancingWindow::closeInspectWindow); diff --git a/src/balancing/CMakeLists.txt b/src/balancing/CMakeLists.txt index 3fbc8b1..54f99a7 100644 --- a/src/balancing/CMakeLists.txt +++ b/src/balancing/CMakeLists.txt @@ -6,6 +6,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BalancingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h PARENT_SCOPE @@ -20,6 +21,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BalancingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp PARENT_SCOPE ) diff --git a/src/balancing/InspectWindow.cpp b/src/balancing/InspectWindow.cpp index fa2b307..28ab4b3 100644 --- a/src/balancing/InspectWindow.cpp +++ b/src/balancing/InspectWindow.cpp @@ -9,14 +9,24 @@ #include #include "ArenaView.h" +#include "EntityAdmin.h" +#include "HealthComponent.h" +#include "ModuleOwnerComponent.h" +#include "ShipIdentityComponent.h" +#include "ShipStatsCalculator.h" +#include "ShipStatsPanel.h" +#include "StationBodyComponent.h" +#include "WeaponComponent.h" const double InspectWindow::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const int InspectWindow::kSpeedCount = 5; -InspectWindow::InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals, +InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config, + const VisualsConfig* visuals, const std::string& arenaName, QWidget* parent) : QWidget(parent) , m_sim(sim) + , m_config(config) { setWindowTitle(tr("Inspect \u2014 %1").arg(QString::fromStdString(arenaName))); resize(900, 700); @@ -96,6 +106,27 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals, team2Layout->addStretch(); infoLayout->addLayout(team2Layout); + // Entity stats section (right side of info panel) + QVBoxLayout* entityLayout = new QVBoxLayout(); + m_entityTitleLabel = new QLabel(infoPanel); + QFont entityTitleFont = m_entityTitleLabel->font(); + entityTitleFont.setBold(true); + m_entityTitleLabel->setFont(entityTitleFont); + m_entityTitleLabel->hide(); + entityLayout->addWidget(m_entityTitleLabel); + + m_entityStatsPanel = new ShipStatsPanel(config, infoPanel); + m_entityStatsPanel->hide(); + entityLayout->addWidget(m_entityStatsPanel); + + m_stationStatsLabel = new QLabel(infoPanel); + m_stationStatsLabel->setWordWrap(true); + m_stationStatsLabel->hide(); + entityLayout->addWidget(m_stationStatsLabel); + + entityLayout->addStretch(); + infoLayout->addLayout(entityLayout); + mainLayout->addWidget(infoPanel); } @@ -108,6 +139,13 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals, pollStatus(); setFocusPolicy(Qt::StrongFocus); + + registerForEvent(); +} + +InspectWindow::~InspectWindow() +{ + unregisterForEvent(); } void InspectWindow::closeEvent(QCloseEvent* event) @@ -151,6 +189,7 @@ void InspectWindow::pollStatus() { const ArenaStatus status = m_sim->status(); updateInfoPanel(status); + refreshEntityStats(); } void InspectWindow::updateInfoPanel(const ArenaStatus& status) @@ -186,3 +225,140 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status) content->setText(lines); } } + +void InspectWindow::handleEvent(std::shared_ptr event) +{ + if (event->entity.has_value()) + { + m_selectedEntity = event->entity; + + EntityAdmin& admin = m_sim->admin(); + entt::entity entity = *m_selectedEntity; + + if (!admin.isValid(entity)) + { + m_selectedEntity = std::nullopt; + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + return; + } + + if (admin.hasAll(entity)) + { + const ShipIdentityComponent& identity = admin.get(entity); + const HealthComponent& health = admin.get(entity); + + m_entityTitleLabel->setText(tr("Ship: %1 (Lv %2)") + .arg(QString::fromStdString(identity.schematicId)) + .arg(identity.level)); + m_entityTitleLabel->show(); + + const ShipStats stats = buildShipStatsFromEntity(admin, entity); + m_entityStatsPanel->refreshFromLive(stats, health.hp); + m_entityStatsPanel->show(); + m_stationStatsLabel->hide(); + } + else if (admin.hasAll(entity)) + { + const HealthComponent& health = admin.get(entity); + + m_entityTitleLabel->setText(tr("Defence Station")); + m_entityTitleLabel->show(); + + float totalDps = 0.0f; + float maxRange = 0.0f; + bool hasWeapons = false; + + admin.forEach( + [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w) + { + if (owner.owner != entity) { return; } + hasWeapons = true; + totalDps += w.damage * w.fireRateHz; + if (w.range_tiles > maxRange) { maxRange = w.range_tiles; } + }); + + QString statsText = tr("HP: %1 / %2") + .arg(static_cast(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f)); + + if (hasWeapons) + { + statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); + statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(maxRange), 'f', 1)); + } + + m_stationStatsLabel->setText(statsText); + m_stationStatsLabel->show(); + m_entityStatsPanel->hide(); + } + } + else + { + m_selectedEntity = std::nullopt; + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + } +} + +void InspectWindow::refreshEntityStats() +{ + if (!m_selectedEntity.has_value()) { return; } + + EntityAdmin& admin = m_sim->admin(); + entt::entity entity = *m_selectedEntity; + + if (!admin.isValid(entity)) + { + m_selectedEntity = std::nullopt; + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + return; + } + + const HealthComponent& health = admin.get(entity); + if (health.hp <= 0.0f) + { + m_selectedEntity = std::nullopt; + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); + return; + } + + if (admin.hasAll(entity)) + { + const ShipStats stats = buildShipStatsFromEntity(admin, entity); + m_entityStatsPanel->refreshFromLive(stats, health.hp); + } + else if (admin.hasAll(entity)) + { + float totalDps = 0.0f; + float maxRange = 0.0f; + bool hasWeapons = false; + + admin.forEach( + [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w) + { + if (owner.owner != entity) { return; } + hasWeapons = true; + totalDps += w.damage * w.fireRateHz; + if (w.range_tiles > maxRange) { maxRange = w.range_tiles; } + }); + + QString statsText = tr("HP: %1 / %2") + .arg(static_cast(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f)); + + if (hasWeapons) + { + statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); + statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(maxRange), 'f', 1)); + } + + m_stationStatsLabel->setText(statsText); + } +} diff --git a/src/balancing/InspectWindow.h b/src/balancing/InspectWindow.h index 0ecf771..f2b9116 100644 --- a/src/balancing/InspectWindow.h +++ b/src/balancing/InspectWindow.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -8,18 +9,27 @@ #include #include +#include "entt/entity/entity.hpp" + #include "ArenaSimulation.h" +#include "EntitySelectedEvent.h" +#include "EventHandler.h" +#include "GameConfig.h" #include "VisualsConfig.h" class ArenaView; +class ShipStatsPanel; -class InspectWindow : public QWidget +class InspectWindow : public QWidget, + public EventHandler { Q_OBJECT public: - InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals, + InspectWindow(ArenaSimulation* sim, const GameConfig* config, + const VisualsConfig* visuals, const std::string& arenaName, QWidget* parent = nullptr); + ~InspectWindow() override; signals: void closed(); @@ -28,6 +38,9 @@ protected: void closeEvent(QCloseEvent* event) override; void keyPressEvent(QKeyEvent* event) override; +private: + void handleEvent(std::shared_ptr event) override; + private slots: void onSpeedButton(int index); void onSpeedChanged(double multiplier); @@ -35,9 +48,11 @@ private slots: private: void updateInfoPanel(const ArenaStatus& status); + void refreshEntityStats(); - ArenaSimulation* m_sim; - ArenaView* m_arenaView; + ArenaSimulation* m_sim; + const GameConfig* m_config; + ArenaView* m_arenaView; std::vector m_speedButtons; QLabel* m_team1Header; @@ -46,6 +61,11 @@ private: QLabel* m_team2Content; QTimer* m_pollTimer; + std::optional m_selectedEntity; + QLabel* m_entityTitleLabel; + ShipStatsPanel* m_entityStatsPanel; + QLabel* m_stationStatsLabel; + static const double kSpeeds[]; static const int kSpeedCount; }; diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index d0323f0..9087852 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -3,6 +3,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/TracePrintRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h PARENT_SCOPE diff --git a/src/lib/eventsystem/event/EntitySelectedEvent.h b/src/lib/eventsystem/event/EntitySelectedEvent.h new file mode 100644 index 0000000..d54a9cd --- /dev/null +++ b/src/lib/eventsystem/event/EntitySelectedEvent.h @@ -0,0 +1,21 @@ +#ifndef ENTITY_SELECTED_EVENT_H +#define ENTITY_SELECTED_EVENT_H + +#include + +#include "entt/entity/entity.hpp" + +#include "Event.h" + +class EntitySelectedEvent : public Event +{ +public: + explicit EntitySelectedEvent(std::optional entity) + : entity(entity) + { + } + + const std::optional entity; +}; + +#endif // ENTITY_SELECTED_EVENT_H diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index db919bf..53af5b6 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -5,6 +5,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h + ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h @@ -18,6 +19,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp PARENT_SCOPE diff --git a/src/lib/sim/EntityHitTest.cpp b/src/lib/sim/EntityHitTest.cpp new file mode 100644 index 0000000..da84e28 --- /dev/null +++ b/src/lib/sim/EntityHitTest.cpp @@ -0,0 +1,56 @@ +#include "EntityHitTest.h" + +#include + +#include "EntityAdmin.h" +#include "PositionComponent.h" +#include "StationBodyComponent.h" +#include "HealthComponent.h" + +entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos) +{ + const QPoint tile(static_cast(std::floor(worldPos.x())), + static_cast(std::floor(worldPos.y()))); + + entt::entity stationHit = entt::null; + admin.forEach( + [&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h) + { + if (stationHit != entt::null) { return; } + if (h.hp <= 0.0f) { return; } + for (const QPoint& cell : sb.bodyCells) + { + if (cell == tile) + { + stationHit = entity; + return; + } + } + }); + + if (stationHit != entt::null) + { + return stationHit; + } + + constexpr float kShipHitRadiusSquared = 0.5f * 0.5f; + entt::entity bestShip = entt::null; + float bestDistSquared = kShipHitRadiusSquared; + + admin.forEach( + [&](entt::entity entity, const PositionComponent& pos, const HealthComponent& h) + { + if (h.hp <= 0.0f) { return; } + if (admin.hasAll(entity)) { return; } + 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; + bestShip = entity; + } + }); + + return bestShip; +} diff --git a/src/lib/sim/EntityHitTest.h b/src/lib/sim/EntityHitTest.h new file mode 100644 index 0000000..716611d --- /dev/null +++ b/src/lib/sim/EntityHitTest.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +#include "entt/entity/entity.hpp" + +class EntityAdmin; + +entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos); diff --git a/src/lib/sim/ShipStatsCalculator.cpp b/src/lib/sim/ShipStatsCalculator.cpp index bac58d8..e0e9110 100644 --- a/src/lib/sim/ShipStatsCalculator.cpp +++ b/src/lib/sim/ShipStatsCalculator.cpp @@ -3,6 +3,16 @@ #include #include +#include "DynamicBodyComponent.h" +#include "EntityAdmin.h" +#include "HealthComponent.h" +#include "ModuleOwnerComponent.h" +#include "RepairToolComponent.h" +#include "SalvageCargoComponent.h" +#include "SensorRangeComponent.h" +#include "Tick.h" +#include "WeaponComponent.h" + ShipStats calculateShipStats(const GameConfig& config, const std::string& shipId, int level, @@ -216,3 +226,68 @@ ShipStats calculateShipStats(const GameConfig& config, return result; } + +ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity) +{ + ShipStats result{}; + + const HealthComponent& health = admin.get(shipEntity); + const DynamicBodyComponent& body = admin.get(shipEntity); + const SensorRangeComponent& sensor = admin.get(shipEntity); + + result.hp = health.maxHp; + result.maxSpeed_tps = body.maxSpeed_tpt * kTickRateHz; + result.sensorRange_tiles = sensor.value_tiles; + result.mainAcceleration_tpss = body.mainAcceleration_tptt * kTickRateHz * kTickRateHz; + result.maneuveringAcceleration_tpss = body.maneuveringAcceleration_tptt * kTickRateHz * kTickRateHz; + result.angularAcceleration_radpss = body.maxAngularAcceleration_rptt * kTickRateHz * kTickRateHz; + result.maxRotationSpeed_radps = body.maxRotationSpeed_rpt * kTickRateHz; + + float weaponDps = 0.0f; + float weaponMaxRange = 0.0f; + bool hasWeapons = false; + + float salvageRate = 0.0f; + float salvageMaxRange = 0.0f; + bool hasSalvage = false; + + float repairRate = 0.0f; + float repairMaxRange = 0.0f; + bool hasRepair = false; + + admin.forEach( + [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w) + { + if (owner.owner != shipEntity) { return; } + hasWeapons = true; + weaponDps += w.damage * w.fireRateHz; + if (w.range_tiles > weaponMaxRange) { weaponMaxRange = w.range_tiles; } + }); + + admin.forEach( + [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const SalvageCargoComponent& s) + { + if (owner.owner != shipEntity) { return; } + hasSalvage = true; + const float rate = (s.collectionIntervalTicks > 0) + ? static_cast(kTickRateHz) / static_cast(s.collectionIntervalTicks) + : 0.0f; + salvageRate += rate; + if (s.collectionRange_tiles > salvageMaxRange) { salvageMaxRange = s.collectionRange_tiles; } + }); + + admin.forEach( + [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const RepairToolComponent& r) + { + if (owner.owner != shipEntity) { return; } + hasRepair = true; + repairRate += r.ratePerTick * kTickRateHz; + if (r.range_tiles > repairMaxRange) { repairMaxRange = r.range_tiles; } + }); + + if (hasWeapons) { result.weapons = ShipStats::WeaponStats{weaponDps, weaponMaxRange}; } + if (hasSalvage) { result.salvage = ShipStats::SalvageStats{salvageRate, salvageMaxRange}; } + if (hasRepair) { result.repair = ShipStats::RepairStats{repairRate, repairMaxRange}; } + + return result; +} diff --git a/src/lib/sim/ShipStatsCalculator.h b/src/lib/sim/ShipStatsCalculator.h index 41f84d5..9881710 100644 --- a/src/lib/sim/ShipStatsCalculator.h +++ b/src/lib/sim/ShipStatsCalculator.h @@ -4,9 +4,13 @@ #include #include +#include "entt/entity/entity.hpp" + #include "GameConfig.h" #include "ShipLayout.h" +class EntityAdmin; + // Effective stats for a ship with a given layout, after applying all passive // module modifiers per REQ-MOD-STAT-CALC. Values are in display units: // speeds in tiles/s, ranges in tiles, accelerations in tiles/s² or rad/s². @@ -47,3 +51,6 @@ ShipStats calculateShipStats(const GameConfig& config, const std::string& shipId, int level, const std::vector& modules); + +ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity); + diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index de0b6ce..a411f0a 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -20,6 +20,8 @@ #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" +#include "EntityHitTest.h" +#include "EntitySelectedEvent.h" #include "EventManager.h" #include "FacingComponent.h" #include "FactionComponent.h" @@ -338,6 +340,13 @@ QPoint GameWorldView::widgetToTile(QPoint widgetPt) const return QPoint(static_cast(std::floor(wx)), static_cast(std::floor(wy))); } +QVector2D GameWorldView::widgetToWorld(QPoint widgetPt) const +{ + const float wx = static_cast(widgetPt.x()) / tilePx() + m_scrollXTiles; + const float wy = static_cast(widgetPt.y()) / tilePx(); + return QVector2D(wx, wy); +} + QRectF GameWorldView::tileRect(QPoint tile) const { const QPointF tl = tileToWidget(tile); @@ -834,7 +843,7 @@ void GameWorldView::drawScrap(QPainter& painter) void GameWorldView::drawStations(QPainter& painter) { m_sim->admin().forEach( - [&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f, + [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h) { const BuildingType visType = f.isEnemy @@ -860,6 +869,13 @@ void GameWorldView::drawStations(QPainter& painter) painter.setBrush(Qt::NoBrush); painter.drawRect(bboxRect); + if (m_selectedEntity.has_value() && *m_selectedEntity == e) + { + painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2)); + } + // HP bar below footprint. if (h.maxHp > 0.0f) { @@ -879,7 +895,7 @@ void GameWorldView::drawShips(QPainter& painter) { m_sim->admin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& si, + [&](entt::entity e, const ShipIdentityComponent& si, const PositionComponent& pos, const FacingComponent& facing, const FactionComponent& fac, const HealthComponent& h) { @@ -906,6 +922,14 @@ void GameWorldView::drawShips(QPainter& painter) painter.setBrush(it->second.fill); painter.drawPolygon(tri); + if (m_selectedEntity.has_value() && *m_selectedEntity == e) + { + painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); + painter.setBrush(Qt::NoBrush); + const qreal r = static_cast(fwd) + 2.0; + painter.drawEllipse(center, r, r); + } + if (h.maxHp > 0.0f) { const float fraction = std::max(0.0f, h.hp / h.maxHp); @@ -1271,41 +1295,62 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) } else { - BuildingId id = buildingAtTile(tile); - if (id == kInvalidBuildingId) + const QVector2D worldPos = widgetToWorld(event->pos()); + const entt::entity hitEntity = entityAtWorldPos(m_sim->admin(), worldPos); + + if (hitEntity != entt::null) { - id = siteAtTile(tile); - } - if (id != kInvalidBuildingId) - { - if (event->modifiers() & Qt::ControlModifier) - { - bool found = false; - std::vector newSel; - for (BuildingId sel : m_selectedBuildingIds) - { - if (sel == id) { found = true; } - else { newSel.push_back(sel); } - } - if (!found) { newSel.push_back(id); } - m_selectedBuildingIds = newSel; - } - else - { - m_selectedBuildingIds = { id }; - } + m_selectedBuildingIds.clear(); emit selectionChanged(m_selectedBuildingIds); + m_selectedEntity = hitEntity; + EventManager::getInstance()->sendEventImmediately( + std::make_shared(hitEntity)); } else { - if (!(event->modifiers() & Qt::ControlModifier)) + if (m_selectedEntity.has_value()) { - m_selectedBuildingIds.clear(); + m_selectedEntity = std::nullopt; + EventManager::getInstance()->sendEventImmediately( + std::make_shared(std::nullopt)); + } + + BuildingId id = buildingAtTile(tile); + if (id == kInvalidBuildingId) + { + id = siteAtTile(tile); + } + if (id != kInvalidBuildingId) + { + if (event->modifiers() & Qt::ControlModifier) + { + bool found = false; + std::vector newSel; + for (BuildingId sel : m_selectedBuildingIds) + { + if (sel == id) { found = true; } + else { newSel.push_back(sel); } + } + if (!found) { newSel.push_back(id); } + m_selectedBuildingIds = newSel; + } + else + { + m_selectedBuildingIds = { id }; + } emit selectionChanged(m_selectedBuildingIds); } - m_boxSelecting = true; - m_boxStartTile = tile; - m_boxCurrentTile = tile; + else + { + if (!(event->modifiers() & Qt::ControlModifier)) + { + m_selectedBuildingIds.clear(); + emit selectionChanged(m_selectedBuildingIds); + } + m_boxSelecting = true; + m_boxStartTile = tile; + m_boxCurrentTile = tile; + } } } } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 01ef36f..0dd9217 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -19,6 +19,7 @@ #include "FireEvent.h" #include "entt/entity/entity.hpp" +#include "EntitySelectedEvent.h" #include "GameConfig.h" #include "Rotation.h" #include "Tick.h" @@ -106,6 +107,7 @@ private: const BuildingDef* findBuildingDef(BuildingType type) const; BuildingId buildingAtTile(QPoint tile) const; BuildingId siteAtTile(QPoint tile) const; + QVector2D widgetToWorld(QPoint widgetPt) const; void drawPortGlyph(QPainter& painter, QPoint bodyTile, Rotation direction, const QColor& color); @@ -166,6 +168,7 @@ private: bool m_debugDraw; std::vector m_selectedBuildingIds; + std::optional m_selectedEntity; bool m_boxSelecting; QPoint m_boxStartTile; QPoint m_boxCurrentTile; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 6152c30..ea15033 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -13,6 +13,16 @@ #include #include "BeltSystem.h" +#include "DynamicBodyComponent.h" +#include "EntityAdmin.h" +#include "EntitySelectedEvent.h" +#include "FactionComponent.h" +#include "HealthComponent.h" +#include "ModuleOwnerComponent.h" +#include "ShipIdentityComponent.h" +#include "ShipStatsCalculator.h" +#include "ShipStatsPanel.h" +#include "StationBodyComponent.h" #include "TickAdvancedEvent.h" #include "Building.h" #include "BuildingSystem.h" @@ -22,6 +32,7 @@ #include "Rotation.h" #include "ShipLayoutPreview.h" #include "Simulation.h" +#include "WeaponComponent.h" namespace { @@ -136,19 +147,39 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, connect(m_filterBList, &QListWidget::itemChanged, this, &SelectedBuildingPanel::onSplitterFilterChanged); + m_entityTitleLabel = new QLabel(this); + QFont titleFont = m_entityTitleLabel->font(); + titleFont.setBold(true); + m_entityTitleLabel->setFont(titleFont); + m_layout->addWidget(m_entityTitleLabel); + m_entityTitleLabel->hide(); + + m_entityStatsPanel = new ShipStatsPanel(config, this); + m_layout->addWidget(m_entityStatsPanel); + m_entityStatsPanel->hide(); + + m_stationStatsLabel = new QLabel(this); + m_stationStatsLabel->setWordWrap(true); + m_layout->addWidget(m_stationStatsLabel); + m_stationStatsLabel->hide(); + buildEmpty(); - registerForEvent(); + registerForEvents(); } SelectedBuildingPanel::~SelectedBuildingPanel() { - unregisterForEvent(); + unregisterForEvents(); } void SelectedBuildingPanel::onSelectionChanged(const std::vector& ids) { m_selectedBuildingIds = ids; + if (!ids.empty()) + { + clearEntityDisplay(); + } rebuild(); } @@ -168,7 +199,7 @@ void SelectedBuildingPanel::rebuild() } } -void SelectedBuildingPanel::buildEmpty() +void SelectedBuildingPanel::clearContent() { m_singleBuildingId = kInvalidBuildingId; m_titleLabel->hide(); @@ -183,6 +214,14 @@ void SelectedBuildingPanel::buildEmpty() m_buffersLabel->hide(); } +void SelectedBuildingPanel::buildEmpty() +{ + clearContent(); + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); +} + void SelectedBuildingPanel::buildSingle(BuildingId id) { m_singleBuildingId = id; @@ -498,12 +537,16 @@ const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const void SelectedBuildingPanel::handleEvent(std::shared_ptr /*event*/) { + if (m_selectedEntity.has_value()) + { + refreshEntityStats(); + return; + } + if (m_singleBuildingId == kInvalidBuildingId) { return; } const Building* b = m_sim->buildings().findBuilding(m_singleBuildingId); if (b) { - // If the panel was last showing this id as a construction site, the - // full building UI (recipe combo, ports, etc.) hasn't been built yet. if (m_titleLabel->text().startsWith(tr("(Building) "))) { rebuild(); @@ -688,3 +731,129 @@ void SelectedBuildingPanel::onClearBelt() m_sim->belts().clearTiles(tiles); } } + +void SelectedBuildingPanel::handleEvent(std::shared_ptr event) +{ + if (event->entity.has_value()) + { + m_selectedEntity = event->entity; + m_selectedBuildingIds.clear(); + clearContent(); + + EntityAdmin& admin = m_sim->admin(); + entt::entity entity = *m_selectedEntity; + + if (!admin.isValid(entity)) + { + clearEntityDisplay(); + return; + } + + if (admin.hasAll(entity)) + { + buildEntityShip(entity); + } + else if (admin.hasAll(entity)) + { + buildEntityStation(entity); + } + } + else + { + clearEntityDisplay(); + } +} + +void SelectedBuildingPanel::buildEntityShip(entt::entity entity) +{ + EntityAdmin& admin = m_sim->admin(); + const ShipIdentityComponent& identity = admin.get(entity); + const HealthComponent& health = admin.get(entity); + + m_entityTitleLabel->setText(tr("Ship: %1 (Lv %2)") + .arg(QString::fromStdString(identity.schematicId)) + .arg(identity.level)); + m_entityTitleLabel->show(); + + const ShipStats stats = buildShipStatsFromEntity(admin, entity); + m_entityStatsPanel->refreshFromLive(stats, health.hp); + m_entityStatsPanel->show(); + + m_stationStatsLabel->hide(); +} + +void SelectedBuildingPanel::buildEntityStation(entt::entity entity) +{ + EntityAdmin& admin = m_sim->admin(); + const HealthComponent& health = admin.get(entity); + + m_entityTitleLabel->setText(tr("Defence Station")); + m_entityTitleLabel->show(); + + float totalDps = 0.0f; + float maxRange = 0.0f; + bool hasWeapons = false; + + admin.forEach( + [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w) + { + if (owner.owner != entity) { return; } + hasWeapons = true; + totalDps += w.damage * w.fireRateHz; + if (w.range_tiles > maxRange) { maxRange = w.range_tiles; } + }); + + QString statsText = tr("HP: %1 / %2") + .arg(static_cast(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f)); + + if (hasWeapons) + { + statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); + statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(maxRange), 'f', 1)); + } + + m_stationStatsLabel->setText(statsText); + m_stationStatsLabel->show(); + + m_entityStatsPanel->hide(); +} + +void SelectedBuildingPanel::refreshEntityStats() +{ + if (!m_selectedEntity.has_value()) { return; } + + EntityAdmin& admin = m_sim->admin(); + entt::entity entity = *m_selectedEntity; + + if (!admin.isValid(entity)) + { + clearEntityDisplay(); + return; + } + + const HealthComponent& health = admin.get(entity); + if (health.hp <= 0.0f) + { + clearEntityDisplay(); + return; + } + + if (admin.hasAll(entity)) + { + const ShipStats stats = buildShipStatsFromEntity(admin, entity); + m_entityStatsPanel->refreshFromLive(stats, health.hp); + } + else if (admin.hasAll(entity)) + { + buildEntityStation(entity); + } +} + +void SelectedBuildingPanel::clearEntityDisplay() +{ + m_selectedEntity = std::nullopt; + m_entityTitleLabel->hide(); + m_entityStatsPanel->hide(); + m_stationStatsLabel->hide(); +} diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index 272e6ea..133dcb3 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -1,13 +1,17 @@ #pragma once +#include #include #include #include #include +#include "entt/entity/entity.hpp" + #include "Building.h" #include "BuildingId.h" +#include "EntitySelectedEvent.h" #include "EventHandler.h" #include "GameConfig.h" #include "RecipesConfig.h" @@ -18,6 +22,7 @@ class Simulation; class ShipLayoutPreview; +class ShipStatsPanel; class QLabel; class QComboBox; class QListWidget; @@ -25,7 +30,7 @@ class QPushButton; class QVBoxLayout; class SelectedBuildingPanel : public QWidget, - public EventHandler + public CombinedEventHandler { Q_OBJECT @@ -42,6 +47,7 @@ public slots: private: void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; private slots: void onRecipeChanged(int comboIndex); @@ -80,4 +86,14 @@ private: BuildingId m_singleBuildingId; QPoint m_splitterTile; std::string m_currentRecipeId; + + std::optional m_selectedEntity; + ShipStatsPanel* m_entityStatsPanel; + QLabel* m_entityTitleLabel; + QLabel* m_stationStatsLabel; + + void buildEntityShip(entt::entity entity); + void buildEntityStation(entt::entity entity); + void refreshEntityStats(); + void clearEntityDisplay(); }; diff --git a/src/ui/ShipStatsPanel.cpp b/src/ui/ShipStatsPanel.cpp index 7837504..08ae0bf 100644 --- a/src/ui/ShipStatsPanel.cpp +++ b/src/ui/ShipStatsPanel.cpp @@ -111,9 +111,21 @@ void ShipStatsPanel::refresh(const std::string& shipId, const std::vector& modules) { const ShipStats stats = calculateShipStats(*m_config, shipId, level, modules); + const QString hpText = tr("HP: %1").arg(static_cast(stats.hp + 0.5f)); + applyStats(stats, hpText); +} - m_hpLabel->setText( - tr("HP: %1").arg(static_cast(stats.hp + 0.5f))); +void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp) +{ + const QString hpText = tr("HP: %1 / %2") + .arg(static_cast(currentHp + 0.5f)) + .arg(static_cast(stats.hp + 0.5f)); + applyStats(stats, hpText); +} + +void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText) +{ + m_hpLabel->setText(hpText); m_speedLabel->setText( tr("Max Speed: %1 tiles/s").arg(fmt(stats.maxSpeed_tps))); m_sensorRangeLabel->setText( diff --git a/src/ui/ShipStatsPanel.h b/src/ui/ShipStatsPanel.h index 18ae694..90f817b 100644 --- a/src/ui/ShipStatsPanel.h +++ b/src/ui/ShipStatsPanel.h @@ -6,6 +6,7 @@ #include #include "ShipLayout.h" +#include "ShipStatsCalculator.h" struct GameConfig; class QLabel; @@ -21,7 +22,11 @@ public: int level, const std::vector& modules); + void refreshFromLive(const ShipStats& stats, float currentHp); + private: + void applyStats(const ShipStats& stats, const QString& hpText); + const GameConfig* m_config; QLabel* m_hpLabel;