From c9f7e5018f2cdb154b47cb6a6c836584fd5761fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?koukaku01=20=E8=A1=8C=E5=AE=A2?= Date: Wed, 16 Sep 2026 17:11:19 +0100 Subject: [PATCH 1/2] feat(simulation): add fixed-step simulation timing --- CMakeLists.txt | 2 + .../EcoSimEngine/scene/Scene_Simulation.hpp | 6 + .../simulation/SimulationClock.hpp | 43 +++++++ resources/defaults/default_simulation.json | 4 +- src/scene/Scene_Simulation.cpp | 97 +++++++++++++--- src/simulation/SimulationClock.cpp | 108 ++++++++++++++++++ tests/foundation_tests.cpp | 107 +++++++++++++++++ 7 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 include/EcoSimEngine/simulation/SimulationClock.hpp create mode 100644 src/simulation/SimulationClock.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c5943f..3177379 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ if(ECOSIM_BUILD_APP) src/scene/Scene.cpp src/scene/Scene_Menu.cpp src/scene/Scene_Simulation.cpp + src/simulation/SimulationClock.cpp src/simulation/SimulationWorld.cpp ) @@ -96,6 +97,7 @@ if(BUILD_TESTING) add_executable( EcoSimEngineFoundationTests tests/foundation_tests.cpp + src/simulation/SimulationClock.cpp ) target_compile_features(EcoSimEngineFoundationTests PRIVATE cxx_std_20) target_include_directories(EcoSimEngineFoundationTests PRIVATE include) diff --git a/include/EcoSimEngine/scene/Scene_Simulation.hpp b/include/EcoSimEngine/scene/Scene_Simulation.hpp index 7a05717..3f1e6b6 100644 --- a/include/EcoSimEngine/scene/Scene_Simulation.hpp +++ b/include/EcoSimEngine/scene/Scene_Simulation.hpp @@ -9,15 +9,21 @@ #include "EcoSimEngine/component/Components.hpp" #include "EcoSimEngine/scene/Scene.hpp" +#include "EcoSimEngine/simulation/SimulationClock.hpp" #include "EcoSimEngine/simulation/SimulationWorld.hpp" class Scene_Simulation : public Scene { private: std::string m_simKey; + SimulationWorld m_world; + SimulationClock m_simulationClock; + const std::string m_defaultSimulationPath{ "resources/defaults/default_simulation.json" }; std::unordered_map m_speciesColors; + + // measure real time elapsed between frames for simulation update sf::Clock m_clock; protected: diff --git a/include/EcoSimEngine/simulation/SimulationClock.hpp b/include/EcoSimEngine/simulation/SimulationClock.hpp new file mode 100644 index 0000000..4ec941d --- /dev/null +++ b/include/EcoSimEngine/simulation/SimulationClock.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +class SimulationClock +{ + static constexpr double EPSILON = 1e-12; + + double m_fixedStep{1.0 / 60.0}; + double m_accumulator{0.0}; + double m_speed{1.0}; + + std::uint64_t m_tick{0}; + std::uint64_t m_maxTicks{100000}; + +public: + SimulationClock() = default; + + SimulationClock( + double fixedStep, + std::uint64_t maxTicks); + + void configure( + double fixedStep, + std::uint64_t maxTicks); + + void reset() noexcept; + + void addElapsed(double realDeltaSeconds); + + [[nodiscard]] bool canStep() const noexcept; + void consumeStep(); + + void setSpeed(double speed); + + [[nodiscard]] double fixedStep() const noexcept; + [[nodiscard]] double speed() const noexcept; + + [[nodiscard]] std::uint64_t tick() const noexcept; + [[nodiscard]] std::uint64_t maxTicks() const noexcept; + + [[nodiscard]] bool finished() const noexcept; +}; \ No newline at end of file diff --git a/resources/defaults/default_simulation.json b/resources/defaults/default_simulation.json index 71dc9e9..7137f64 100644 --- a/resources/defaults/default_simulation.json +++ b/resources/defaults/default_simulation.json @@ -17,8 +17,8 @@ "width": 1000, "height": 1000 }, - "timeStep": 1.0, + "timeStep": 0.0166666667, "maxTicks": 100000 } } -} +} \ No newline at end of file diff --git a/src/scene/Scene_Simulation.cpp b/src/scene/Scene_Simulation.cpp index 234e32a..0bade9e 100644 --- a/src/scene/Scene_Simulation.cpp +++ b/src/scene/Scene_Simulation.cpp @@ -117,8 +117,21 @@ void Scene_Simulation::spawnFromJson(const nlohmann::json &simJson) auto &em = m_world.entityManager(); auto &cm = m_world.componentManager(); - // read population counts - auto populations = simJson["simulation"]["initialPopulation"]; + const auto &simulationJson = simJson.at("simulation"); + const auto &worldJson = simulationJson.at("world"); + + const double fixedStep = + worldJson.at("timeStep").get(); + + const std::uint64_t maxTicks = + worldJson.at("maxTicks").get(); + + m_simulationClock.configure( + fixedStep, + maxTicks); + + const auto &populations = + simulationJson.at("initialPopulation"); // --- set up RNG once per load --- std::random_device rd; @@ -194,24 +207,50 @@ void Scene_Simulation::sDoAction(const Action &action) if (action.name() == ActionName::QUIT_AND_SAVE) { onEnd(); + return; + } + + if (action.name() == ActionName::PAUSE) + { + setPaused(!m_paused); + return; } } void Scene_Simulation::update() { - float dt = m_clock.restart().asSeconds(); + const float realDeltaSeconds = m_clock.restart().asSeconds(); - if (dt <= 0.0f) + // Always restart the real-time clock, including while paused. + // Otherwise time spent paused would be accumulated and processed + // as a large catch-up burst after unpausing. + if (m_paused) { - dt = 1.0f / 60.0f; + return; } - // update when not paused - if (!m_paused) + if (m_simulationClock.finished()) { - m_world.update(dt); - m_currentFrame++; + return; } + + m_simulationClock.addElapsed( + static_cast(realDeltaSeconds)); + + while (m_simulationClock.canStep()) + { + m_world.update( + static_cast(m_simulationClock.fixedStep())); + + m_simulationClock.consumeStep(); + } + + // Keep the legacy Scene frame counter aligned with simulation + // progression for now. SimulationClock::tick() is the authoritative + // simulation counter. + m_currentFrame = + static_cast( + m_simulationClock.tick()); } void Scene_Simulation::onEnd() @@ -259,11 +298,43 @@ void Scene_Simulation::sRender() void Scene_Simulation::onGui() { // Example overlay window showing simulation info - ImGui::Begin("Simulation Info", nullptr, ImGuiWindowFlags_AlwaysAutoResize); + ImGui::Begin( + "Simulation Info ", + nullptr, + ImGuiWindowFlags_AlwaysAutoResize); + + ImGui::Text( + "Tick: %llu / %llu", + static_cast(m_simulationClock.tick()), + static_cast(m_simulationClock.maxTicks()) + ); + + ImGui::Text( + "Fixed timestep: %.6f s", + m_simulationClock.fixedStep()); + + ImGui::Text( + "Paused: %s", + m_paused ? "yes" : "no"); + + if (m_simulationClock.finished()) + { + ImGui::Text("Simulation finished."); + } + + float simulationSped = static_cast(m_simulationClock.speed()); + + if (ImGui::SliderFloat( + "Simulation Speed", + &simulationSped, + 0.25f, + 4.0f, + "%.2fx")) + { + m_simulationClock.setSpeed(static_cast(simulationSped)); + } - // Frame/time info - ImGui::Text("Frame: %zu", m_currentFrame); - ImGui::Text("Delta time: %.3f s", m_clock.getElapsedTime().asSeconds()); + ImGui::Separator(); // Mouse info ImGui::Text("Mouse Position: (%.1f, %.1f)", m_mousePos.x, m_mousePos.y); diff --git a/src/simulation/SimulationClock.cpp b/src/simulation/SimulationClock.cpp new file mode 100644 index 0000000..919767a --- /dev/null +++ b/src/simulation/SimulationClock.cpp @@ -0,0 +1,108 @@ +#include "EcoSimEngine/simulation/SimulationClock.hpp" + +#include +#include + +SimulationClock::SimulationClock( + double fixedStep, + std::uint64_t maxTicks) +{ + configure(fixedStep, maxTicks); +} + +void SimulationClock::configure( + double fixedStep, + std::uint64_t maxTicks) +{ + if (!std::isfinite(fixedStep) || fixedStep <= 0.0) + { + throw std::invalid_argument( + "SimulationClock fixed step must be a positive finite value"); + } + + m_fixedStep = fixedStep; + m_maxTicks = maxTicks; + + reset(); +} + +void SimulationClock::reset() noexcept +{ + m_accumulator = 0.0; + m_tick = 0; +} + +void SimulationClock::addElapsed(double realDeltaSeconds) +{ + if (!std::isfinite(realDeltaSeconds) || realDeltaSeconds < 0.0) + { + throw std::invalid_argument( + "SimulationClock elapsed time must be a non-negative finite value"); + } + + m_accumulator += realDeltaSeconds * m_speed; +} + +bool SimulationClock::canStep() const noexcept +{ + if (finished()) + { + return false; + } + + return m_accumulator + EPSILON >= m_fixedStep; +} + +void SimulationClock::consumeStep() +{ + if (!canStep()) + { + throw std::logic_error( + "SimulationClock cannot consume a step when no step is available"); + } + + m_accumulator -= m_fixedStep; + + if (m_accumulator < 0.0) + { + m_accumulator = 0.0; + } + + ++m_tick; +} + +void SimulationClock::setSpeed(double speed) +{ + if (!std::isfinite(speed) || speed <= 0.0) + { + throw std::invalid_argument( + "SimulationClock speed must be a positive finite value"); + } + + m_speed = speed; +} + +double SimulationClock::fixedStep() const noexcept +{ + return m_fixedStep; +} + +double SimulationClock::speed() const noexcept +{ + return m_speed; +} + +std::uint64_t SimulationClock::tick() const noexcept +{ + return m_tick; +} + +std::uint64_t SimulationClock::maxTicks() const noexcept +{ + return m_maxTicks; +} + +bool SimulationClock::finished() const noexcept +{ + return m_tick >= m_maxTicks; +} \ No newline at end of file diff --git a/tests/foundation_tests.cpp b/tests/foundation_tests.cpp index 0eccf9d..a5fa0f2 100644 --- a/tests/foundation_tests.cpp +++ b/tests/foundation_tests.cpp @@ -4,6 +4,7 @@ #include "EcoSimEngine/system/System.hpp" #include "EcoSimEngine/system/SystemManager.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" +#include "EcoSimEngine/simulation/SimulationClock.hpp" #include #include @@ -179,6 +180,111 @@ void testEventBus() { require(total == 32, "unsubscribe must remove only the selected subscription"); } +void testSimulationClock() +{ + // A. Not enough elapsed time for one fixed step. + SimulationClock clock(0.1, 10); + + clock.addElapsed(0.05); + + require( + !clock.canStep(), + "clock must not step before enough time has accumulated"); + + require( + clock.tick() == 0, + "tick must remain zero before a step is consumed"); + + + // B. Accumulated frame time should eventually produce one step. + clock.addElapsed(0.05); + + require( + clock.canStep(), + "accumulated elapsed time must make one step available"); + + clock.consumeStep(); + + require( + clock.tick() == 1, + "consuming one step must increment the simulation tick"); + + require( + !clock.canStep(), + "consuming the available step must remove it from the accumulator"); + + + // C. One frame may produce multiple fixed simulation steps. + clock.addElapsed(0.25); + + int stepsConsumed = 0; + + while (clock.canStep()) + { + clock.consumeStep(); + ++stepsConsumed; + } + + require( + stepsConsumed == 2, + "0.25 seconds with a 0.1 second fixed step must produce two complete steps"); + + require( + clock.tick() == 3, + "simulation tick must count every consumed fixed step"); + + + // D. Speed changes accumulation rate, not fixed-step size. + SimulationClock fastClock(0.1, 10); + + fastClock.setSpeed(2.0); + fastClock.addElapsed(0.05); + + require( + fastClock.canStep(), + "2x speed must turn 0.05 real seconds into 0.1 simulation seconds"); + + require( + fastClock.fixedStep() == 0.1, + "changing simulation speed must not change the fixed timestep"); + + fastClock.consumeStep(); + + require( + fastClock.tick() == 1, + "2x speed clock must consume the resulting simulation step normally"); + + + // E. maxTicks is a hard upper bound. + SimulationClock limitedClock(0.1, 3); + + limitedClock.addElapsed(1.0); + + int limitedStepsConsumed = 0; + + while (limitedClock.canStep()) + { + limitedClock.consumeStep(); + ++limitedStepsConsumed; + } + + require( + limitedStepsConsumed == 3, + "clock must stop after maxTicks even when more accumulated time remains"); + + require( + limitedClock.tick() == 3, + "tick must stop exactly at maxTicks"); + + require( + limitedClock.finished(), + "clock must report finished after reaching maxTicks"); + + require( + !limitedClock.canStep(), + "finished clock must not expose another simulation step"); +} + } // namespace int main() { @@ -187,6 +293,7 @@ int main() { testSystemManager(); testEntityDestruction(); testEventBus(); + testSimulationClock(); } catch (const std::exception& error) { std::cerr << "foundation test failure: " << error.what() << '\n'; return 1; From 3e049babcae809251fcf771a5f19cc7e371ba43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?koukaku01=20=E8=A1=8C=E5=AE=A2?= Date: Wed, 16 Sep 2026 20:43:49 +0100 Subject: [PATCH 2/2] feat(simulation): make randomness seed-controlled --- CMakeLists.txt | 1 + .../simulation/SimulationWorld.hpp | 15 +- include/EcoSimEngine/system/AISystem.hpp | 42 +- include/EcoSimEngine/utils/ColorUtils.hpp | 26 + include/EcoSimEngine/utils/Random.hpp | 26 + include/EcoSimEngine/utils/Utils.hpp | 37 -- resources/defaults/default_simulation.json | 1 + src/scene/Scene_Simulation.cpp | 111 ++-- src/simulation/SimulationWorld.cpp | 12 +- tests/foundation_tests.cpp | 515 +++++++++++------- 10 files changed, 491 insertions(+), 295 deletions(-) create mode 100644 include/EcoSimEngine/utils/ColorUtils.hpp create mode 100644 include/EcoSimEngine/utils/Random.hpp delete mode 100644 include/EcoSimEngine/utils/Utils.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3177379..0816331 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,7 @@ if(BUILD_TESTING) EcoSimEngineFoundationTests tests/foundation_tests.cpp src/simulation/SimulationClock.cpp + src/simulation/SimulationWorld.cpp ) target_compile_features(EcoSimEngineFoundationTests PRIVATE cxx_std_20) target_include_directories(EcoSimEngineFoundationTests PRIVATE include) diff --git a/include/EcoSimEngine/simulation/SimulationWorld.hpp b/include/EcoSimEngine/simulation/SimulationWorld.hpp index 4421bf7..5b78205 100644 --- a/include/EcoSimEngine/simulation/SimulationWorld.hpp +++ b/include/EcoSimEngine/simulation/SimulationWorld.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "EcoSimEngine/component/ComponentManager.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" #include "EcoSimEngine/system/SystemManager.hpp" @@ -11,12 +14,18 @@ class SimulationWorld ComponentManager m_componentManager; EntityManager m_entityManager; + std::mt19937 m_rng{std::mt19937::default_seed}; + public: SimulationWorld(); void update(float dt); - EntityManager& entityManager() noexcept; - ComponentManager& componentManager() noexcept; - SystemManager& systemManager() noexcept; + void reseed(std::uint32_t seed); + + [[nodiscard]] std::mt19937& rng() noexcept; + + [[nodiscard]] EntityManager& entityManager() noexcept; + [[nodiscard]] ComponentManager& componentManager() noexcept; + [[nodiscard]] SystemManager& systemManager() noexcept; }; diff --git a/include/EcoSimEngine/system/AISystem.hpp b/include/EcoSimEngine/system/AISystem.hpp index ad41845..cb699b7 100644 --- a/include/EcoSimEngine/system/AISystem.hpp +++ b/include/EcoSimEngine/system/AISystem.hpp @@ -1,35 +1,45 @@ #pragma once -#include "EcoSimEngine/system/System.hpp" +#include + #include "EcoSimEngine/ecs/Entity.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" #include "EcoSimEngine/math/Vec2.hpp" -#include "EcoSimEngine/utils/Utils.hpp" - +#include "EcoSimEngine/system/System.hpp" +#include "EcoSimEngine/utils/Random.hpp" -class AISystem : public System { +class AISystem : public System +{ public: - void update(EntityManager& em, ComponentManager& cm, float dt) + void update( + EntityManager &em, + ComponentManager &cm, + float dt, + std::mt19937 &rng) { for (EntityId id : mEntities) { auto e = em.getEntityById(id); - if (!e || !e->isActive()) continue; - if (!cm.has(id) || !cm.has(id)) continue; - - auto& behavior = cm.get(id); - auto& t = cm.get(id); + if (!e || !e->isActive()) + continue; + if (!cm.has(id) || !cm.has(id)) + continue; + + auto &behavior = cm.get(id); + auto &transform = cm.get(id); behavior.stateTimer -= dt; - if (behavior.stateTimer <= 0.0f) { - behavior.stateTimer = randomFloat(1.0f, 5.0f); + if (behavior.stateTimer <= 0.0f) + { + behavior.stateTimer = randomFloat(rng, 1.0f, 5.0f); behavior.current = BehaviorState::Wander; } - if (behavior.current == BehaviorState::Wander && t.velocity.length() < 0.1f) { - Vec2f dir = randomUnitVector(); - t.velocity = dir * behavior.movementSpeed; + if (behavior.current == BehaviorState::Wander && transform.velocity.length() < 0.1f) + { + const Vec2f direction = randomUnitVector(rng); + transform.velocity = direction * behavior.movementSpeed; } } } -}; \ No newline at end of file +}; diff --git a/include/EcoSimEngine/utils/ColorUtils.hpp b/include/EcoSimEngine/utils/ColorUtils.hpp new file mode 100644 index 0000000..ec256f0 --- /dev/null +++ b/include/EcoSimEngine/utils/ColorUtils.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +inline sf::Color hslToRgb(float h, float s, float l) { + float c = (1.0f - std::fabs(2.0f * l - 1.0f)) * s; + float x = c * (1.0f - std::fabs(std::fmod(h / 60.0f, 2.0f) - 1.0f)); + const float m = l - c / 2.0f; + float r = 0.0f, g = 0.0f, b = 0.0f; + + if (h < 60.0f) { r = c; g = x; } + else if (h < 120.0f) { r = x; g = c; } + else if (h < 180.0f) { g = c; b = x; } + else if (h < 240.0f) { g = x; b = c; } + else if (h < 300.0f) { r = x; b = c; } + else { r = c; b = x; } + + return sf::Color( + static_cast((r + m) * 255.0f), + static_cast((g + m) * 255.0f), + static_cast((b + m) * 255.0f) + ); +} + diff --git a/include/EcoSimEngine/utils/Random.hpp b/include/EcoSimEngine/utils/Random.hpp new file mode 100644 index 0000000..5ae514e --- /dev/null +++ b/include/EcoSimEngine/utils/Random.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "EcoSimEngine/math/Vec2.hpp" + +inline float randomFloat( + std::mt19937 &rng, + float min, + float max) +{ + std::uniform_real_distribution distribution(min, max); + + return distribution(rng); +} + +inline Vec2f randomUnitVector(std::mt19937& rng) +{ + const float angle = randomFloat( + rng, + 0.0f, + 2.0f * 3.14159265358979323846f); + + return Vec2f{ std::cos(angle), std::sin(angle) }; +} diff --git a/include/EcoSimEngine/utils/Utils.hpp b/include/EcoSimEngine/utils/Utils.hpp deleted file mode 100644 index 02f5494..0000000 --- a/include/EcoSimEngine/utils/Utils.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include "EcoSimEngine/math/Vec2.hpp" -#include -#include -#include - -inline sf::Color hslToRgb(float h, float s, float l) { - float c = (1 - std::fabs(2 * l - 1)) * s; - float x = c * (1 - std::fabs(fmod(h / 60.0f, 2) - 1)); - float m = l - c / 2; - float r = 0, g = 0, b = 0; - - if (h < 60) { r = c; g = x; } - else if (h < 120) { r = x; g = c; } - else if (h < 180) { g = c; b = x; } - else if (h < 240) { g = x; b = c; } - else if (h < 300) { r = x; b = c; } - else { r = c; b = x; } - - return sf::Color( - static_cast((r + m) * 255), - static_cast((g + m) * 255), - static_cast((b + m) * 255) - ); -} - -inline float randomFloat(float a, float b) { - static thread_local std::mt19937 gen((std::random_device())()); - std::uniform_real_distribution d(a, b); - return d(gen); -} - -inline Vec2f randomUnitVector() { - float a = randomFloat(0.0f, 2.0f * 3.14159265358979323846f); - return Vec2f{ std::cos(a), std::sin(a) }; -} diff --git a/resources/defaults/default_simulation.json b/resources/defaults/default_simulation.json index 7137f64..478a7ba 100644 --- a/resources/defaults/default_simulation.json +++ b/resources/defaults/default_simulation.json @@ -1,5 +1,6 @@ { "simulation": { + "seed": 48596, "initialPopulation": { "fox": { "total": 10, diff --git a/src/scene/Scene_Simulation.cpp b/src/scene/Scene_Simulation.cpp index 0bade9e..d4c03b3 100644 --- a/src/scene/Scene_Simulation.cpp +++ b/src/scene/Scene_Simulation.cpp @@ -1,8 +1,8 @@ +#include #include #include #include #include -#include #include "SFML/Graphics/CircleShape.hpp" #include "SFML/Graphics/RectangleShape.hpp" @@ -18,7 +18,9 @@ #include "EcoSimEngine/scene/Scene_Menu.hpp" #include "EcoSimEngine/scene/Scene_Simulation.hpp" #include "EcoSimEngine/math/Vec2.hpp" -#include "EcoSimEngine/utils/Utils.hpp" + +#include "EcoSimEngine/utils/ColorUtils.hpp" +#include "EcoSimEngine/utils/Random.hpp" Scene_Simulation::Scene_Simulation(SimulationEngine *engine, const std::string &simKey) : Scene(engine), m_simKey(simKey) @@ -115,57 +117,72 @@ void Scene_Simulation::loadDefaultSimulation(const std::string &defaultSimulatio void Scene_Simulation::spawnFromJson(const nlohmann::json &simJson) { auto &em = m_world.entityManager(); - auto &cm = m_world.componentManager(); const auto &simulationJson = simJson.at("simulation"); const auto &worldJson = simulationJson.at("world"); + // --- configure deterministic RNG --- + const std::uint32_t seed = + simulationJson.at("seed").get(); + + m_world.reseed(seed); + + // --- configure simulation clock --- const double fixedStep = worldJson.at("timeStep").get(); const std::uint64_t maxTicks = worldJson.at("maxTicks").get(); - m_simulationClock.configure( - fixedStep, - maxTicks); + m_simulationClock.configure(fixedStep, maxTicks); + + // --- load world dimensions --- + const float worldWidth = + worldJson.at("size").at("width").get(); + + const float worldHeight = + worldJson.at("size").at("height").get(); const auto &populations = simulationJson.at("initialPopulation"); - // --- set up RNG once per load --- - std::random_device rd; - std::mt19937 gen(rd()); - std::uniform_real_distribution distX( - 0, simJson["simulation"]["world"]["size"]["width"].get()); - std::uniform_real_distribution distY( - 0, simJson["simulation"]["world"]["size"]["height"].get()); - - int speciesCount = static_cast(populations.size()); + const int speciesCount = + static_cast(populations.size()); // Precompute species colours (single pass) int idx = 0; - for (auto &[speciesName, popData] : populations.items()) + + for (const auto &[speciesName, popData] : populations.items()) { - float hue = (idx * 360.0f) / std::max(1, speciesCount); // evenly spaced hues - m_speciesColors[speciesName] = hslToRgb(hue, 0.7f, 0.5f); // 70% sat, 50% lightness + const float hue = + (idx * 360.0f) / std::max(1, speciesCount); // evenly spaced hues + + m_speciesColors[speciesName] = + hslToRgb(hue, 0.7f, 0.5f); // 70% sat, 50% lightness + ++idx; } - for (auto &[speciesName, popData] : populations.items()) + for (const auto &[speciesName, popData] : populations.items()) { - int total = popData["total"]; - int males = popData["male"]; - int females = popData["female"]; + const int total = popData.at("total").get(); + const int males = popData.at("male").get(); + const int females = popData.at("female").get(); // load species data JSON (per-species data) - std::string speciesFile = "resources/definitions/species/" + speciesName + ".json"; + std::string speciesFile = + "resources/definitions/species/" + speciesName + ".json"; + std::ifstream sf(speciesFile); + if (!sf.is_open()) { - std::cerr << "Could not open species file: " << speciesFile << "\n"; + std::cerr << "Could not open species file: " + << speciesFile << '\n'; + continue; } + nlohmann::json speciesJson; sf >> speciesJson; @@ -180,19 +197,36 @@ void Scene_Simulation::spawnFromJson(const nlohmann::json &simJson) em.addComponent(entity, 100.0f); // proper random position - float x = distX(gen); - float y = distY(gen); - em.addComponent(entity, Vec2f(x, y)); + const float x = + randomFloat( + m_world.rng(), + 0.0f, + worldWidth); + + const float y = + randomFloat( + m_world.rng(), + 0.0f, + worldHeight); + + em.addComponent(entity, Vec2f{x, y}); // reproductive component - auto &repro = em.addComponent(entity); - repro.sex = (i < males ? Sex::Male : Sex::Female); - repro.canReproduce = true; + auto &reproductive = + em.addComponent(entity); + + reproductive.sex = + (i < males ? Sex::Male : Sex::Female); + + reproductive.canReproduce = true; - // TODO em.addComponent(entity); } - std::cout << "Loaded " << total << " " << speciesName << " entities.\n"; + + std::cout << "Loaded " + << total << " " + << speciesName + << " entities.\n"; } // Now flush pending entities -> this also calls EntitySignatureChanged for newly added entities @@ -306,8 +340,7 @@ void Scene_Simulation::onGui() ImGui::Text( "Tick: %llu / %llu", static_cast(m_simulationClock.tick()), - static_cast(m_simulationClock.maxTicks()) - ); + static_cast(m_simulationClock.maxTicks())); ImGui::Text( "Fixed timestep: %.6f s", @@ -325,11 +358,11 @@ void Scene_Simulation::onGui() float simulationSped = static_cast(m_simulationClock.speed()); if (ImGui::SliderFloat( - "Simulation Speed", - &simulationSped, - 0.25f, - 4.0f, - "%.2fx")) + "Simulation Speed", + &simulationSped, + 0.25f, + 4.0f, + "%.2fx")) { m_simulationClock.setSpeed(static_cast(simulationSped)); } diff --git a/src/simulation/SimulationWorld.cpp b/src/simulation/SimulationWorld.cpp index 72f8cad..b9dc9db 100644 --- a/src/simulation/SimulationWorld.cpp +++ b/src/simulation/SimulationWorld.cpp @@ -31,7 +31,7 @@ void SimulationWorld::update(float dt) if (auto ai = m_systemManager.GetSystem()) { - ai->update(m_entityManager, m_componentManager, dt); + ai->update(m_entityManager, m_componentManager, dt, m_rng); } if (auto movement = m_systemManager.GetSystem()) @@ -43,6 +43,16 @@ void SimulationWorld::update(float dt) } } +void SimulationWorld::reseed(std::uint32_t seed) +{ + m_rng.seed(seed); +} + +std::mt19937& SimulationWorld::rng() noexcept +{ + return m_rng; +} + // getters for the managers EntityManager& SimulationWorld::entityManager() noexcept { diff --git a/tests/foundation_tests.cpp b/tests/foundation_tests.cpp index a5fa0f2..f1ea870 100644 --- a/tests/foundation_tests.cpp +++ b/tests/foundation_tests.cpp @@ -1,300 +1,417 @@ +#include "EcoSimEngine/component/Components.hpp" #include "EcoSimEngine/component/ComponentManager.hpp" #include "EcoSimEngine/component/ComponentIndices.hpp" #include "EcoSimEngine/event/EventBus.hpp" #include "EcoSimEngine/system/System.hpp" #include "EcoSimEngine/system/SystemManager.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" +#include "EcoSimEngine/simulation/SimulationWorld.hpp" #include "EcoSimEngine/simulation/SimulationClock.hpp" #include #include -namespace { +namespace +{ + + struct Health + { + int value; + explicit Health(int initialValue) : value(initialValue) {} + }; + + struct SimulationSnapshot + { + float positionX, positionY; + float velocityX, velocityY; -struct Health { - int value; - explicit Health(int initialValue) : value(initialValue) {} -}; + BehaviorState behaviorState; + float stateTimer; + }; -class TransformSystem final : public System {}; -class UnregisteredSystem final : public System {}; + class TransformSystem final : public System + { + }; + class UnregisteredSystem final : public System + { + }; -void require(bool condition, const char* message) { - if (!condition) { - throw std::runtime_error(message); + void require(bool condition, const char *message) + { + if (!condition) + { + throw std::runtime_error(message); + } } -} -void testComponentManager() { - ComponentManager components; + // helper + SimulationSnapshot runDeterministicSimulation( + std::uint32_t seed) +{ + SimulationWorld world; + + world.reseed(seed); - require(!components.has(7), "new manager must not contain components"); + auto& entities = + world.entityManager(); - auto& health = components.add(7, 125); - require(health.value == 125, "added component must keep constructor state"); - require(components.has(7), "added component must be discoverable"); - require(&components.get(7) == &health, "get must return stored component"); + auto& components = + world.componentManager(); - bool duplicateRejected = false; - try { - static_cast(components.add(7, 90)); - } catch (const std::logic_error&) { - duplicateRejected = true; - } - require(duplicateRejected, "duplicate component insertion must be rejected"); + auto entity = + entities.addEntity("test"); - components.remove(7); - require(!components.has(7), "removed component must no longer exist"); + entities.addComponent( + entity, + Vec2f{0.0f, 0.0f}); + + entities.addComponent( + entity); + + entities.update(); - bool missingGetRejected = false; - try { - static_cast(components.get(7)); - } catch (const std::out_of_range&) { - missingGetRejected = true; + constexpr float fixedStep = + 1.0f / 60.0f; + + constexpr int ticks = 300; + + for (int i = 0; i < ticks; ++i) + { + world.update(fixedStep); } - require(missingGetRejected, "getting a missing component must fail explicitly"); + + const auto id = + entity->id(); + + const auto& transform = + components.get(id); + + const auto& behavior = + components.get(id); + + return SimulationSnapshot{ + transform.pos.x, + transform.pos.y, + transform.velocity.x, + transform.velocity.y, + behavior.current, + behavior.stateTimer}; } -void testSystemManager() { - SystemManager systems; - auto transformSystem = systems.RegisterSystem(); - - Signature required; - required.set(0); - systems.SetSignature(required); - - Signature entitySignature; - systems.EntitySignatureChanged(11, entitySignature); - require(!transformSystem->mEntities.contains(11), - "entity without required components must not join system"); - - entitySignature.set(0); - systems.EntitySignatureChanged(11, entitySignature); - require(transformSystem->mEntities.contains(11), - "matching entity must join system"); - - entitySignature.reset(0); - systems.EntitySignatureChanged(11, entitySignature); - require(!transformSystem->mEntities.contains(11), - "entity must leave system after signature stops matching"); - - bool duplicateRejected = false; - try { - static_cast(systems.RegisterSystem()); - } catch (const std::logic_error&) { - duplicateRejected = true; + void testComponentManager() + { + ComponentManager components; + + require(!components.has(7), "new manager must not contain components"); + + auto &health = components.add(7, 125); + require(health.value == 125, "added component must keep constructor state"); + require(components.has(7), "added component must be discoverable"); + require(&components.get(7) == &health, "get must return stored component"); + + bool duplicateRejected = false; + try + { + static_cast(components.add(7, 90)); + } + catch (const std::logic_error &) + { + duplicateRejected = true; + } + require(duplicateRejected, "duplicate component insertion must be rejected"); + + components.remove(7); + require(!components.has(7), "removed component must no longer exist"); + + bool missingGetRejected = false; + try + { + static_cast(components.get(7)); + } + catch (const std::out_of_range &) + { + missingGetRejected = true; + } + require(missingGetRejected, "getting a missing component must fail explicitly"); } - require(duplicateRejected, "duplicate system registration must fail in release builds too"); - bool unregisteredSignatureRejected = false; - try { - systems.SetSignature(required); - } catch (const std::logic_error&) { - unregisteredSignatureRejected = true; + void testSystemManager() + { + SystemManager systems; + auto transformSystem = systems.RegisterSystem(); + + Signature required; + required.set(0); + systems.SetSignature(required); + + Signature entitySignature; + systems.EntitySignatureChanged(11, entitySignature); + require(!transformSystem->mEntities.contains(11), + "entity without required components must not join system"); + + entitySignature.set(0); + systems.EntitySignatureChanged(11, entitySignature); + require(transformSystem->mEntities.contains(11), + "matching entity must join system"); + + entitySignature.reset(0); + systems.EntitySignatureChanged(11, entitySignature); + require(!transformSystem->mEntities.contains(11), + "entity must leave system after signature stops matching"); + + bool duplicateRejected = false; + try + { + static_cast(systems.RegisterSystem()); + } + catch (const std::logic_error &) + { + duplicateRejected = true; + } + require(duplicateRejected, "duplicate system registration must fail in release builds too"); + + bool unregisteredSignatureRejected = false; + try + { + systems.SetSignature(required); + } + catch (const std::logic_error &) + { + unregisteredSignatureRejected = true; + } + require(unregisteredSignatureRejected, + "setting a signature for an unregistered system must fail explicitly"); } - require(unregisteredSignatureRejected, - "setting a signature for an unregistered system must fail explicitly"); -} -void testEntityDestruction() { - SystemManager systems; - ComponentManager components; - EntityManager entities(systems, components); + void testEntityDestruction() + { + SystemManager systems; + ComponentManager components; + EntityManager entities(systems, components); - auto transformSystem = systems.RegisterSystem(); + auto transformSystem = systems.RegisterSystem(); - Signature required; - required.set(COMP_INDEX_CTransform); - systems.SetSignature(required); + Signature required; + required.set(COMP_INDEX_CTransform); + systems.SetSignature(required); - auto destroyedEntity = entities.addEntity("destroyed"); - auto survivingEntity = entities.addEntity("survivor"); + auto destroyedEntity = entities.addEntity("destroyed"); + auto survivingEntity = entities.addEntity("survivor"); - entities.addComponent(destroyedEntity); - entities.addComponent(destroyedEntity, 50.0f); + entities.addComponent(destroyedEntity); + entities.addComponent(destroyedEntity, 50.0f); - entities.addComponent(survivingEntity); + entities.addComponent(survivingEntity); - entities.update(); + entities.update(); - const auto destroyedId = destroyedEntity->id(); - const auto survivingId = survivingEntity->id(); + const auto destroyedId = destroyedEntity->id(); + const auto survivingId = survivingEntity->id(); - require(components.has(destroyedId), - "entity must have Transform before destruction"); + require(components.has(destroyedId), + "entity must have Transform before destruction"); - require(components.has(destroyedId), - "entity must have Energy before destruction"); + require(components.has(destroyedId), + "entity must have Energy before destruction"); - require(transformSystem->mEntities.contains(destroyedId), - "entity must belong to matching system before destruction"); + require(transformSystem->mEntities.contains(destroyedId), + "entity must belong to matching system before destruction"); - entities.destroyEntity(destroyedEntity); + entities.destroyEntity(destroyedEntity); - require(!destroyedEntity->isActive(), - "destroyed entity must become inactive"); + require(!destroyedEntity->isActive(), + "destroyed entity must become inactive"); - require(!components.has(destroyedId), - "destroying entity must remove Transform"); + require(!components.has(destroyedId), + "destroying entity must remove Transform"); - require(!components.has(destroyedId), - "destroying entity must remove Energy"); + require(!components.has(destroyedId), + "destroying entity must remove Energy"); - require(destroyedEntity->signature().none(), - "destroying entity must clear its component signature"); + require(destroyedEntity->signature().none(), + "destroying entity must clear its component signature"); - require(!transformSystem->mEntities.contains(destroyedId), - "destroying entity must remove it from systems"); + require(!transformSystem->mEntities.contains(destroyedId), + "destroying entity must remove it from systems"); - require(components.has(survivingId), - "destroying one entity must not remove another entity's components"); + require(components.has(survivingId), + "destroying one entity must not remove another entity's components"); - require(transformSystem->mEntities.contains(survivingId), - "destroying one entity must not remove another entity from systems"); + require(transformSystem->mEntities.contains(survivingId), + "destroying one entity must not remove another entity from systems"); - entities.update(); + entities.update(); - require(entities.getEntityById(destroyedId) == nullptr, - "destroyed entity must be removed from EntityManager after update"); + require(entities.getEntityById(destroyedId) == nullptr, + "destroyed entity must be removed from EntityManager after update"); - require(entities.getEntityById(survivingId) != nullptr, - "surviving entity must remain managed"); -} + require(entities.getEntityById(survivingId) != nullptr, + "surviving entity must remain managed"); + } -void testEventBus() { - EventBus bus; - int total = 0; + void testEventBus() + { + EventBus bus; + int total = 0; - const auto first = bus.subscribe([&](const int& value) { - total += value; - }); - bus.subscribe([&](const int& value) { - total += value * 10; - }); + const auto first = bus.subscribe([&](const int &value) + { total += value; }); + bus.subscribe([&](const int &value) + { total += value * 10; }); - bus.publish(2); - require(total == 22, "publish must notify all subscribers"); + bus.publish(2); + require(total == 22, "publish must notify all subscribers"); - bus.unsubscribe(first); - bus.publish(1); - require(total == 32, "unsubscribe must remove only the selected subscription"); -} + bus.unsubscribe(first); + bus.publish(1); + require(total == 32, "unsubscribe must remove only the selected subscription"); + } -void testSimulationClock() -{ - // A. Not enough elapsed time for one fixed step. - SimulationClock clock(0.1, 10); + void testSimulationClock() + { + // A. Not enough elapsed time for one fixed step. + SimulationClock clock(0.1, 10); - clock.addElapsed(0.05); + clock.addElapsed(0.05); - require( - !clock.canStep(), - "clock must not step before enough time has accumulated"); + require( + !clock.canStep(), + "clock must not step before enough time has accumulated"); - require( - clock.tick() == 0, - "tick must remain zero before a step is consumed"); + require( + clock.tick() == 0, + "tick must remain zero before a step is consumed"); + // B. Accumulated frame time should eventually produce one step. + clock.addElapsed(0.05); - // B. Accumulated frame time should eventually produce one step. - clock.addElapsed(0.05); + require( + clock.canStep(), + "accumulated elapsed time must make one step available"); - require( - clock.canStep(), - "accumulated elapsed time must make one step available"); + clock.consumeStep(); - clock.consumeStep(); + require( + clock.tick() == 1, + "consuming one step must increment the simulation tick"); - require( - clock.tick() == 1, - "consuming one step must increment the simulation tick"); + require( + !clock.canStep(), + "consuming the available step must remove it from the accumulator"); - require( - !clock.canStep(), - "consuming the available step must remove it from the accumulator"); + // C. One frame may produce multiple fixed simulation steps. + clock.addElapsed(0.25); + int stepsConsumed = 0; - // C. One frame may produce multiple fixed simulation steps. - clock.addElapsed(0.25); + while (clock.canStep()) + { + clock.consumeStep(); + ++stepsConsumed; + } - int stepsConsumed = 0; + require( + stepsConsumed == 2, + "0.25 seconds with a 0.1 second fixed step must produce two complete steps"); - while (clock.canStep()) - { - clock.consumeStep(); - ++stepsConsumed; - } + require( + clock.tick() == 3, + "simulation tick must count every consumed fixed step"); - require( - stepsConsumed == 2, - "0.25 seconds with a 0.1 second fixed step must produce two complete steps"); + // D. Speed changes accumulation rate, not fixed-step size. + SimulationClock fastClock(0.1, 10); - require( - clock.tick() == 3, - "simulation tick must count every consumed fixed step"); + fastClock.setSpeed(2.0); + fastClock.addElapsed(0.05); + require( + fastClock.canStep(), + "2x speed must turn 0.05 real seconds into 0.1 simulation seconds"); - // D. Speed changes accumulation rate, not fixed-step size. - SimulationClock fastClock(0.1, 10); + require( + fastClock.fixedStep() == 0.1, + "changing simulation speed must not change the fixed timestep"); - fastClock.setSpeed(2.0); - fastClock.addElapsed(0.05); + fastClock.consumeStep(); - require( - fastClock.canStep(), - "2x speed must turn 0.05 real seconds into 0.1 simulation seconds"); + require( + fastClock.tick() == 1, + "2x speed clock must consume the resulting simulation step normally"); - require( - fastClock.fixedStep() == 0.1, - "changing simulation speed must not change the fixed timestep"); + // E. maxTicks is a hard upper bound. + SimulationClock limitedClock(0.1, 3); - fastClock.consumeStep(); + limitedClock.addElapsed(1.0); - require( - fastClock.tick() == 1, - "2x speed clock must consume the resulting simulation step normally"); + int limitedStepsConsumed = 0; + while (limitedClock.canStep()) + { + limitedClock.consumeStep(); + ++limitedStepsConsumed; + } - // E. maxTicks is a hard upper bound. - SimulationClock limitedClock(0.1, 3); + require( + limitedStepsConsumed == 3, + "clock must stop after maxTicks even when more accumulated time remains"); - limitedClock.addElapsed(1.0); + require( + limitedClock.tick() == 3, + "tick must stop exactly at maxTicks"); - int limitedStepsConsumed = 0; + require( + limitedClock.finished(), + "clock must report finished after reaching maxTicks"); - while (limitedClock.canStep()) - { - limitedClock.consumeStep(); - ++limitedStepsConsumed; + require( + !limitedClock.canStep(), + "finished clock must not expose another simulation step"); } + void testDeterministicSimulation() +{ + constexpr std::uint32_t seed = 48596; + + const SimulationSnapshot first = + runDeterministicSimulation(seed); + + const SimulationSnapshot second = + runDeterministicSimulation(seed); + require( - limitedStepsConsumed == 3, - "clock must stop after maxTicks even when more accumulated time remains"); + first.positionX == second.positionX && + first.positionY == second.positionY, + "same seed and tick sequence must reproduce entity position"); require( - limitedClock.tick() == 3, - "tick must stop exactly at maxTicks"); + first.velocityX == second.velocityX && + first.velocityY == second.velocityY, + "same seed and tick sequence must reproduce entity velocity"); require( - limitedClock.finished(), - "clock must report finished after reaching maxTicks"); + first.behaviorState == second.behaviorState, + "same seed and tick sequence must reproduce behavior state"); require( - !limitedClock.canStep(), - "finished clock must not expose another simulation step"); + first.stateTimer == second.stateTimer, + "same seed and tick sequence must reproduce behavior timing"); } -} // namespace +} // namespace -int main() { - try { +int main() +{ + try + { testComponentManager(); testSystemManager(); testEntityDestruction(); testEventBus(); testSimulationClock(); - } catch (const std::exception& error) { + testDeterministicSimulation(); + } + catch (const std::exception &error) + { std::cerr << "foundation test failure: " << error.what() << '\n'; return 1; }