diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index 6244394..d094525 100644 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -4,6 +4,7 @@ SET(SRCS) add_subdirectory(core) add_subdirectory(config) add_subdirectory(utility) +add_subdirectory(sim) SET(HDRS ${HDRS} diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt new file mode 100644 index 0000000..e935faa --- /dev/null +++ b/src/lib/sim/CMakeLists.txt @@ -0,0 +1,19 @@ +SET(HDRS + ${HDRS} + ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h + ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h + PARENT_SCOPE +) + +SET(SRCS + ${SRCS} + ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp + PARENT_SCOPE +) + +set(LIB_INCLUDE_PATH + ${LIB_INCLUDE_PATH} + ${CMAKE_CURRENT_SOURCE_DIR} + PARENT_SCOPE +) diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp new file mode 100644 index 0000000..ac1966f --- /dev/null +++ b/src/lib/sim/Simulation.cpp @@ -0,0 +1,38 @@ +#include "Simulation.h" + +Simulation::Simulation(const GameConfig& config, unsigned int seed) + : m_config(config) + , m_rng(seed) + , m_currentTick(0) + , m_nextId(1) +{ +} + +void Simulation::tick() +{ + ++m_currentTick; +} + +std::vector Simulation::drainFireEvents() +{ + std::vector result; + result.swap(m_fireEvents); + return result; +} + +std::vector Simulation::drainBlueprintDropEvents() +{ + std::vector result; + result.swap(m_blueprintDropEvents); + return result; +} + +Tick Simulation::currentTick() const +{ + return m_currentTick; +} + +EntityId Simulation::allocateId() +{ + return m_nextId++; +} diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h new file mode 100644 index 0000000..7595b60 --- /dev/null +++ b/src/lib/sim/Simulation.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#include "EntityId.h" +#include "FireEvent.h" +#include "BlueprintDropEvent.h" +#include "GameConfig.h" +#include "Tick.h" + +class Simulation +{ +public: + explicit Simulation(const GameConfig& config, unsigned int seed = 0); + + // Advances the simulation by one tick. Tick order per architecture.md §Tick Order. + // Currently a stub; subsystems are plugged in across Steps 3-7. + void tick(); + + // Returns all fire events accumulated since the last drain, clearing the + // internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM). + std::vector drainFireEvents(); + + // Returns all blueprint drop events since the last drain. + std::vector drainBlueprintDropEvents(); + + Tick currentTick() const; + +private: + EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. + + const GameConfig& m_config; + std::mt19937 m_rng; + + Tick m_currentTick; + EntityId m_nextId; // starts at 1; 0 is kInvalidEntityId. + + std::vector m_fireEvents; + std::vector m_blueprintDropEvents; +}; diff --git a/src/lib/sim/TickDriver.cpp b/src/lib/sim/TickDriver.cpp new file mode 100644 index 0000000..cdb0eca --- /dev/null +++ b/src/lib/sim/TickDriver.cpp @@ -0,0 +1,21 @@ +#include "TickDriver.h" + +#include "Tick.h" + +int TickDriver::advance(double elapsedWallMs, double gameSpeedMultiplier) +{ + m_accumulatorMs += elapsedWallMs * gameSpeedMultiplier; + + int ticks = 0; + while (m_accumulatorMs >= kTickDurationMs) + { + m_accumulatorMs -= kTickDurationMs; + ++ticks; + } + return ticks; +} + +void TickDriver::reset() +{ + m_accumulatorMs = 0.0; +} diff --git a/src/lib/sim/TickDriver.h b/src/lib/sim/TickDriver.h new file mode 100644 index 0000000..5d8498b --- /dev/null +++ b/src/lib/sim/TickDriver.h @@ -0,0 +1,21 @@ +#pragma once + +// Accumulator-based fixed-timestep driver. Decouples wall-clock render rate +// from the 30 Hz simulation tick rate. See architecture.md §Render Loop. +class TickDriver +{ +public: + TickDriver() = default; + + // Adds elapsedWallMs * gameSpeedMultiplier to the accumulator and returns + // how many simulation ticks should be stepped this frame. The remainder + // stays in the accumulator for the next call. + // A multiplier of 0.0 freezes accumulation (pause). + // Valid multipliers per REQ-UI-SPEED: 0, 0.5, 1, 2, 4. + int advance(double elapsedWallMs, double gameSpeedMultiplier); + + void reset(); + +private: + double m_accumulatorMs = 0.0; +}; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 92de5db..56deb94 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -5,4 +5,5 @@ add_files( test.cpp FormulaTest.cpp ConfigLoaderTest.cpp + SimulationTest.cpp ) diff --git a/src/test/SimulationTest.cpp b/src/test/SimulationTest.cpp new file mode 100644 index 0000000..0b45c7f --- /dev/null +++ b/src/test/SimulationTest.cpp @@ -0,0 +1,133 @@ +#include "catch.hpp" + +#include "GameConfig.h" +#include "Simulation.h" +#include "Tick.h" +#include "TickDriver.h" + +// --------------------------------------------------------------------------- +// Simulation +// --------------------------------------------------------------------------- + +TEST_CASE("Simulation::currentTick starts at 0", "[simulation]") +{ + const GameConfig config; + const Simulation sim(config); + + REQUIRE(sim.currentTick() == 0); +} + +TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]") +{ + const GameConfig config; + Simulation sim(config); + + sim.tick(); + + REQUIRE(sim.currentTick() == 1); +} + +TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]") +{ + const GameConfig config; + Simulation sim(config); + + for (int i = 0; i < 10; ++i) + { + sim.tick(); + } + + REQUIRE(sim.currentTick() == 10); +} + +TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]") +{ + const GameConfig config; + Simulation sim(config); + + REQUIRE(sim.drainFireEvents().empty()); +} + +TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]") +{ + const GameConfig config; + Simulation sim(config); + + // First drain: empty. + sim.drainFireEvents(); + + // Second drain must also be empty (not a double-return). + REQUIRE(sim.drainFireEvents().empty()); +} + +TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]") +{ + const GameConfig config; + Simulation sim(config); + + REQUIRE(sim.drainBlueprintDropEvents().empty()); +} + +// --------------------------------------------------------------------------- +// TickDriver +// --------------------------------------------------------------------------- + +TEST_CASE("TickDriver: 0x speed never produces ticks", "[simulation]") +{ + TickDriver driver; + + REQUIRE(driver.advance(100.0, 0.0) == 0); + REQUIRE(driver.advance(1000.0, 0.0) == 0); +} + +TEST_CASE("TickDriver: exactly one tick duration at 1x produces 1 tick", "[simulation]") +{ + TickDriver driver; + + REQUIRE(driver.advance(kTickDurationMs, 1.0) == 1); +} + +TEST_CASE("TickDriver: two tick durations at 1x produces 2 ticks", "[simulation]") +{ + TickDriver driver; + + REQUIRE(driver.advance(2.0 * kTickDurationMs, 1.0) == 2); +} + +TEST_CASE("TickDriver: 4x speed with quarter tick duration produces 1 tick", "[simulation]") +{ + TickDriver driver; + + REQUIRE(driver.advance(kTickDurationMs / 4.0, 4.0) == 1); +} + +TEST_CASE("TickDriver: 0.5x speed with double tick duration produces 1 tick", "[simulation]") +{ + TickDriver driver; + + REQUIRE(driver.advance(kTickDurationMs / 0.5, 0.5) == 1); +} + +TEST_CASE("TickDriver: accumulator carries partial progress across frames", "[simulation]") +{ + TickDriver driver; + + // 60% of a tick — not enough to fire yet. + REQUIRE(driver.advance(kTickDurationMs * 0.6, 1.0) == 0); + + // Another 60% — cumulative 120%, so exactly 1 tick fires. + REQUIRE(driver.advance(kTickDurationMs * 0.6, 1.0) == 1); +} + +TEST_CASE("TickDriver::reset clears the accumulator", "[simulation]") +{ + TickDriver driver; + + // Advance to just below one tick. + driver.advance(kTickDurationMs * 0.9, 1.0); + + driver.reset(); + + // Nothing in the accumulator: zero elapsed time should not fire. + REQUIRE(driver.advance(0.0, 1.0) == 0); +}