diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f0fcd1..f314e16 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ endif() add_library(Caffeine src/core/Timer.cpp + src/core/GameLoop.cpp ) target_include_directories(Caffeine PUBLIC diff --git a/src/core/GameLoop.cpp b/src/core/GameLoop.cpp new file mode 100644 index 0000000..9cd1df7 --- /dev/null +++ b/src/core/GameLoop.cpp @@ -0,0 +1,82 @@ +#include "GameLoop.hpp" +#include + +namespace Caffeine { + +GameLoop::GameLoop(const GameLoopConfig& config) + : m_config(config) { +} + +GameLoop::~GameLoop() { +} + +void GameLoop::init() { + if (m_state != GameState::Init) return; + m_state = GameState::Running; + m_accumulator = 0.0; + m_elapsedTime = 0.0; + m_alpha = 0.0; + m_frameCount = 0; +} + +void GameLoop::tick(f64 deltaTime) { + if (m_state != GameState::Running && m_state != GameState::Paused) return; + + if (m_callbacks) m_callbacks->onBeginFrame(); + if (onBeginFrame) onBeginFrame(); + + deltaTime = std::min(deltaTime, m_config.maxFrameTime); + m_elapsedTime += deltaTime; + + if (m_state == GameState::Running) { + m_accumulator += deltaTime; + + while (m_accumulator >= m_config.fixedDeltaTime) { + processFixedUpdate(m_config.fixedDeltaTime); + m_accumulator -= m_config.fixedDeltaTime; + } + + if (m_config.interpolation) { + m_alpha = m_accumulator / m_config.fixedDeltaTime; + } else { + m_alpha = 0.0; + } + } + + if (m_callbacks) m_callbacks->onRender(m_alpha); + if (onRender) onRender(m_alpha); + + if (m_callbacks) m_callbacks->onEndFrame(); + if (onEndFrame) onEndFrame(); + + m_frameCount++; +} + +void GameLoop::pause() { + if (m_state == GameState::Running) { + m_state = GameState::Paused; + } +} + +void GameLoop::resume() { + if (m_state == GameState::Paused) { + m_state = GameState::Running; + } +} + +void GameLoop::shutdown() { + if (m_state == GameState::Running || m_state == GameState::Paused) { + m_state = GameState::Shutdown; + } +} + +void GameLoop::setCallbacks(IGameCallbacks* callbacks) { + m_callbacks = callbacks; +} + +void GameLoop::processFixedUpdate(f64 dt) { + if (m_callbacks) m_callbacks->onFixedUpdate(dt); + if (onFixedUpdate) onFixedUpdate(dt); +} + +} diff --git a/src/core/GameLoop.hpp b/src/core/GameLoop.hpp new file mode 100644 index 0000000..40ab7ed --- /dev/null +++ b/src/core/GameLoop.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "Types.hpp" +#include + +namespace Caffeine { + +enum class GameState : u8 { + Init, + Running, + Paused, + Shutdown +}; + +struct GameLoopConfig { + f64 fixedDeltaTime = 1.0 / 60.0; + f64 maxFrameTime = 0.25; + u32 targetFPS = 0; + bool vsync = true; + bool interpolation = true; +}; + +class IGameCallbacks { +public: + virtual ~IGameCallbacks() = default; + + virtual void onBeginFrame() {} + virtual void onFixedUpdate(f64 dt) { (void)dt; } + virtual void onRender(f64 alpha) { (void)alpha; } + virtual void onEndFrame() {} +}; + +class GameLoop { +public: + explicit GameLoop(const GameLoopConfig& config = {}); + ~GameLoop(); + + void init(); + void tick(f64 deltaTime); + void pause(); + void resume(); + void shutdown(); + + GameState state() const { return m_state; } + f64 elapsedTime() const { return m_elapsedTime; } + f64 interpolationAlpha() const { return m_alpha; } + u64 frameCount() const { return m_frameCount; } + f64 accumulator() const { return m_accumulator; } + + void setCallbacks(IGameCallbacks* callbacks); + + std::function onFixedUpdate; + std::function onRender; + std::function onBeginFrame; + std::function onEndFrame; + + const GameLoopConfig& config() const { return m_config; } + +private: + void processFixedUpdate(f64 dt); + + GameLoopConfig m_config; + IGameCallbacks* m_callbacks = nullptr; + GameState m_state = GameState::Init; + f64 m_accumulator = 0.0; + f64 m_elapsedTime = 0.0; + f64 m_alpha = 0.0; + u64 m_frameCount = 0; +}; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 601a9bb..1fdae69 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(CaffeineTest test_math.cpp test_core.cpp test_timer.cpp + test_gameloop.cpp ) target_link_libraries(CaffeineTest PRIVATE Caffeine) diff --git a/tests/test_gameloop.cpp b/tests/test_gameloop.cpp new file mode 100644 index 0000000..488b912 --- /dev/null +++ b/tests/test_gameloop.cpp @@ -0,0 +1,300 @@ +#include "catch.hpp" +#include +#include +#include "../src/core/GameLoop.hpp" + +using namespace Caffeine; + +TEST_CASE("GameLoopConfig - Default values", "[gameloop][config]") { + GameLoopConfig cfg; + REQUIRE_THAT(cfg.fixedDeltaTime, Catch::Matchers::WithinAbs(1.0 / 60.0, 1e-9)); + REQUIRE_THAT(cfg.maxFrameTime, Catch::Matchers::WithinAbs(0.25, 1e-9)); + REQUIRE(cfg.targetFPS == 0); + REQUIRE(cfg.vsync == true); + REQUIRE(cfg.interpolation == true); +} + +TEST_CASE("GameState - Init is default", "[gameloop][state]") { + GameLoop loop; + REQUIRE(loop.state() == GameState::Init); +} + +TEST_CASE("GameLoop - Init transitions to Running", "[gameloop][state]") { + GameLoop loop; + loop.init(); + REQUIRE(loop.state() == GameState::Running); +} + +TEST_CASE("GameLoop - Pause transitions from Running", "[gameloop][state]") { + GameLoop loop; + loop.init(); + loop.pause(); + REQUIRE(loop.state() == GameState::Paused); +} + +TEST_CASE("GameLoop - Resume transitions from Paused to Running", "[gameloop][state]") { + GameLoop loop; + loop.init(); + loop.pause(); + loop.resume(); + REQUIRE(loop.state() == GameState::Running); +} + +TEST_CASE("GameLoop - Shutdown from Running", "[gameloop][state]") { + GameLoop loop; + loop.init(); + loop.shutdown(); + REQUIRE(loop.state() == GameState::Shutdown); +} + +TEST_CASE("GameLoop - Shutdown from Paused", "[gameloop][state]") { + GameLoop loop; + loop.init(); + loop.pause(); + loop.shutdown(); + REQUIRE(loop.state() == GameState::Shutdown); +} + +TEST_CASE("GameLoop - Pause from Init is ignored", "[gameloop][state]") { + GameLoop loop; + loop.pause(); + REQUIRE(loop.state() == GameState::Init); +} + +TEST_CASE("GameLoop - Resume from Init is ignored", "[gameloop][state]") { + GameLoop loop; + loop.resume(); + REQUIRE(loop.state() == GameState::Init); +} + +TEST_CASE("GameLoop - Tick does nothing before init", "[gameloop][state]") { + GameLoop loop; + loop.tick(1.0 / 60.0); + REQUIRE(loop.frameCount() == 0); +} + +TEST_CASE("GameLoop - Tick does nothing after shutdown", "[gameloop][state]") { + GameLoop loop; + loop.init(); + loop.shutdown(); + loop.tick(1.0 / 60.0); + REQUIRE(loop.frameCount() == 0); +} + +TEST_CASE("GameLoop - Frame count increments on tick", "[gameloop][tick]") { + GameLoop loop; + loop.init(); + loop.tick(1.0 / 60.0); + REQUIRE(loop.frameCount() == 1); + loop.tick(1.0 / 60.0); + REQUIRE(loop.frameCount() == 2); +} + +TEST_CASE("GameLoop - Elapsed time accumulates", "[gameloop][tick]") { + GameLoop loop; + loop.init(); + loop.tick(1.0 / 60.0); + loop.tick(1.0 / 60.0); + REQUIRE(loop.elapsedTime() > 0.0); +} + +TEST_CASE("GameLoop - Fixed update fires at correct rate", "[gameloop][tick]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + GameLoop loop(cfg); + loop.init(); + + int fixedUpdateCount = 0; + loop.onFixedUpdate = [&](f64) { fixedUpdateCount++; }; + + loop.tick(1.0 / 60.0); + REQUIRE(fixedUpdateCount == 1); +} + +TEST_CASE("GameLoop - Multiple fixed updates per frame when dt is large", "[gameloop][tick]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + GameLoop loop(cfg); + loop.init(); + + int fixedUpdateCount = 0; + loop.onFixedUpdate = [&](f64) { fixedUpdateCount++; }; + + loop.tick(3.0 / 60.0); + REQUIRE(fixedUpdateCount == 3); +} + +TEST_CASE("GameLoop - No fixed update when dt is too small", "[gameloop][tick]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + GameLoop loop(cfg); + loop.init(); + + int fixedUpdateCount = 0; + loop.onFixedUpdate = [&](f64) { fixedUpdateCount++; }; + + loop.tick(0.001); + REQUIRE(fixedUpdateCount == 0); +} + +TEST_CASE("GameLoop - Spiral of death prevention clamps accumulator", "[gameloop][tick]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + cfg.maxFrameTime = 0.25; + GameLoop loop(cfg); + loop.init(); + + int fixedUpdateCount = 0; + loop.onFixedUpdate = [&](f64) { fixedUpdateCount++; }; + + loop.tick(10.0); + + int maxPossibleUpdates = static_cast(cfg.maxFrameTime / cfg.fixedDeltaTime); + REQUIRE(fixedUpdateCount <= maxPossibleUpdates); +} + +TEST_CASE("GameLoop - Interpolation alpha is in [0, 1)", "[gameloop][tick]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + cfg.interpolation = true; + GameLoop loop(cfg); + loop.init(); + + loop.tick(0.5 / 60.0); + f64 alpha = loop.interpolationAlpha(); + REQUIRE(alpha >= 0.0); + REQUIRE(alpha < 1.0); +} + +TEST_CASE("GameLoop - Alpha is zero when interpolation disabled", "[gameloop][tick]") { + GameLoopConfig cfg; + cfg.interpolation = false; + GameLoop loop(cfg); + loop.init(); + + loop.tick(0.5 / 60.0); + REQUIRE(loop.interpolationAlpha() == 0.0); +} + +TEST_CASE("GameLoop - Paused state skips fixed update", "[gameloop][tick]") { + GameLoop loop; + loop.init(); + + int fixedUpdateCount = 0; + loop.onFixedUpdate = [&](f64) { fixedUpdateCount++; }; + + loop.pause(); + loop.tick(1.0 / 60.0); + REQUIRE(fixedUpdateCount == 0); +} + +TEST_CASE("GameLoop - Paused state still calls render", "[gameloop][tick]") { + GameLoop loop; + loop.init(); + + int renderCount = 0; + loop.onRender = [&](f64) { renderCount++; }; + + loop.pause(); + loop.tick(1.0 / 60.0); + REQUIRE(renderCount == 1); +} + +TEST_CASE("GameLoop - onBeginFrame and onEndFrame called per tick", "[gameloop][callbacks]") { + GameLoop loop; + loop.init(); + + int beginCount = 0; + int endCount = 0; + loop.onBeginFrame = [&]() { beginCount++; }; + loop.onEndFrame = [&]() { endCount++; }; + + loop.tick(1.0 / 60.0); + REQUIRE(beginCount == 1); + REQUIRE(endCount == 1); + + loop.tick(1.0 / 60.0); + REQUIRE(beginCount == 2); + REQUIRE(endCount == 2); +} + +TEST_CASE("GameLoop - onRender called once per tick", "[gameloop][callbacks]") { + GameLoop loop; + loop.init(); + + int renderCount = 0; + loop.onRender = [&](f64) { renderCount++; }; + + loop.tick(3.0 / 60.0); + REQUIRE(renderCount == 1); +} + +TEST_CASE("GameLoop - Fixed update receives correct dt", "[gameloop][callbacks]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + GameLoop loop(cfg); + loop.init(); + + f64 receivedDt = 0.0; + loop.onFixedUpdate = [&](f64 dt) { receivedDt = dt; }; + + loop.tick(1.0 / 60.0); + REQUIRE_THAT(receivedDt, Catch::Matchers::WithinAbs(1.0 / 60.0, 1e-9)); +} + +TEST_CASE("GameLoop - IGameCallbacks interface works", "[gameloop][callbacks]") { + struct TestCallbacks : IGameCallbacks { + int fixedCount = 0; + int renderCount = 0; + int beginCount = 0; + int endCount = 0; + + void onBeginFrame() override { beginCount++; } + void onFixedUpdate(f64) override { fixedCount++; } + void onRender(f64) override { renderCount++; } + void onEndFrame() override { endCount++; } + }; + + TestCallbacks cb; + GameLoop loop; + loop.setCallbacks(&cb); + loop.init(); + + loop.tick(1.0 / 60.0); + REQUIRE(cb.beginCount == 1); + REQUIRE(cb.fixedCount == 1); + REQUIRE(cb.renderCount == 1); + REQUIRE(cb.endCount == 1); +} + +TEST_CASE("GameLoop - Deterministic accumulation over 3600 frames", "[gameloop][integration]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 60.0; + GameLoop loop(cfg); + loop.init(); + + int fixedUpdateCount = 0; + loop.onFixedUpdate = [&](f64) { fixedUpdateCount++; }; + + for (int i = 0; i < 3600; ++i) { + loop.tick(1.0 / 60.0); + } + + REQUIRE(fixedUpdateCount == 3600); +} + +TEST_CASE("GameLoop - Custom config applies", "[gameloop][config]") { + GameLoopConfig cfg; + cfg.fixedDeltaTime = 1.0 / 30.0; + cfg.maxFrameTime = 0.5; + cfg.targetFPS = 120; + cfg.vsync = false; + cfg.interpolation = false; + + GameLoop loop(cfg); + REQUIRE_THAT(loop.config().fixedDeltaTime, Catch::Matchers::WithinAbs(1.0 / 30.0, 1e-9)); + REQUIRE_THAT(loop.config().maxFrameTime, Catch::Matchers::WithinAbs(0.5, 1e-9)); + REQUIRE(loop.config().targetFPS == 120); + REQUIRE(loop.config().vsync == false); + REQUIRE(loop.config().interpolation == false); +}