diff --git a/docs/requirements.md b/docs/requirements.md index 10b7f7e..ad58d4f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -278,15 +278,19 @@ A separate executable target (`balancing`) that links against `lib` but contains - REQ-BAL-SIM-ENV: Each arena simulates a pure-space environment using the same tick-based simulation as the main game. There is no asteroid, no buildings, no belts, no wave system, and no threat accumulation. Only ships, HQs, defence stations, and combat are active. - REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. All ships use aggressive stance and closest-target priority. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI). -- REQ-BAL-SIM-SPEED: Each arena runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. +- REQ-BAL-SIM-SPEED: Each arena that is not being inspected runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. An inspected arena runs at a player-controllable game speed (same speed steps as the main game: 0×, 0.5×, 1×, 2×, 4×) with full rendering in the inspect window, defaulting to 1× on open. - REQ-BAL-SIM-PARALLEL: All arenas are simulated in parallel, each on its own thread. - REQ-BAL-SIM-END: An arena fight ends when either team's HQ is destroyed or all ships and defence stations of one team have been destroyed. If a team has no defence stations, destroying all its ships is sufficient. When the fight ends, the simulation for that arena stops. ### UI -- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. +- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT). - REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running. - REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running. -- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name and two columns (one per team). Each column shows the team name as a header, followed by a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`. +- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, followed by a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`. - REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced). - REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended. +- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time. +- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live. +- REQ-BAL-UI-INSPECT-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view. +- REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e1f5f18..5cf0f8a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -241,6 +241,7 @@ set_target_properties(${TARGET_BALANCING_NAME} PROPERTIES target_include_directories(${TARGET_BALANCING_NAME} PRIVATE "${TARGET_BALANCING_INCLUDE_DIRS}" "${TARGET_LIB_INCLUDE_DIRS}" + "${TARGET_UI_INCLUDE_DIRS}" "${LIB_INCLUDE_PATH}" ) target_compile_definitions(${TARGET_BALANCING_NAME} PRIVATE @@ -248,7 +249,7 @@ target_compile_definitions(${TARGET_BALANCING_NAME} PRIVATE BALANCING_CONFIG="${CMAKE_SOURCE_DIR}/bin/balancing/data/balancing.toml" TOML_FLOAT_CHARCONV=0 ) -target_link_libraries(${TARGET_BALANCING_NAME} ${TARGET_LIB_NAME} Qt5::Widgets) +target_link_libraries(${TARGET_BALANCING_NAME} ${TARGET_LIB_NAME} Qt5::Widgets ${OPENGL_LIBRARIES}) unset(BALANCING_FILES) unset(RELATIVE_HDRS) diff --git a/src/balancing/ArenaSimulation.cpp b/src/balancing/ArenaSimulation.cpp index 5b2e86d..2cb06ce 100644 --- a/src/balancing/ArenaSimulation.cpp +++ b/src/balancing/ArenaSimulation.cpp @@ -256,6 +256,7 @@ void ArenaSimulation::tick() // Combat resolution (tick step 8). std::vector fireEvents; m_combatSystem->tick(m_currentTick, *m_shipSystem, *m_buildingSystem, fireEvents); + m_fireEvents.insert(m_fireEvents.end(), fireEvents.begin(), fireEvents.end()); m_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem); // Deaths (tick step 9, simplified). @@ -375,6 +376,57 @@ void ArenaSimulation::tickDeaths() } } +void ArenaSimulation::tickOnce() +{ + if (!m_finished) + { + tick(); + updateStatus(); + } +} + +std::vector ArenaSimulation::drainFireEvents() +{ + std::vector result; + result.swap(m_fireEvents); + return result; +} + +bool ArenaSimulation::isFinished() const +{ + return m_finished; +} + +int ArenaSimulation::winnerTeam() const +{ + return m_winnerTeam; +} + +Tick ArenaSimulation::currentTick() const +{ + return m_currentTick; +} + +const ArenaConfig& ArenaSimulation::arenaConfig() const +{ + return m_arenaConfig; +} + +const BuildingSystem& ArenaSimulation::buildings() const +{ + return *m_buildingSystem; +} + +const ShipSystem& ArenaSimulation::ships() const +{ + return *m_shipSystem; +} + +const ScrapSystem& ArenaSimulation::scraps() const +{ + return *m_scrapSystem; +} + void ArenaSimulation::updateStatus() { ArenaStatus newStatus; diff --git a/src/balancing/ArenaSimulation.h b/src/balancing/ArenaSimulation.h index d112476..6990f41 100644 --- a/src/balancing/ArenaSimulation.h +++ b/src/balancing/ArenaSimulation.h @@ -51,7 +51,18 @@ public: void run(); void requestStop(); + void tickOnce(); + std::vector drainFireEvents(); + ArenaStatus status() const; + bool isFinished() const; + int winnerTeam() const; + Tick currentTick() const; + + const ArenaConfig& arenaConfig() const; + const BuildingSystem& buildings() const; + const ShipSystem& ships() const; + const ScrapSystem& scraps() const; private: EntityId allocateId(); @@ -81,6 +92,8 @@ private: int m_winnerTeam; std::atomic m_stopRequested; + std::vector m_fireEvents; + mutable std::mutex m_statusMutex; ArenaStatus m_status; }; diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp new file mode 100644 index 0000000..93c7bcb --- /dev/null +++ b/src/balancing/ArenaView.cpp @@ -0,0 +1,321 @@ +#include "ArenaView.h" + +#include +#include +#include + +#include +#include + +#include "ArenaSimulation.h" +#include "Building.h" +#include "BuildingSystem.h" +#include "Scrap.h" +#include "ScrapSystem.h" +#include "Ship.h" +#include "ShipSystem.h" + +namespace +{ + +ShipRole shipRole(const Ship& ship) +{ + if (ship.isEnemy) { return ShipRole::Enemy; } + if (ship.cargo.has_value()) { return ShipRole::Salvage; } + if (ship.repairTool.has_value()) { return ShipRole::Repair; } + return ShipRole::PlayerCombat; +} + +} // namespace + + +ArenaView::ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals, + QWidget* parent) + : QOpenGLWidget(parent) + , m_sim(sim) + , m_visuals(visuals) + , m_wallMs(0) + , m_gameSpeedMultiplier(1.0) + , m_prevNonZeroSpeed(1.0) + , m_rng(std::random_device{}()) + , m_finishedEmitted(false) +{ + setFocusPolicy(Qt::StrongFocus); + + m_renderTimer = new QTimer(this); + m_renderTimer->setInterval(16); + connect(m_renderTimer, &QTimer::timeout, this, &ArenaView::onFrame); + m_renderTimer->start(); + m_frameTimer.start(); +} + +void ArenaView::setGameSpeed(double multiplier) +{ + if (multiplier > 0.001) + { + m_prevNonZeroSpeed = multiplier; + } + m_gameSpeedMultiplier = multiplier; + emit speedChanged(multiplier); +} + +double ArenaView::gameSpeed() const +{ + return m_gameSpeedMultiplier; +} + +void ArenaView::togglePause() +{ + if (m_gameSpeedMultiplier < 0.001) + { + setGameSpeed(m_prevNonZeroSpeed); + } + else + { + setGameSpeed(0.0); + } +} + +void ArenaView::onFrame() +{ + const qint64 elapsed = m_frameTimer.restart(); + m_wallMs += elapsed; + + { + const int ticks = m_tickDriver.advance( + static_cast(elapsed), m_gameSpeedMultiplier); + for (int i = 0; i < ticks; ++i) + { + m_sim->tickOnce(); + } + } + + { + const std::vector fires = m_sim->drainFireEvents(); + for (const FireEvent& fe : fires) + { + float maxRadius = 0.125f; + const Building* tBld = m_sim->buildings().findBuilding(fe.target); + if (tBld) + { + const int shorter = std::min(tBld->footprint.width(), + tBld->footprint.height()); + maxRadius = shorter / 2.0f; + } + + std::uniform_real_distribution angleDist(0.0f, 6.28318530f); + std::uniform_real_distribution radiusDist(0.0f, maxRadius); + const float angle = angleDist(m_rng); + const float radius = radiusDist(m_rng); + + ActiveBeam beam; + beam.event = fe; + beam.emittedWallMs = m_wallMs; + beam.targetOffset = QVector2D(radius * std::cos(angle), + radius * std::sin(angle)); + m_activeBeams.push_back(beam); + } + } + + { + std::vector live; + for (const ActiveBeam& b : m_activeBeams) + { + if (m_wallMs - b.emittedWallMs < kBeamLifetimeMs) + { + live.push_back(b); + } + } + m_activeBeams = std::move(live); + } + + if (m_sim->isFinished() && !m_finishedEmitted) + { + m_finishedEmitted = true; + emit finished(); + } + + update(); +} + +void ArenaView::paintGL() +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, false); + + drawTiles(painter); + drawBuildings(painter); + drawScrap(painter); + drawShips(painter); + drawBeams(painter); +} + +// --------------------------------------------------------------------------- +// Coordinate helpers +// --------------------------------------------------------------------------- + +float ArenaView::tilePx() const +{ + const ArenaConfig& ac = m_sim->arenaConfig(); + const int totalWidth = ac.playerBufferWidth + + ac.contestZoneWidth + + ac.enemyBufferWidth; + const int totalHeight = ac.heightTiles; + if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; } + + const float pxPerTileH = static_cast(height()) / static_cast(totalHeight); + const float pxPerTileW = static_cast(width()) / static_cast(totalWidth); + return std::min(pxPerTileH, pxPerTileW); +} + +QPointF ArenaView::worldToWidget(QVector2D worldPos) const +{ + return QPointF( + static_cast(worldPos.x() * tilePx()), + static_cast(worldPos.y() * tilePx())); +} + +QPointF ArenaView::tileToWidget(QPoint tile) const +{ + return worldToWidget(QVector2D(static_cast(tile.x()), + static_cast(tile.y()))); +} + +QRectF ArenaView::tileRect(QPoint tile) const +{ + const QPointF tl = tileToWidget(tile); + return QRectF(tl.x(), tl.y(), + static_cast(tilePx()), static_cast(tilePx())); +} + +std::optional ArenaView::entityPosition(EntityId id) const +{ + for (const Ship& ship : m_sim->ships().allShips()) + { + if (ship.id == id) + { + return ship.position; + } + } + const Building* bldg = m_sim->buildings().findBuilding(id); + if (bldg) + { + return QVector2D( + bldg->anchor.x() + bldg->footprint.width() * 0.5f, + bldg->anchor.y() + bldg->footprint.height() * 0.5f); + } + return std::nullopt; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +void ArenaView::drawTiles(QPainter& painter) +{ + const ArenaConfig& ac = m_sim->arenaConfig(); + const int totalWidth = ac.playerBufferWidth + + ac.contestZoneWidth + + ac.enemyBufferWidth; + const int totalHeight = ac.heightTiles; + + painter.setPen(Qt::NoPen); + for (int x = 0; x < totalWidth; ++x) + { + for (int y = 0; y < totalHeight; ++y) + { + painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill); + } + } +} + +void ArenaView::drawBuildings(QPainter& painter) +{ + for (const Building& b : m_sim->buildings().allBuildings()) + { + const std::map::const_iterator it = + m_visuals->buildings.find(b.type); + if (it == m_visuals->buildings.end()) { continue; } + const BuildingVisuals& bv = it->second; + + painter.setPen(Qt::NoPen); + for (const QPoint& cell : b.bodyCells) + { + painter.fillRect(tileRect(cell), bv.fill); + } + + const QPointF tl = tileToWidget(b.anchor); + const QRectF bboxRect(tl.x(), tl.y(), + b.footprint.width() * static_cast(tilePx()), + b.footprint.height() * static_cast(tilePx())); + + painter.setPen(QPen(bv.outline, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect); + + if (!bv.glyph.isEmpty()) + { + painter.setPen(bv.outline); + painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); + } + } +} + +void ArenaView::drawScrap(QPainter& painter) +{ + const float r = tilePx() * 0.2f; + for (const Scrap& scrap : m_sim->scraps().allScraps()) + { + const QPointF center = worldToWidget(scrap.position); + painter.setBrush(QColor(128, 110, 90)); + painter.setPen(QPen(QColor(50, 40, 30), 1)); + painter.drawEllipse(center, + static_cast(r), static_cast(r)); + } +} + +void ArenaView::drawShips(QPainter& painter) +{ + for (const Ship& ship : m_sim->ships().allShips()) + { + const ShipRole role = shipRole(ship); + const std::map::const_iterator it = + m_visuals->ships.find(role); + if (it == m_visuals->ships.end()) { continue; } + + const QPointF center = worldToWidget(ship.position); + const QVector2D vel = ship.velocity; + const QVector2D dir = (vel.length() > 0.0001f) + ? vel.normalized() + : QVector2D(1.0f, 0.0f); + const QVector2D perp(-dir.y(), dir.x()); + + const float fwd = tilePx() * 0.45f; + const float side = tilePx() * 0.25f; + + QPolygonF tri; + tri << QPointF(center.x() + static_cast(dir.x() * fwd), + center.y() + static_cast(dir.y() * fwd)) + << QPointF(center.x() + static_cast(perp.x() * side - dir.x() * side), + center.y() + static_cast(perp.y() * side - dir.y() * side)) + << QPointF(center.x() + static_cast(-perp.x() * side - dir.x() * side), + center.y() + static_cast(-perp.y() * side - dir.y() * side)); + + painter.setPen(QPen(it->second.outline, 1)); + painter.setBrush(it->second.fill); + painter.drawPolygon(tri); + } +} + +void ArenaView::drawBeams(QPainter& painter) +{ + painter.setPen(QPen(m_visuals->beams.color, m_visuals->beams.widthPx)); + for (const ActiveBeam& beam : m_activeBeams) + { + const std::optional shooterPos = entityPosition(beam.event.shooter); + const std::optional targetPos = entityPosition(beam.event.target); + if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } + painter.drawLine(worldToWidget(*shooterPos), + worldToWidget(*targetPos + beam.targetOffset)); + } +} diff --git a/src/balancing/ArenaView.h b/src/balancing/ArenaView.h new file mode 100644 index 0000000..523d666 --- /dev/null +++ b/src/balancing/ArenaView.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "EntityId.h" +#include "FireEvent.h" +#include "Tick.h" +#include "TickDriver.h" +#include "VisualsConfig.h" + +class ArenaSimulation; +class QPainter; + +class ArenaView : public QOpenGLWidget +{ + Q_OBJECT + +public: + ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals, + QWidget* parent = nullptr); + + void setGameSpeed(double multiplier); + double gameSpeed() const; + void togglePause(); + +signals: + void speedChanged(double multiplier); + void finished(); + +protected: + void paintGL() override; + +private slots: + void onFrame(); + +private: + void drawTiles(QPainter& painter); + void drawBuildings(QPainter& painter); + void drawScrap(QPainter& painter); + void drawShips(QPainter& painter); + void drawBeams(QPainter& painter); + + float tilePx() const; + QPointF worldToWidget(QVector2D worldPos) const; + QPointF tileToWidget(QPoint tile) const; + QRectF tileRect(QPoint tile) const; + + std::optional entityPosition(EntityId id) const; + + struct ActiveBeam + { + FireEvent event; + qint64 emittedWallMs; + QVector2D targetOffset; + }; + + static constexpr qint64 kBeamLifetimeMs = 300; + + ArenaSimulation* m_sim; + const VisualsConfig* m_visuals; + + TickDriver m_tickDriver; + QElapsedTimer m_frameTimer; + qint64 m_wallMs; + std::mt19937 m_rng; + double m_gameSpeedMultiplier; + double m_prevNonZeroSpeed; + + QTimer* m_renderTimer; + + std::vector m_activeBeams; + bool m_finishedEmitted; +}; diff --git a/src/balancing/ArenaWidget.cpp b/src/balancing/ArenaWidget.cpp index 787920b..9aa234b 100644 --- a/src/balancing/ArenaWidget.cpp +++ b/src/balancing/ArenaWidget.cpp @@ -30,6 +30,10 @@ void ArenaWidget::buildLayout(const std::string& arenaName) titleRow->addStretch(); + m_inspectButton = new QPushButton("Inspect", this); + connect(m_inspectButton, &QPushButton::clicked, this, &ArenaWidget::inspectRequested); + titleRow->addWidget(m_inspectButton); + m_startButton = new QPushButton("Start", this); connect(m_startButton, &QPushButton::clicked, this, &ArenaWidget::startRequested); titleRow->addWidget(m_startButton); @@ -72,6 +76,14 @@ void ArenaWidget::startSimulation() setStyleSheet("ArenaWidget { border: 2px solid #3366ff; padding: 8px; }"); } +void ArenaWidget::resetToGrey() +{ + m_running = false; + m_wasFinished = false; + m_startButton->setEnabled(true); + setStyleSheet("ArenaWidget { border: 2px solid #999999; padding: 8px; }"); +} + void ArenaWidget::updateStatus(const ArenaStatus& status) { for (int ti = 0; ti < 2; ++ti) diff --git a/src/balancing/ArenaWidget.h b/src/balancing/ArenaWidget.h index 7bc493a..515a260 100644 --- a/src/balancing/ArenaWidget.h +++ b/src/balancing/ArenaWidget.h @@ -18,9 +18,11 @@ public: void updateStatus(const ArenaStatus& status); void startSimulation(); + void resetToGrey(); signals: void startRequested(); + void inspectRequested(); private: void buildLayout(const std::string& arenaName); @@ -30,6 +32,7 @@ private: QLabel* m_team2Header; QLabel* m_team1Content; QLabel* m_team2Content; + QPushButton* m_inspectButton; QPushButton* m_startButton; bool m_running; bool m_wasFinished; diff --git a/src/balancing/BalancingWindow.cpp b/src/balancing/BalancingWindow.cpp index 9520a25..1579a45 100644 --- a/src/balancing/BalancingWindow.cpp +++ b/src/balancing/BalancingWindow.cpp @@ -5,6 +5,8 @@ #include #include "ConfigLoader.h" +#include "InspectWindow.h" +#include "VisualsLoader.h" BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig, GameConfig gameConfig, @@ -16,7 +18,10 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig, , m_configDir(configDir) , m_balancingConfigPath(balancingConfigPath) , m_nextSeed(0) + , m_inspectWindow(nullptr) + , m_inspectedArenaIndex(-1) { + m_visuals = VisualsLoader::load(m_configDir + "/visuals.toml"); setWindowTitle("DotaFactory — Balancing Tool"); resize(800, 600); @@ -48,6 +53,13 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig, BalancingWindow::~BalancingWindow() { m_pollTimer->stop(); + if (m_inspectWindow) + { + m_inspectWindow->disconnect(this); + delete m_inspectWindow; + m_inspectWindow = nullptr; + } + m_inspectedSim.reset(); stopAllArenas(); } @@ -76,6 +88,8 @@ void BalancingWindow::populateArenas(const BalancingConfig& balancingConfig) connect(entry.widget, &ArenaWidget::startRequested, this, [this, index]() { startArena(index); }); + connect(entry.widget, &ArenaWidget::inspectRequested, + this, [this, index]() { inspectArena(index); }); m_arenas.push_back(std::move(entry)); } @@ -111,6 +125,13 @@ void BalancingWindow::pollStatuses() entry.widget->updateStatus(status); } } + + if (m_inspectedSim && m_inspectedArenaIndex >= 0) + { + const ArenaStatus status = m_inspectedSim->status(); + m_arenas[static_cast(m_inspectedArenaIndex)].widget->updateStatus(status); + } + updateButtons(); } @@ -154,8 +175,93 @@ void BalancingWindow::startArena(int index) updateButtons(); } +void BalancingWindow::inspectArena(int index) +{ + if (m_inspectWindow) + { + m_inspectWindow->disconnect(this); + delete m_inspectWindow; + m_inspectWindow = nullptr; + + if (m_inspectedSim && m_inspectedArenaIndex >= 0 + && !m_inspectedSim->isFinished()) + { + m_arenas[static_cast(m_inspectedArenaIndex)].widget->resetToGrey(); + } + m_inspectedSim.reset(); + m_inspectedArenaIndex = -1; + } + + ArenaEntry& entry = m_arenas[static_cast(index)]; + + if (entry.worker.joinable()) + { + entry.simulation->requestStop(); + entry.worker.join(); + } + + m_inspectedSim = std::make_unique( + m_gameConfig, entry.config, m_nextSeed++); + m_inspectedArenaIndex = index; + + entry.widget->resetToGrey(); + entry.widget->startSimulation(); + entry.widget->updateStatus(m_inspectedSim->status()); + + m_inspectWindow = new InspectWindow( + m_inspectedSim.get(), &m_visuals, entry.config.name, nullptr); + connect(m_inspectWindow, &InspectWindow::closed, + this, &BalancingWindow::closeInspectWindow); + + setMainControlsEnabled(false); + m_inspectWindow->show(); +} + +void BalancingWindow::closeInspectWindow() +{ + if (!m_inspectWindow) + { + return; + } + + m_inspectWindow->disconnect(this); + m_inspectWindow->deleteLater(); + m_inspectWindow = nullptr; + + if (m_inspectedArenaIndex >= 0 && m_inspectedSim) + { + if (!m_inspectedSim->isFinished()) + { + m_arenas[static_cast(m_inspectedArenaIndex)].widget->resetToGrey(); + } + } + + m_inspectedSim.reset(); + m_inspectedArenaIndex = -1; + setMainControlsEnabled(true); + updateButtons(); +} + +void BalancingWindow::setMainControlsEnabled(bool enabled) +{ + m_reloadButton->setEnabled(enabled); + m_startAllButton->setEnabled(enabled); + for (ArenaEntry& entry : m_arenas) + { + for (QPushButton* btn : entry.widget->findChildren()) + { + btn->setEnabled(enabled); + } + } +} + void BalancingWindow::updateButtons() { + if (m_inspectWindow) + { + return; + } + bool anyRunning = false; bool allRunning = true; for (ArenaEntry& entry : m_arenas) diff --git a/src/balancing/BalancingWindow.h b/src/balancing/BalancingWindow.h index c0276bb..eb74fb6 100644 --- a/src/balancing/BalancingWindow.h +++ b/src/balancing/BalancingWindow.h @@ -14,6 +14,9 @@ #include "ArenaSimulation.h" #include "BalancingConfig.h" #include "GameConfig.h" +#include "VisualsConfig.h" + +class InspectWindow; class BalancingWindow : public QWidget { @@ -32,11 +35,14 @@ private slots: void reloadConfig(); void startAll(); void startArena(int index); + void inspectArena(int index); + void closeInspectWindow(); private: void populateArenas(const BalancingConfig& balancingConfig); void stopAllArenas(); void updateButtons(); + void setMainControlsEnabled(bool enabled); struct ArenaEntry { @@ -48,6 +54,7 @@ private: std::vector m_arenas; GameConfig m_gameConfig; + VisualsConfig m_visuals; std::string m_configDir; std::string m_balancingConfigPath; unsigned int m_nextSeed; @@ -55,4 +62,8 @@ private: QPushButton* m_startAllButton; QScrollArea* m_scrollArea; QTimer* m_pollTimer; + + InspectWindow* m_inspectWindow; + int m_inspectedArenaIndex; + std::unique_ptr m_inspectedSim; }; diff --git a/src/balancing/CMakeLists.txt b/src/balancing/CMakeLists.txt index d565819..3fbc8b1 100644 --- a/src/balancing/CMakeLists.txt +++ b/src/balancing/CMakeLists.txt @@ -2,8 +2,12 @@ SET(HDRS ${HDRS} ${CMAKE_CURRENT_SOURCE_DIR}/ArenaWidget.h ${CMAKE_CURRENT_SOURCE_DIR}/ArenaSimulation.h + ${CMAKE_CURRENT_SOURCE_DIR}/ArenaView.h ${CMAKE_CURRENT_SOURCE_DIR}/BalancingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h PARENT_SCOPE ) @@ -12,7 +16,10 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ArenaWidget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ArenaSimulation.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ArenaView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BalancingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp PARENT_SCOPE ) diff --git a/src/balancing/InspectWindow.cpp b/src/balancing/InspectWindow.cpp new file mode 100644 index 0000000..a264a4b --- /dev/null +++ b/src/balancing/InspectWindow.cpp @@ -0,0 +1,187 @@ +#include "InspectWindow.h" + +#include + +#include +#include +#include +#include +#include + +#include "ArenaView.h" + +const double InspectWindow::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 4.0 }; +const int InspectWindow::kSpeedCount = 5; + +InspectWindow::InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals, + const std::string& arenaName, QWidget* parent) + : QWidget(parent) + , m_sim(sim) +{ + setWindowTitle(QString("Inspect \u2014 %1").arg(QString::fromStdString(arenaName))); + resize(900, 700); + setAttribute(Qt::WA_DeleteOnClose, false); + + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(0); + + // Header: arena name + speed buttons + { + QWidget* header = new QWidget(this); + QHBoxLayout* headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(8, 4, 8, 4); + headerLayout->setSpacing(8); + + QLabel* nameLabel = new QLabel(QString::fromStdString(arenaName), header); + QFont nameFont = nameLabel->font(); + nameFont.setBold(true); + nameFont.setPointSize(nameFont.pointSize() + 2); + nameLabel->setFont(nameFont); + headerLayout->addWidget(nameLabel); + + headerLayout->addStretch(); + + const char* labels[] = { "0x", "0.5x", "1x", "2x", "4x" }; + QSignalMapper* mapper = new QSignalMapper(this); + for (int i = 0; i < kSpeedCount; ++i) + { + QPushButton* btn = new QPushButton(labels[i], header); + btn->setCheckable(true); + btn->setChecked(i == 2); + headerLayout->addWidget(btn); + m_speedButtons.push_back(btn); + mapper->setMapping(btn, i); + connect(btn, &QPushButton::clicked, + mapper, qOverload<>(&QSignalMapper::map)); + } + connect(mapper, qOverload(&QSignalMapper::mapped), + this, &InspectWindow::onSpeedButton); + + header->setFixedHeight(header->sizeHint().height()); + mainLayout->addWidget(header); + } + + // Arena view (center, stretch) + m_arenaView = new ArenaView(sim, visuals, this); + mainLayout->addWidget(m_arenaView, 1); + + connect(m_arenaView, &ArenaView::speedChanged, + this, &InspectWindow::onSpeedChanged); + + // Info panel (bottom) + { + QWidget* infoPanel = new QWidget(this); + QHBoxLayout* infoLayout = new QHBoxLayout(infoPanel); + infoLayout->setContentsMargins(8, 4, 8, 4); + infoLayout->setSpacing(16); + + QVBoxLayout* team1Layout = new QVBoxLayout(); + m_team1Header = new QLabel(infoPanel); + QFont headerFont = m_team1Header->font(); + headerFont.setBold(true); + m_team1Header->setFont(headerFont); + team1Layout->addWidget(m_team1Header); + m_team1Content = new QLabel(infoPanel); + team1Layout->addWidget(m_team1Content); + team1Layout->addStretch(); + infoLayout->addLayout(team1Layout); + + QVBoxLayout* team2Layout = new QVBoxLayout(); + m_team2Header = new QLabel(infoPanel); + m_team2Header->setFont(headerFont); + team2Layout->addWidget(m_team2Header); + m_team2Content = new QLabel(infoPanel); + team2Layout->addWidget(m_team2Content); + team2Layout->addStretch(); + infoLayout->addLayout(team2Layout); + + mainLayout->addWidget(infoPanel); + } + + // Poll timer for info panel updates + m_pollTimer = new QTimer(this); + connect(m_pollTimer, &QTimer::timeout, this, &InspectWindow::pollStatus); + m_pollTimer->start(100); + + // Show initial status + pollStatus(); + + setFocusPolicy(Qt::StrongFocus); +} + +void InspectWindow::closeEvent(QCloseEvent* event) +{ + m_pollTimer->stop(); + emit closed(); + event->accept(); +} + +void InspectWindow::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Space) + { + m_arenaView->togglePause(); + } + else + { + QWidget::keyPressEvent(event); + } +} + +void InspectWindow::onSpeedButton(int index) +{ + if (index >= 0 && index < kSpeedCount) + { + m_arenaView->setGameSpeed(kSpeeds[index]); + } +} + +void InspectWindow::onSpeedChanged(double multiplier) +{ + for (int i = 0; i < kSpeedCount; ++i) + { + const bool active = (std::abs(kSpeeds[i] - multiplier) < 0.001); + m_speedButtons[static_cast(i)]->setChecked(active); + } +} + +void InspectWindow::pollStatus() +{ + const ArenaStatus status = m_sim->status(); + updateInfoPanel(status); +} + +void InspectWindow::updateInfoPanel(const ArenaStatus& status) +{ + for (int ti = 0; ti < 2; ++ti) + { + const ArenaStatus::TeamStatus& team = status.teams[ti]; + QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; + QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; + + if (status.finished && status.winnerTeam == ti) + { + header->setText("[WON] " + QString::fromStdString(team.name)); + } + else + { + header->setText(QString::fromStdString(team.name)); + } + + QString lines; + for (const ArenaStatus::Entry& entry : team.entries) + { + if (!lines.isEmpty()) + { + lines += "\n"; + } + lines += QString("%1/%2 %3 L%4") + .arg(entry.surviving) + .arg(entry.total) + .arg(QString::fromStdString(entry.displayName)) + .arg(entry.level); + } + content->setText(lines); + } +} diff --git a/src/balancing/InspectWindow.h b/src/balancing/InspectWindow.h new file mode 100644 index 0000000..0ecf771 --- /dev/null +++ b/src/balancing/InspectWindow.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "ArenaSimulation.h" +#include "VisualsConfig.h" + +class ArenaView; + +class InspectWindow : public QWidget +{ + Q_OBJECT + +public: + InspectWindow(ArenaSimulation* sim, const VisualsConfig* visuals, + const std::string& arenaName, QWidget* parent = nullptr); + +signals: + void closed(); + +protected: + void closeEvent(QCloseEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; + +private slots: + void onSpeedButton(int index); + void onSpeedChanged(double multiplier); + void pollStatus(); + +private: + void updateInfoPanel(const ArenaStatus& status); + + ArenaSimulation* m_sim; + ArenaView* m_arenaView; + + std::vector m_speedButtons; + QLabel* m_team1Header; + QLabel* m_team2Header; + QLabel* m_team1Content; + QLabel* m_team2Content; + QTimer* m_pollTimer; + + static const double kSpeeds[]; + static const int kSpeedCount; +};