From 50a2f98edb1093e64155350b3a07c5466efdbf84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:45:35 +0000 Subject: [PATCH] Add time scale, pause and stepping, more console commands, and timers and tweens Batch 2 of ROADMAP.md. - Time: Pancake::timeScale, pause, step, getFps and getUnscaledDeltaTime. The scene gets the frame time times the time scale, zero while paused, or exactly one physics step per step() while paused. Components keep updating while paused, so a pause menu can still read input. - Display: Pancake::vsync and fullscreen, applied by the main loop. - Console commands: pause, step, timescale, spawn, kill and tp (by id or name), fps (an overlay showing frame rate, pause and time scale), vsync and fullscreen. - Timers: Scene::after, every, cancel and hasTimer. They advance with the scene's delta time, run after entity updates, fire at most once a frame, and are cancelled when their owner entity dies. Callbacks can add and cancel timers safely. - Tweens: a non-serialised Tween component for position, size, rotation, sprite colour and custom values, with Ease functions, delays, and begin and complete callbacks. Start values are read after the delay, so tweens chain. Colour tweens look the sprite up by id, so removing the sprite first is safe. - Entity::update only updates the components it had when the frame began. A component added during an update, like a tween chained from onComplete, used to run in the same frame and consume its time. - The demo gets P (pause), N (step) and F (fps) keys, and spawned boxes pop in with a size tween. Tests: new unit test test_time for timers, easing and tweens, and engine test test_time_engine for pause, step, time scale, the console commands, colour tweens (including a removed sprite, which ASan checks) and fullscreen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015cNAkNqoDWA5gFReD1AsqV --- CLAUDE.md | 4 +- README.md | 27 +++- ROADMAP.md | 6 +- examples/demo.cpp | 10 ++ include/pancake/components/tween.hpp | 77 +++++++++++ include/pancake/core/engine.hpp | 16 +++ include/pancake/core/scene.hpp | 25 ++++ include/pancake/core/window.hpp | 15 +++ include/pancake/pancake.hpp | 1 + include/pancake/physics/world.hpp | 1 + src/components/tween.cpp | 189 +++++++++++++++++++++++++++ src/core/console.cpp | 119 +++++++++++++++++ src/core/engine.cpp | 48 +++++++ src/core/entity.cpp | 6 +- src/core/scene.cpp | 66 ++++++++++ src/core/window.cpp | 106 ++++++++++++++- src/physics/world.cpp | 4 + tests/CMakeLists.txt | 2 + tests/test_time.cpp | 136 +++++++++++++++++++ tests/test_time_engine.cpp | 184 ++++++++++++++++++++++++++ 20 files changed, 1032 insertions(+), 10 deletions(-) create mode 100644 include/pancake/components/tween.hpp create mode 100644 src/components/tween.cpp create mode 100644 tests/test_time.cpp create mode 100644 tests/test_time_engine.cpp 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; +}