diff --git a/CLAUDE.md b/CLAUDE.md index a68dafc..d0b38fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,11 +34,13 @@ There is no external test framework. `tests/test.hpp` provides `TEST("name") {.. **Engine/Window loop** (`core/engine.cpp`, `core/window.cpp`). `Pancake::*` free functions are a thin facade over the `Window` namespace, which holds all global state (GLFW window, current `Scene*`, shaders, entity-picking framebuffer). - Calls such as `load`, `save`, `reset`, `title`, and `size` only **set flags**, which the main loop applies at the top of the next frame. `load(fn)` runs the init function against the current scene, and `reset()` replaces the scene with an empty one. - A component that resets the scene deletes itself, so tests and demos must `load` a new driver in the same frame. -- Each frame does `scene->update(dt)`, then renders twice: once into an RGB32F framebuffer with the entity shader (sprite entity IDs for `Window::readPixel`) and once to the screen. Then `DebugDraw::render()` runs, followed by ImGui (`Console::render`, `Inspector::render`). +- Each frame does `scene->update(dt)`, where `dt` is the frame time × time scale, zero while paused, or exactly one physics step for each `step()` while paused. Components keep updating while paused. Then the frame renders twice: once into an RGB32F framebuffer with the entity shader (sprite entity IDs for `Window::readPixel`) and once to the screen. Then `DebugDraw::render()` runs, followed by ImGui (`Console::render`, `Inspector::render`). - Shutdown deletes the scene, then the GL resources, then ImGui. **Scene → Entity → Component** (`core/`). `Scene` owns `Entity*`s plus a `Camera`, a `Renderer` (sprite batches), a physics `World`, and a `SpatialHashGrid`. That grid is refreshed every frame for entities whose bounds (transform plus rigidbody) changed, and it backs `getEntitiesIn/Near/At`. Entities own `Component*`s, looked up by type name (`getComponent("Rigidbody")`) or by class (`getComponent()`, which skips dead components and matches subclasses). Entities have an optional `name`, found with `Scene::findEntity`. IDs come from static counters, and loading from JSON bumps the counter past loaded IDs. Lifecycle: - `start()` runs when the entity is added to a started scene. Most components self-register with scene systems in `start()` through the global `Window::getScene()` (e.g. `SpriteRenderer` → `getRenderer()->add`, `Rigidbody` → `getPhysics()->add`) and unregister in `end()`. +- `Entity::update` only updates the components it had when the frame began, so a component added during an update (e.g. a chained `Tween`) first runs next frame. +- `Scene::after`/`every` timers run after entity updates, advance with the scene `dt`, and are cancelled when their owner entity dies. They aren't serialised. `Tween` (`components/tween.hpp`) is a non-serialised component that kills itself when done, and reads its start value when it begins, after its delay. - `kill()` marks a component dead and calls `end()`. The owning update loop deletes dead entities and components afterwards. - `Scene::~Scene` kills every entity before deleting any systems, so components can unregister while the systems still exist. - `TransformableComponent` adds a position/size/rotation offset relative to the entity. diff --git a/README.md b/README.md index 34091d5..933e8f4 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,8 @@ The engine provides the following features: - Entity-component system for managing game objects, with typed component access and named entities. - Resource loading from file system. - JSON serialization and deserialization for game data, including custom components. -- An in-game console with built-in commands (`help`, `save`, `load`, `reset`, `debug`, `gravity`, `entities`, `inspector`) and support for your own. +- Time scale, pause and single stepping, with timers (`scene->after`, `scene->every`) and tweens with easing that follow them. +- An in-game console with built-in commands (`help`, `save`, `load`, `reset`, `debug`, `gravity`, `entities`, `inspector`, `pause`, `step`, `timescale`, `spawn`, `kill`, `tp`, `fps`, `vsync`, `fullscreen`) and support for your own. - An entity inspector: click an entity to view and edit its components. - Debug drawing for collision shapes and other debugging purposes. @@ -167,11 +168,34 @@ int level = 2; Pancake::load([level](Scene* scene) { /* build the level */ }); ``` +## Time, Timers and Tweens +```c++ +Pancake::timeScale(0.25f); // Slow motion. Components get the scaled delta time. +Pancake::pause(true); // Components keep updating with a delta time of zero, so a pause menu can still read input. +Pancake::step(); // While paused, advance one physics step on the next frame. +float real = Pancake::getUnscaledDeltaTime(); + +// Timers follow the time scale. With an owner, a timer is cancelled when the owner dies. +int timer = scene->after(2.0f, []() { /* ... */ }, entity); +scene->every(0.5f, []() { /* ... */ }); +scene->cancel(timer); + +// Tweens are components, so they die with their entity. Start values are read after the delay. +Tween::position(entity, vec2(0.0f, 5.0f), 0.5f, Ease::outQuad) + ->setDelay(1.0f) + ->onComplete([entity]() { Tween::rotation(entity, 3.14f, 0.25f); }); +Tween::colour(sprite, vec4(1.0f, 0.0f, 0.0f, 1.0f), 0.2f); +Tween::value(entity, 1.0f, [](float t) { /* t eases from 0 to 1 */ }, Ease::inOutSine); +``` + ## Debugging Tools ```c++ Pancake::console(true); // Open the console. Pancake::console() toggles it. Pancake::inspector(true); // Open the inspector. Pancake::inspector() toggles it. Pancake::debug(true); // Draw physics colliders, bounds and contacts. +Pancake::vsync(false); // Uncap the frame rate. +Pancake::fullscreen(true); // Fill the primary monitor. Pancake::fullscreen() toggles it. +Console::execute("fps on"); // Show the frame rate overlay. // Add a console command. Console::registerCommand("spawn", "spawn : Spawn a crate.", [](std::vector args) { @@ -184,6 +208,7 @@ Console::registerCommand("spawn", "spawn : Spawn a crate.", [](std::vecto - The collision solver was rewritten, so restitution and friction may feel slightly different. Bounces below 1 unit per second are no longer applied, so resting bodies don't jitter. - `KeyListener::isKeyBeginDown` and `MouseListener::isMouseBeginDown` are now true for the whole frame a key or button was pressed in, rather than only for the first caller. - A key or mouse button pressed and released within one frame now still reports `isKeyBeginDown`/`isMouseBeginDown` for that frame, along with the new `isKeyEndDown`/`isMouseEndDown`. +- Components added to an entity during its update now first update on the next frame, instead of the same frame. - `Pancake::load` and `Window::load` take a `std::function` instead of a function pointer. Existing calls still compile. ## Contributions diff --git a/ROADMAP.md b/ROADMAP.md index 2572969..0427e3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,9 +12,9 @@ Tick an item off in the same branch that implements it. ## Batch 2: time and debugging -- [ ] Time scale and pause: `Pancake::timeScale(float)`, `Pancake::pause(bool)`, single frame step, console `pause` / `timescale`. -- [ ] More console commands: `spawn`, `kill `, `tp `, `fps` overlay, `vsync on|off`, `fullscreen`. -- [ ] Timers and tweens: `scene->after(seconds, fn)`, tweening position and colour with easing. +- [x] Time scale and pause: `Pancake::timeScale(float)`, `Pancake::pause(bool)`, `Pancake::step()`, console `pause` / `step` / `timescale`. +- [x] More console commands: `spawn`, `kill `, `tp `, `fps` overlay, `vsync on|off`, `fullscreen`. +- [x] Timers and tweens: `scene->after` / `every` / `cancel`, `Tween` for position, size, rotation, colour and custom values, with `Ease` functions. ## Batch 3: physics for gameplay diff --git a/examples/demo.cpp b/examples/demo.cpp index b656abf..714b210 100644 --- a/examples/demo.cpp +++ b/examples/demo.cpp @@ -7,6 +7,9 @@ // D toggle physics debug drawing // I toggle the inspector // ` toggle the console (try "help") +// P pause or resume +// N step one physics frame (pauses) +// F toggle the frame rate overlay // R reset the sandbox // Escape quit // @@ -115,6 +118,10 @@ void spawnBox(Scene* scene, vec2 position, vec2 size, float mass, vec4 colour) { ImpactFlash* flash = new ImpactFlash(); flash->colour = colour; e->addComponent(flash); + + // Pop in: the sprite grows to full size while the collider is full size from the start. + e->setSize(0.2f * size); + Tween::size(e, size, 0.25f, Ease::outBack); } scene->addEntity(e); @@ -155,6 +162,9 @@ class Sandbox : public Component { if (KeyListener::isKeyBeginDown(GLFW_KEY_D)) {Console::execute("debug");} if (KeyListener::isKeyBeginDown(GLFW_KEY_I)) {Pancake::inspector();} if (KeyListener::isKeyBeginDown(GLFW_KEY_R)) {Pancake::reset(); Pancake::load(build);} + if (KeyListener::isKeyBeginDown(GLFW_KEY_P)) {Pancake::pause();} + if (KeyListener::isKeyBeginDown(GLFW_KEY_N)) {Pancake::step();} + if (KeyListener::isKeyBeginDown(GLFW_KEY_F)) {Console::execute("fps");} } if (KeyListener::isKeyBeginDown(GLFW_KEY_GRAVE_ACCENT)) {Pancake::console();} diff --git a/include/pancake/components/tween.hpp b/include/pancake/components/tween.hpp new file mode 100644 index 0000000..bd1dca7 --- /dev/null +++ b/include/pancake/components/tween.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +#include "pancake/core/component.hpp" +#include "pancake/graphics/spriterenderer.hpp" + +namespace Pancake { + + // Easing functions map progress in [0, 1] to eased progress, starting at 0 and ending at 1. + namespace Ease { + + typedef float (*Function)(float t); + + float linear(float t); + float inQuad(float t); + float outQuad(float t); + float inOutQuad(float t); + float inCubic(float t); + float outCubic(float t); + float inOutCubic(float t); + float inSine(float t); + float outSine(float t); + float inOutSine(float t); + float outBack(float t); + float outBounce(float t); + + } + + // Animates a value over time, then kills itself. It advances with the scene's delta time, so it follows + // the time scale and stops while paused. Tweens live on an entity, so they die with it, and are not saved. + // Start values are read when the tween begins, after its delay, so delayed tweens can be chained. + class Tween : public Component { + + private: + + float duration; + float delay; + float elapsed; + bool begun; + bool finished; + Ease::Function ease; + std::function beginCallback; + std::function applyCallback; + std::function completeCallback; + + public: + + Tween(); + Tween(float duration, std::function apply, Ease::Function ease = Ease::linear); + + void update(float dt) override; + void imgui() override; + + float getDuration(); + float getDelay(); + float getProgress(); + bool isFinished(); + + Tween* setDelay(float delay); + Tween* setEase(Ease::Function ease); + Tween* onBegin(std::function callback); + Tween* onComplete(std::function callback); + + // Tween an entity's transform, or a sprite's colour. The tween is added to the entity that owns the target. + static Tween* position(Entity* entity, glm::vec2 to, float duration, Ease::Function ease = Ease::linear); + static Tween* size(Entity* entity, glm::vec2 to, float duration, Ease::Function ease = Ease::linear); + static Tween* rotation(Entity* entity, float to, float duration, Ease::Function ease = Ease::linear); + static Tween* colour(SpriteRenderer* sprite, glm::vec4 to, float duration, Ease::Function ease = Ease::linear); + + // Call apply with eased progress from 0 to 1, for anything else. + static Tween* value(Entity* entity, float duration, std::function apply, Ease::Function ease = Ease::linear); + + }; + +} diff --git a/include/pancake/core/engine.hpp b/include/pancake/core/engine.hpp index 42c321b..741dd11 100644 --- a/include/pancake/core/engine.hpp +++ b/include/pancake/core/engine.hpp @@ -35,6 +35,22 @@ namespace Pancake { void inspector(bool state); void inspector(); + + // Time. Pausing keeps components updating, with a delta time of zero, so physics, timers and tweens stop. + void timeScale(float scale); + float getTimeScale(); + void pause(bool state); + void pause(); + bool isPaused(); + void step(int frames = 1); // Pause if needed, then advance the given number of physics steps, one per frame. + float getFps(); + float getUnscaledDeltaTime(); + + void vsync(bool state); + void fullscreen(bool state); + void fullscreen(); + bool isFullscreen(); + Scene* getScene(); Entity* getEntity(int id); Component* getComponent(int id); diff --git a/include/pancake/core/scene.hpp b/include/pancake/core/scene.hpp index 627f042..b5620aa 100644 --- a/include/pancake/core/scene.hpp +++ b/include/pancake/core/scene.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "pancake/core/camera.hpp" @@ -18,6 +19,16 @@ namespace Pancake { private: + struct Timer { + int id; + float remaining; + float interval; + bool repeat; + int owner; // The entity the timer belongs to, or -1. + bool cancelled; + std::function callback; + }; + std::string name; std::vector entities; std::unordered_map entityIndex; @@ -31,7 +42,14 @@ namespace Pancake { SpatialHashGrid* grid; std::unordered_map> gridBounds; + std::vector timers; + std::vector addedTimers; // Timers added by timer callbacks, merged in after the callbacks run. + bool updatingTimers; + int nextTimerId; + std::pair computeBounds(Entity* entity); + void updateTimers(float dt); + int addTimer(float seconds, bool repeat, std::function callback, Entity* owner); void updateGrid(Entity* entity); public: @@ -66,6 +84,13 @@ namespace Pancake { std::vector findEntities(std::string name); template std::vector getEntitiesWith(); + // Timers advance with the scene's delta time, so they follow the time scale and stop while paused. + // A timer with an owner is cancelled when the owner dies. Timers are not saved with the scene. + int after(float seconds, std::function callback, Entity* owner = nullptr); + int every(float seconds, std::function callback, Entity* owner = nullptr); + void cancel(int timer); + bool hasTimer(int timer); + // Spatial queries. An entity's bounds cover its transform and its rigidbody, if it has one. std::pair getBounds(Entity* entity); std::vector getEntitiesIn(glm::vec2 min, glm::vec2 max); diff --git a/include/pancake/core/window.hpp b/include/pancake/core/window.hpp index c815bb7..719f572 100644 --- a/include/pancake/core/window.hpp +++ b/include/pancake/core/window.hpp @@ -31,6 +31,21 @@ namespace Pancake { void setProjectionHeight(float height); int readPixel(int x, int y); + // Time. The scene is updated with the frame time multiplied by the time scale, or zero while paused. + void setTimeScale(float scale); + float getTimeScale(); + void setPaused(bool paused); + bool isPaused(); + void step(int frames); + float getFps(); + float getUnscaledDeltaTime(); + + // Display. + void setVsync(bool vsync); + bool hasVsync(); + void setFullscreen(bool fullscreen); + bool isFullscreen(); + void openConsole(); void closeConsole(); diff --git a/include/pancake/pancake.hpp b/include/pancake/pancake.hpp index afc13fd..11665f2 100644 --- a/include/pancake/pancake.hpp +++ b/include/pancake/pancake.hpp @@ -12,6 +12,7 @@ #include "pancake/components/animation.hpp" #include "pancake/components/transition.hpp" +#include "pancake/components/tween.hpp" #include "pancake/core/camera.hpp" #include "pancake/core/component.hpp" diff --git a/include/pancake/physics/world.hpp b/include/pancake/physics/world.hpp index bc86371..03a7148 100644 --- a/include/pancake/physics/world.hpp +++ b/include/pancake/physics/world.hpp @@ -56,6 +56,7 @@ namespace Pancake { void update(float dt); void render(); + float getTimeStep(); glm::vec2 getGravity(); void setGravity(glm::vec2 gravity); bool hasDebugDraw(); diff --git a/src/components/tween.cpp b/src/components/tween.cpp new file mode 100644 index 0000000..ffcf6ea --- /dev/null +++ b/src/components/tween.cpp @@ -0,0 +1,189 @@ +#include +#include +#include +#include +#include + +#include "pancake/components/tween.hpp" + +namespace Pancake { + + namespace { + const float PI = 3.14159265358979f; + } + + namespace Ease { + + float linear(float t) {return t;} + float inQuad(float t) {return t * t;} + float outQuad(float t) {return 1.0f - (1.0f - t) * (1.0f - t);} + float inOutQuad(float t) {return t < 0.5f ? 2.0f * t * t : 1.0f - 2.0f * (1.0f - t) * (1.0f - t);} + float inCubic(float t) {return t * t * t;} + float outCubic(float t) {return 1.0f - std::pow(1.0f - t, 3.0f);} + float inOutCubic(float t) {return t < 0.5f ? 4.0f * t * t * t : 1.0f - std::pow(2.0f - 2.0f * t, 3.0f) / 2.0f;} + float inSine(float t) {return 1.0f - std::cos(t * PI / 2.0f);} + float outSine(float t) {return std::sin(t * PI / 2.0f);} + float inOutSine(float t) {return 0.5f - std::cos(t * PI) / 2.0f;} + + float outBack(float t) { + const float c1 = 1.70158f; + const float c3 = c1 + 1.0f; + return 1.0f + c3 * std::pow(t - 1.0f, 3.0f) + c1 * std::pow(t - 1.0f, 2.0f); + } + + float outBounce(float t) { + const float n = 7.5625f; + const float d = 2.75f; + if (t < 1.0f / d) {return n * t * t;} + if (t < 2.0f / d) {t -= 1.5f / d; return n * t * t + 0.75f;} + if (t < 2.5f / d) {t -= 2.25f / d; return n * t * t + 0.9375f;} + t -= 2.625f / d; + return n * t * t + 0.984375f; + } + + } + + Tween::Tween() : Tween(0.0f, nullptr) { + + } + + Tween::Tween(float duration, std::function apply, Ease::Function ease) : Component("Tween") { + this->duration = duration; + this->delay = 0.0f; + this->elapsed = 0.0f; + this->begun = false; + this->finished = false; + this->ease = ease == nullptr ? Ease::linear : ease; + this->applyCallback = apply; + this->setSerialisable(false); + } + + void Tween::update(float dt) { + + if (this->finished) {return;} + + // Wait out the delay, carrying any leftover time into the tween. + if (this->delay > 0.0f) { + this->delay -= dt; + if (this->delay > 0.0f) {return;} + dt = -this->delay; + this->delay = 0.0f; + } + + if (!this->begun) { + this->begun = true; + if (this->beginCallback) {this->beginCallback();} + } + + this->elapsed += dt; + float t = this->getProgress(); + if (this->applyCallback) {this->applyCallback(t >= 1.0f ? 1.0f : this->ease(t));} + + if (t >= 1.0f) { + this->finished = true; + this->kill(); + if (this->completeCallback) {this->completeCallback();} + } + + } + + void Tween::imgui() { + ImGui::ProgressBar(this->getProgress()); + } + + float Tween::getDuration() { + return this->duration; + } + + float Tween::getDelay() { + return this->delay; + } + + float Tween::getProgress() { + if (this->duration <= 0.0f) {return this->begun ? 1.0f : 0.0f;} + return std::min(this->elapsed / this->duration, 1.0f); + } + + bool Tween::isFinished() { + return this->finished; + } + + Tween* Tween::setDelay(float delay) { + this->delay = delay; + return this; + } + + Tween* Tween::setEase(Ease::Function ease) { + this->ease = ease == nullptr ? Ease::linear : ease; + return this; + } + + Tween* Tween::onBegin(std::function callback) { + this->beginCallback = callback; + return this; + } + + Tween* Tween::onComplete(std::function callback) { + this->completeCallback = callback; + return this; + } + + Tween* Tween::position(Entity* entity, glm::vec2 to, float duration, Ease::Function ease) { + std::shared_ptr from = std::make_shared(); + Tween* tween = new Tween(duration, [entity, from, to](float t) {entity->setPosition(glm::mix(*from, to, t));}, ease); + tween->beginCallback = [entity, from]() {*from = entity->getPosition();}; + entity->addComponent(tween); + return tween; + } + + Tween* Tween::size(Entity* entity, glm::vec2 to, float duration, Ease::Function ease) { + std::shared_ptr from = std::make_shared(); + Tween* tween = new Tween(duration, [entity, from, to](float t) {entity->setSize(glm::mix(*from, to, t));}, ease); + tween->beginCallback = [entity, from]() {*from = entity->getSize();}; + entity->addComponent(tween); + return tween; + } + + Tween* Tween::rotation(Entity* entity, float to, float duration, Ease::Function ease) { + std::shared_ptr from = std::make_shared(); + // Rotate by the difference, so transformable components' offsets rotate with the entity. + Tween* tween = new Tween(duration, [entity, from, to](float t) {entity->addRotation(glm::mix(*from, to, t) - entity->getRotation());}, ease); + tween->beginCallback = [entity, from]() {*from = entity->getRotation();}; + entity->addComponent(tween); + return tween; + } + + Tween* Tween::colour(SpriteRenderer* sprite, glm::vec4 to, float duration, Ease::Function ease) { + + // The sprite may be removed before the tween ends, so look it up by id rather than keeping the pointer. + Entity* entity = sprite->getEntity(); + if (entity == nullptr) {std::cout << "ERROR::TWEEN::COLOUR::SPRITE_HAS_NO_ENTITY\n"; return nullptr;} + int id = sprite->getId(); + auto find = [entity, id]() -> SpriteRenderer* { + for (Component* c : entity->getComponents()) { + if (c->getId() == id && !c->isDead()) {return dynamic_cast(c);} + } + return nullptr; + }; + + std::shared_ptr from = std::make_shared(); + Tween* tween = new Tween(duration, [find, from, to](float t) { + SpriteRenderer* s = find(); + if (s != nullptr) {s->setColour(glm::mix(*from, to, t));} + }, ease); + tween->beginCallback = [find, from]() { + SpriteRenderer* s = find(); + if (s != nullptr) {*from = s->getColour();} + }; + entity->addComponent(tween); + return tween; + + } + + Tween* Tween::value(Entity* entity, float duration, std::function apply, Ease::Function ease) { + Tween* tween = new Tween(duration, apply, ease); + entity->addComponent(tween); + return tween; + } + +} diff --git a/src/core/console.cpp b/src/core/console.cpp index 2952e23..fffe9aa 100644 --- a/src/core/console.cpp +++ b/src/core/console.cpp @@ -7,6 +7,9 @@ #include "pancake/core/inspector.hpp" #include "pancake/core/window.hpp" #include "pancake/graphics/debugdraw.hpp" +#include "pancake/graphics/spriterenderer.hpp" +#include "pancake/physics/rigidbody.hpp" +#include "pancake/physics/collider.hpp" namespace Pancake { @@ -18,6 +21,7 @@ namespace Pancake { }; bool active = false; + bool fpsOverlay = false; bool scrollToBottom = false; std::vector output; std::map commands; @@ -37,6 +41,44 @@ namespace Pancake { return args[0] == "on" || args[0] == "true" || args[0] == "1"; } + bool parseFloat(std::string text, float* value) { + try { + size_t used; + *value = std::stof(text, &used); + return used == text.size(); + } catch (const std::exception& e) {return false;} + } + + // Finds a live entity by id, or by name when the argument isn't a number. + Entity* findEntity(std::string idOrName) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return nullptr;} + Entity* e = nullptr; + try { + size_t used; + int id = std::stoi(idOrName, &used); + if (used == idOrName.size()) {e = scene->getEntity(id);} + } catch (const std::exception& ex) {} + if (e == nullptr) {e = scene->findEntity(idOrName);} + if (e != nullptr && e->isDead()) {return nullptr;} + return e; + } + + void renderOverlay() { + if (!fpsOverlay) {return;} + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings + | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove; + ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x - 10.0f, 10.0f), ImGuiCond_Always, ImVec2(1.0f, 0.0f)); + ImGui::SetNextWindowBgAlpha(0.5f); + if (ImGui::Begin("##FPS", nullptr, flags)) { + float fps = Window::getFps(); + ImGui::Text("%.0f FPS (%.2f ms)", fps, fps > 0.0f ? 1000.0f / fps : 0.0f); + if (Window::isPaused()) {ImGui::Text("Paused");} + if (Window::getTimeScale() != 1.0f) {ImGui::Text("Time scale %.2f", Window::getTimeScale());} + } + ImGui::End(); + } + void registerDefaults() { static bool registered = false; @@ -105,6 +147,81 @@ namespace Pancake { Inspector::set(parseState(args, Inspector::opened())); }); + Console::registerCommand("pause", "pause [on|off]: Pause or resume the scene.", [](std::vector args) { + bool state = parseState(args, Window::isPaused()); + Window::setPaused(state); + Console::print(state ? "Paused" : "Resumed"); + }); + + Console::registerCommand("step", "step [frames]: Pause, then advance one physics step per frame.", [](std::vector args) { + int frames = 1; + if (!args.empty()) { + try {frames = std::stoi(args[0]);} + catch (const std::exception& e) {Console::print("Usage: step [frames]"); return;} + } + Window::step(frames); + }); + + Console::registerCommand("timescale", "timescale [scale]: Show or set how fast the scene runs.", [](std::vector args) { + float scale; + if (args.size() == 1) { + if (!parseFloat(args[0], &scale) || scale < 0.0f) {Console::print("Usage: timescale "); return;} + Window::setTimeScale(scale); + } + Console::print("Time scale: " + std::to_string(Window::getTimeScale())); + }); + + Console::registerCommand("spawn", "spawn [w h]: Spawn a dynamic box.", [](std::vector args) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + float x, y; + float w = 1.0f, h = 1.0f; + bool valid = (args.size() == 2 || args.size() == 4) && parseFloat(args[0], &x) && parseFloat(args[1], &y); + if (valid && args.size() == 4) {valid = parseFloat(args[2], &w) && parseFloat(args[3], &h) && w > 0.0f && h > 0.0f;} + if (!valid) {Console::print("Usage: spawn [w h]"); return;} + + Entity* e = new Entity(glm::vec2(x, y), glm::vec2(w, h), 0.0f); + e->addComponent(); + BoxCollider* box = new BoxCollider(); + box->setSize(w, h); + box->setMass(w * h); + e->addComponent()->addCollider(box); + scene->addEntity(e); + Console::print("Spawned entity " + std::to_string(e->getId())); + }); + + Console::registerCommand("kill", "kill : Remove an entity.", [](std::vector args) { + if (args.size() != 1) {Console::print("Usage: kill "); return;} + Entity* e = findEntity(args[0]); + if (e == nullptr) {Console::print("No entity " + args[0]); return;} + e->kill(); + Console::print("Killed entity " + std::to_string(e->getId())); + }); + + Console::registerCommand("tp", "tp : Move an entity and stop it.", [](std::vector args) { + float x, y; + if (args.size() != 3 || !parseFloat(args[1], &x) || !parseFloat(args[2], &y)) {Console::print("Usage: tp "); return;} + Entity* e = findEntity(args[0]); + if (e == nullptr) {Console::print("No entity " + args[0]); return;} + e->setPosition(glm::vec2(x, y)); + Rigidbody* r = e->getComponent(); + if (r != nullptr) {r->setVelocity(0.0f, 0.0f)->setAngularVelocity(0.0f);} + }); + + Console::registerCommand("fps", "fps [on|off]: Show the frame rate.", [](std::vector args) { + fpsOverlay = parseState(args, fpsOverlay); + }); + + Console::registerCommand("vsync", "vsync [on|off]: Limit the frame rate to the display.", [](std::vector args) { + bool state = parseState(args, Window::hasVsync()); + Window::setVsync(state); + Console::print(std::string("Vsync ") + (state ? "on" : "off")); + }); + + Console::registerCommand("fullscreen", "fullscreen [on|off]: Fill the primary monitor.", [](std::vector args) { + Window::setFullscreen(parseState(args, Window::isFullscreen())); + }); + } } @@ -128,6 +245,8 @@ namespace Pancake { } void render() { + + renderOverlay(); // If not active, don't render the terminal. if (!active) {return;} diff --git a/src/core/engine.cpp b/src/core/engine.cpp index 6694afe..5978e04 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -89,6 +89,54 @@ namespace Pancake { Inspector::toggle(); } + void timeScale(float scale) { + Window::setTimeScale(scale); + } + + float getTimeScale() { + return Window::getTimeScale(); + } + + void pause(bool state) { + Window::setPaused(state); + } + + void pause() { + Window::setPaused(!Window::isPaused()); + } + + bool isPaused() { + return Window::isPaused(); + } + + void step(int frames) { + Window::step(frames); + } + + float getFps() { + return Window::getFps(); + } + + float getUnscaledDeltaTime() { + return Window::getUnscaledDeltaTime(); + } + + void vsync(bool state) { + Window::setVsync(state); + } + + void fullscreen(bool state) { + Window::setFullscreen(state); + } + + void fullscreen() { + Window::setFullscreen(!Window::isFullscreen()); + } + + bool isFullscreen() { + return Window::isFullscreen(); + } + Scene* getScene() { return Window::getScene(); } diff --git a/src/core/entity.cpp b/src/core/entity.cpp index 8e8e22b..4240ab2 100644 --- a/src/core/entity.cpp +++ b/src/core/entity.cpp @@ -80,9 +80,11 @@ namespace Pancake { void Entity::update(float dt) { - // Update all the components. + // Update all the components. Components added during the update, such as a tween chained from + // another tween finishing, first update next frame, so they don't consume this frame's time. std::deque dead; - for (int i = 0; i < this->components.size(); i++) { + int n = this->components.size(); + for (int i = 0; i < n; i++) { if (!this->components[i]->isDead()) {this->components[i]->update(dt);} else {dead.push_front(i);} } diff --git a/src/core/scene.cpp b/src/core/scene.cpp index 96d6714..47afbec 100644 --- a/src/core/scene.cpp +++ b/src/core/scene.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -24,6 +25,8 @@ namespace Pancake { this->renderer = new Renderer(); this->physics = new World(1.0f / 60.0f, glm::vec2(0.0f, -10.0f)); this->grid = new SpatialHashGrid(4); + this->updatingTimers = false; + this->nextTimerId = 1; } Scene::~Scene() { @@ -71,6 +74,9 @@ namespace Pancake { else {dead.push_front(i);} } + // Run timers after entities, so a timer owned by an entity killed this frame doesn't fire. + this->updateTimers(dt); + // Delete all dead elements for (int i : dead) { Entity* e = this->entities[i]; @@ -88,6 +94,66 @@ namespace Pancake { } + void Scene::updateTimers(float dt) { + + this->updatingTimers = true; + + // Callbacks can cancel timers but not add to this vector, so references into it stay valid. + for (Timer& t : this->timers) { + if (t.cancelled) {continue;} + if (t.owner >= 0) { + Entity* owner = this->getEntity(t.owner); + if (owner == nullptr || owner->isDead()) {t.cancelled = true; continue;} + } + t.remaining -= dt; + if (t.remaining > 0.0f) {continue;} + if (!t.repeat) {t.cancelled = true;} + else {t.remaining = std::max(t.remaining + t.interval, 0.0f);} // Fires at most once a frame. + t.callback(); + } + + this->updatingTimers = false; + this->timers.erase(std::remove_if(this->timers.begin(), this->timers.end(), [](const Timer& t) {return t.cancelled;}), this->timers.end()); + for (Timer& t : this->addedTimers) { + if (!t.cancelled) {this->timers.push_back(std::move(t));} + } + this->addedTimers.clear(); + + } + + int Scene::addTimer(float seconds, bool repeat, std::function callback, Entity* owner) { + Timer t; + t.id = this->nextTimerId++; + t.remaining = seconds; + t.interval = std::max(seconds, 0.0f); + t.repeat = repeat; + t.owner = owner == nullptr ? -1 : owner->getId(); + t.cancelled = false; + t.callback = callback; + if (this->updatingTimers) {this->addedTimers.push_back(std::move(t));} + else {this->timers.push_back(std::move(t));} + return this->nextTimerId - 1; + } + + int Scene::after(float seconds, std::function callback, Entity* owner) { + return this->addTimer(seconds, false, callback, owner); + } + + int Scene::every(float seconds, std::function callback, Entity* owner) { + return this->addTimer(seconds, true, callback, owner); + } + + void Scene::cancel(int timer) { + for (Timer& t : this->timers) {if (t.id == timer) {t.cancelled = true;}} + for (Timer& t : this->addedTimers) {if (t.id == timer) {t.cancelled = true;}} + } + + bool Scene::hasTimer(int timer) { + for (Timer& t : this->timers) {if (t.id == timer && !t.cancelled) {return true;}} + for (Timer& t : this->addedTimers) {if (t.id == timer && !t.cancelled) {return true;}} + return false; + } + void Scene::render() { this->renderer->render(); } diff --git a/src/core/window.cpp b/src/core/window.cpp index 281070e..1a6f144 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -58,6 +58,19 @@ namespace Pancake { bool heightFlag = false; int heightValue; + float timeScale = 1.0f; + bool paused = false; + int stepFrames = 0; + float fps = 0.0f; + float unscaledDeltaTime = 0.0f; + + bool vsync = true; + bool vsyncFlag = false; + + bool fullscreen = false; + bool fullscreenFlag = false; + int windowedX, windowedY, windowedWidth, windowedHeight; // Restored when leaving fullscreen. + Shader* defaultShader; Shader* entityShader; Framebuffer* entityTexture; @@ -100,6 +113,30 @@ namespace Pancake { } + // The time the scene advances by this frame. + float sceneDeltaTime(float dt) { + if (!paused) {return dt * timeScale;} + if (stepFrames <= 0) {return 0.0f;} + stepFrames--; + return scene->getPhysics()->getTimeStep(); // Exactly one physics step, whatever the frame time. + } + + void applyFullscreen() { + GLFWmonitor* monitor = glfwGetPrimaryMonitor(); + if (fullscreen && monitor != nullptr) { + glfwGetWindowPos(window, &windowedX, &windowedY); + glfwGetWindowSize(window, &windowedWidth, &windowedHeight); + const GLFWvidmode* mode = glfwGetVideoMode(monitor); + glfwSetWindowMonitor(window, monitor, 0, 0, mode->width, mode->height, mode->refreshRate); + } else if (fullscreen) { + std::cout << "ERROR::WINDOW::NO_MONITOR_FOR_FULLSCREEN\n"; + fullscreen = false; + } else if (glfwGetWindowMonitor(window) != nullptr) { + glfwSetWindowMonitor(window, nullptr, windowedX, windowedY, windowedWidth, windowedHeight, 0); + } + glfwSwapInterval(vsync ? 1 : 0); // Some platforms reset the swap interval when the monitor changes. + } + void resizeCallback(GLFWwindow* window, int screenWidth, int screenHeight) { width = screenWidth; height = screenHeight; @@ -175,8 +212,9 @@ namespace Pancake { // Make the OpenGl context current glfwMakeContextCurrent(window); - // Enable v-sync - glfwSwapInterval(1); + // Enable v-sync, unless it was turned off before starting. + glfwSwapInterval(vsync ? 1 : 0); + vsyncFlag = false; // Make the window visible glfwShowWindow(window); @@ -207,6 +245,7 @@ namespace Pancake { DebugDraw::init(); scene = new Scene(); scene->start(); + if (fullscreen) {fullscreenFlag = true;} // Loop float beginTime = (float)glfwGetTime(); @@ -249,6 +288,16 @@ namespace Pancake { heightFlag = false; } + if (vsyncFlag) { + glfwSwapInterval(vsync ? 1 : 0); + vsyncFlag = false; + } + + if (fullscreenFlag) { + applyFullscreen(); + fullscreenFlag = false; + } + glfwPollEvents(); if (saveFlag) { @@ -284,7 +333,9 @@ namespace Pancake { } if (dt > 0) { - update(dt); + unscaledDeltaTime = dt; + fps = fps <= 0.0f ? 1.0f / dt : 0.95f * fps + 0.05f / dt; + update(sceneDeltaTime(dt)); render(); } @@ -398,6 +449,55 @@ namespace Pancake { } + void setTimeScale(float scale) { + timeScale = scale < 0.0f ? 0.0f : scale; + } + + float getTimeScale() { + return timeScale; + } + + void setPaused(bool state) { + paused = state; + if (!paused) {stepFrames = 0;} + } + + bool isPaused() { + return paused; + } + + void step(int frames) { + paused = true; + if (frames > 0) {stepFrames += frames;} + } + + float getFps() { + return fps; + } + + float getUnscaledDeltaTime() { + return unscaledDeltaTime; + } + + void setVsync(bool state) { + vsync = state; + vsyncFlag = true; + } + + bool hasVsync() { + return vsync; + } + + void setFullscreen(bool state) { + if (state == fullscreen) {return;} + fullscreen = state; + if (window != nullptr) {fullscreenFlag = true;} // Otherwise applied when the window is created. + } + + bool isFullscreen() { + return fullscreen; + } + void openConsole() { Console::open(); } diff --git a/src/physics/world.cpp b/src/physics/world.cpp index 8906d1a..bd29bff 100644 --- a/src/physics/world.cpp +++ b/src/physics/world.cpp @@ -152,6 +152,10 @@ namespace Pancake { } + float World::getTimeStep() { + return this->timeStep; + } + World::World(float timeStep, glm::vec2 gravity) { this->grid = new SpatialHashGrid(4); this->gravity = gravity; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 182e2dd..a7a6d50 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,4 +33,6 @@ pancake_test(test_physics) pancake_test(test_raycast) pancake_test(test_serialise) pancake_test(test_entity) +pancake_test(test_time) pancake_engine_test(test_engine) +pancake_engine_test(test_time_engine) diff --git a/tests/test_time.cpp b/tests/test_time.cpp new file mode 100644 index 0000000..4e9af7d --- /dev/null +++ b/tests/test_time.cpp @@ -0,0 +1,136 @@ +// Scene timers, tweens and easing, driven with explicit delta times and no window. + +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +TEST("one shot timers fire once after their delay") { + Scene scene; + int fired = 0; + int timer = scene.after(0.5f, [&fired]() {fired++;}); + CHECK(scene.hasTimer(timer)); + scene.update(0.25f); + CHECK(fired == 0); + scene.update(0.25f); + CHECK(fired == 1); + CHECK(!scene.hasTimer(timer)); + scene.update(1.0f); + CHECK(fired == 1); +} + +TEST("repeating timers fire at most once a frame and keep their rhythm") { + Scene scene; + int fired = 0; + int timer = scene.every(0.1f, [&fired]() {fired++;}); + for (int i = 0; i < 10; i++) {scene.update(0.05f);} + CHECK(fired == 5); + + // A long frame fires once, then catches up over the next frames. + scene.update(0.35f); + CHECK(fired == 6); + scene.update(0.0f); + CHECK(fired == 7); + + scene.cancel(timer); + scene.update(1.0f); + CHECK(fired == 7); + CHECK(!scene.hasTimer(timer)); +} + +TEST("a zero delta time freezes timers") { + Scene scene; + bool fired = false; + scene.after(0.1f, [&fired]() {fired = true;}); + for (int i = 0; i < 100; i++) {scene.update(0.0f);} + CHECK(!fired); +} + +TEST("timers can add and cancel timers from their callbacks") { + Scene scene; + int first = 0; + int second = 0; + int repeating = scene.every(0.1f, [&]() {first++;}); + scene.after(0.1f, [&]() { + scene.cancel(repeating); + scene.after(0.1f, [&]() {second++;}); + }); + scene.update(0.1f); + CHECK(first == 1); // The repeating timer was earlier in the list, so it fired before being cancelled. + CHECK(second == 0); + scene.update(0.1f); + CHECK(first == 1); + CHECK(second == 1); +} + +TEST("timers with an owner are cancelled when it dies") { + Scene scene; + Entity* owner = new Entity(); + scene.addEntity(owner); + bool fired = false; + int timer = scene.after(0.1f, [&fired]() {fired = true;}, owner); + owner->kill(); + scene.update(0.2f); + CHECK(!fired); + CHECK(!scene.hasTimer(timer)); +} + +TEST("easing functions start at 0 and end at 1") { + Ease::Function functions[] = {Ease::linear, Ease::inQuad, Ease::outQuad, Ease::inOutQuad, Ease::inCubic, Ease::outCubic, + Ease::inOutCubic, Ease::inSine, Ease::outSine, Ease::inOutSine, Ease::outBack, Ease::outBounce}; + for (Ease::Function f : functions) { + CHECK_NEAR(f(0.0f), 0.0, 1e-5); + CHECK_NEAR(f(1.0f), 1.0, 1e-5); + } + CHECK(Ease::outQuad(0.5f) > 0.5f); + CHECK(Ease::inQuad(0.5f) < 0.5f); +} + +TEST("position tweens ease from where the entity is when they begin") { + Entity e(glm::vec2(0.0f, 0.0f)); + bool done = false; + Tween* tween = Tween::position(&e, glm::vec2(10.0f, 0.0f), 1.0f, Ease::inQuad); + tween->setDelay(0.5f)->onComplete([&done]() {done = true;}); + + e.setPosition(glm::vec2(2.0f, 0.0f)); // Moved during the delay, so the tween starts from here. + e.update(0.5f); + CHECK_NEAR(e.getPosition().x, 2.0, 1e-5); + e.update(0.5f); + CHECK_NEAR(e.getPosition().x, 2.0 + 8.0 * 0.25, 1e-5); + CHECK(!done); + e.update(0.75f); // Overshooting the end lands exactly on the target. + CHECK_NEAR(e.getPosition().x, 10.0, 1e-5); + CHECK(done); + CHECK(tween->isFinished()); + CHECK(!e.hasComponent()); + e.update(0.0f); // The finished tween is deleted. + CHECK(e.getComponents().empty()); +} + +TEST("tweens can be chained from onComplete") { + Entity e(glm::vec2(0.0f, 0.0f)); + Tween::position(&e, glm::vec2(1.0f, 0.0f), 1.0f)->onComplete([&e]() { + Tween::position(&e, glm::vec2(1.0f, 1.0f), 1.0f); + }); + e.update(1.0f); + CHECK_NEAR(e.getPosition().x, 1.0, 1e-5); + e.update(0.5f); + CHECK_NEAR(e.getPosition().y, 0.5, 1e-5); + e.update(0.5f); + CHECK_NEAR(e.getPosition().y, 1.0, 1e-5); +} + +TEST("rotation and size tweens, and value tweens for anything else") { + Entity e(glm::vec2(0.0f), glm::vec2(1.0f), 0.0f); + Tween::rotation(&e, 1.0f, 1.0f); + Tween::size(&e, glm::vec2(3.0f, 1.0f), 1.0f); + float value = 0.0f; + Tween::value(&e, 2.0f, [&value](float t) {value = 4.0f * t;}); + e.update(1.0f); + CHECK_NEAR(e.getRotation(), 1.0, 1e-5); + CHECK_NEAR(e.getSize().x, 3.0, 1e-5); + CHECK_NEAR(value, 2.0, 1e-5); +} + +RUN_TESTS() diff --git a/tests/test_time_engine.cpp b/tests/test_time_engine.cpp new file mode 100644 index 0000000..5fa47f0 --- /dev/null +++ b/tests/test_time_engine.cpp @@ -0,0 +1,184 @@ +// Runs the real engine loop to check pausing, stepping and time scale, the console commands that go with them, +// colour tweens, and display settings. The driver counts frames, which keep coming while the scene is paused. + +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +namespace { + + int frame = 0; + float pausedY = 0.0f; + float steppedY = 0.0f; + bool timerFired = false; + int spawnedId = -1; + SpriteRenderer* removedSprite = nullptr; + + void log(const char* message) { + std::printf("frame %d: %s\n", frame, message); + std::fflush(stdout); + } + + Entity* box() { + return Pancake::getScene()->findEntity("Box"); + } + +} + +class Driver : public Component { + + public: + + Driver() : Component("Driver") {} + + void update(float dt) override { + + frame++; + Scene* scene = Pancake::getScene(); + + if (frame == 5) { + log("pause"); + Pancake::pause(true); + } + + if (frame == 7) { + CHECK(dt == 0.0f); + pausedY = box()->getPosition().y; + scene->after(0.001f, []() {timerFired = true;}); + } + + if (frame == 20) { + log("nothing moves or fires while paused"); + CHECK(Pancake::isPaused()); + CHECK(box()->getPosition().y == pausedY); + CHECK(!timerFired); + CHECK(Pancake::getFps() > 0.0f); + CHECK(Pancake::getUnscaledDeltaTime() > 0.0f); + Pancake::step(); + } + + if (frame == 21) { + CHECK_NEAR(dt, scene->getPhysics()->getTimeStep(), 1e-6); + } + + if (frame == 22) { + log("a step advances exactly one physics step and stays paused"); + steppedY = box()->getPosition().y; + CHECK(steppedY < pausedY); + CHECK(timerFired); + CHECK(Pancake::isPaused()); + CHECK(dt == 0.0f); + } + + if (frame == 25) { + CHECK(box()->getPosition().y == steppedY); + Console::execute("pause off"); + CHECK(!Pancake::isPaused()); + Console::execute("timescale 0"); + CHECK(Pancake::getTimeScale() == 0.0f); + } + + if (frame == 26) {steppedY = box()->getPosition().y;} + + if (frame == 30) { + log("a time scale of zero freezes the scene"); + CHECK(box()->getPosition().y == steppedY); + Console::execute("timescale 2"); + CHECK(Pancake::getTimeScale() == 2.0f); + Console::execute("timescale -1"); // Rejected. + CHECK(Pancake::getTimeScale() == 2.0f); + Console::execute("timescale 1"); + } + + if (frame == 31) { + log("spawn, teleport and kill from the console"); + Console::execute("spawn 5 5 2 1"); + std::vector bodies = scene->getEntitiesWith(); + CHECK(bodies.size() == 2); + for (Entity* e : bodies) {if (e->getName() != "Box") {spawnedId = e->getId();}} + Entity* spawned = scene->getEntity(spawnedId); + CHECK(spawned != nullptr); + if (spawned != nullptr) { + CHECK_NEAR(spawned->getSize().x, 2.0, 1e-6); + CHECK_NEAR(spawned->getComponent()->getMass(), 2.0, 1e-6); + CHECK(spawned->hasComponent()); + } + + Console::execute("tp Box 0 100"); + CHECK_NEAR(box()->getPosition().y, 100.0, 1e-6); + CHECK(box()->getComponent()->getVelocity() == vec2(0.0f, 0.0f)); + + Console::execute("kill " + std::to_string(spawnedId)); + CHECK(scene->getEntity(spawnedId)->isDead()); + Console::execute("kill Box"); + CHECK(box() == nullptr); + Console::execute("kill Nobody"); // Prints an error rather than crashing. + + Console::execute("fps on"); + Console::execute("vsync off"); + CHECK(!Window::hasVsync()); + } + + if (frame == 32) { + log("colour tweens, including one whose sprite is removed"); + Entity* e = new Entity(vec2(-3.0f, 0.0f)); + SpriteRenderer* sprite = e->addComponent(); + sprite->setColour(vec4(0.0f, 0.0f, 0.0f, 1.0f)); + Tween::colour(sprite, vec4(1.0f, 0.0f, 0.0f, 1.0f), 0.0f); + e->setName("Tinted"); + scene->addEntity(e); + + Entity* f = new Entity(vec2(3.0f, 0.0f)); + removedSprite = f->addComponent(); + Tween::colour(removedSprite, vec4(1.0f), 10.0f); + scene->addEntity(f); + } + + if (frame == 34) { + SpriteRenderer* sprite = scene->findEntity("Tinted")->getComponent(); + CHECK_NEAR(sprite->getColour().r, 1.0, 1e-6); + CHECK(!scene->findEntity("Tinted")->hasComponent()); + removedSprite->kill(); // Deleted this frame. ASan reports it if the tween still uses it. + } + + if (frame == 36) { + log("fullscreen on"); + Console::execute("fullscreen on"); + CHECK(Pancake::isFullscreen()); + } + + if (frame == 40) { + log("fullscreen off"); + Pancake::fullscreen(false); + CHECK(!Pancake::isFullscreen()); + } + + if (frame == 45) {Pancake::stop();} + + } + +}; + +void init(Scene* scene) { + scene->getPhysics()->setGravity(vec2(0.0f, -10.0f)); + Entity* e = new Entity(vec2(0.0f, 0.0f)); + e->setName("Box"); + e->addComponent(); + BoxCollider* collider = new BoxCollider(); + collider->setMass(1.0f); + e->addComponent()->addCollider(collider); + scene->addEntity(e); + Entity* d = new Entity(); + d->addComponent(new Driver()); + scene->addEntity(d); +} + +int main() { + Pancake::load(init); + Pancake::start(); + CHECK(frame == 45); + std::printf("engine ran %d frames, %d failed checks\n", frame, PancakeTest::failures()); + return PancakeTest::failures() == 0 ? 0 : 1; +}