diff --git a/CLAUDE.md b/CLAUDE.md index 856ff0d..23ecefc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,8 @@ There is no external test framework. `tests/test.hpp` provides `TEST("name") {.. - `Camera::shake` offsets the view only (`getView`). `getPosition` stays the logical position, which `CameraFollow` and mouse world coordinates use. `CameraFollow` (`components/camerafollow.hpp`) is serialised; its logic is in `apply(camera, target, dt)` so it can be tested without a window. - `TransformableComponent` adds a position/size/rotation offset relative to the entity. +**Prefabs and copies** (`core/prefab.cpp`). `Entity::load(json)` keeps the ids in the json. `Entity::instantiate(json)` and `Entity::clone()` give the entity and its components new ids (a component keeps the id its constructor gave it). `Prefab::load/spawn` cache each file's json and read it again when its modified time changes. + **Serialisation / reflection** (`core/factory.hpp`). `REGISTER(Base, Derived)` registers a default constructor under the class name. `FACTORY(Component).create(type)` is used by `Entity::load`, and `FACTORY(Collider)` by `Rigidbody::load`. Every serialisable component needs a default constructor, a `serialise()`/`load(json)` pair that chains to its base class, and a `REGISTER` line. New fields added to existing components should be optional on load, so older save files keep loading (see `gravityScale`). `Scene::serialise` also writes gravity and the asset pools (fonts, spritesheets, sprites, audio). **Physics** (`physics/`). `Rigidbody` is a component holding non-component `Collider`s (`BoxCollider`, `CircleCollider`), which it owns. Mass, centroid, moment and bounds are lazily recomputed via dirty flags. A body with zero total collider mass is static. @@ -70,7 +72,9 @@ There is no external test framework. `tests/test.hpp` provides `TEST("name") {.. **Rendering** (`graphics/`). `Renderer` groups `SpriteRenderer`s into `RenderBatch`es by z-index (max 1000 quads and 7 textures per batch). `TextRenderer` and `Animation` create child `SpriteRenderer`s. Shader sources are constants in `asset/shaders.hpp`, and the default font (Pixellari) is embedded in `asset/fonts.hpp`. Assets are cached by name in the namespaces in `asset/assetpool.hpp`. `DebugDraw` draws immediate-mode lines with a lifetime in frames. -**Tools** (`core/console.cpp`, `core/inspector.cpp`). The console has a command registry (`Console::registerCommand`, `Console::execute`). The inspector picks the clicked entity with the picking texture, falling back to scene bounds for entities without sprites. It stores the selection as an id so it never dereferences a deleted entity, and it draws each component's `imgui()`. +**Hot reload** (`asset/assetpool.cpp`). `TexturePool::get` and `AudioPool::get` record each file's modified time. `AssetPool::reloadChanged()` reloads changed files in place (`Texture::reload` keeps the GL texture id, so sprites keep working), and `AssetPool::update` calls it twice a second of real time when `setHotReload(true)`. + +**Tools** (`core/console.cpp`, `core/inspector.cpp`). The console has a command registry (`Console::registerCommand`, `Console::execute`). The inspector picks the clicked entity with the picking texture, falling back to scene bounds for entities without sprites. It stores the selection as an id so it never dereferences a deleted entity, and it draws each component's `imgui()`. Dragging moves the selection and stops its body. The editing actions are public (`Inspector::duplicateSelected`, `deleteSelected`, `moveSelected`) so tests can call them. **Audio** (`audio/`). A SoLoud wrapper. `AudioEngine::init/destroy` wraps `Window::start()` in `Pancake::start()`. diff --git a/README.md b/README.md index 9b7bbbe..50045fd 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,9 @@ The engine provides the following features: - Resource loading from file system. - JSON serialization and deserialization for game data, including custom components. - 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`, `shake`, `gamepads`) and support for your own. -- An entity inspector: click an entity to view and edit its components. +- 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`, `shake`, `gamepads`, `clone`, `prefab`, `saveprefab`, `hotreload`, `reload`) and support for your own. +- An entity inspector: click an entity to view and edit its components, drag it to move it, duplicate or delete it, and pause, step, save and load from its toolbar. +- Entity cloning and prefabs, and hot reloading of textures and audio when their files change. - Debug drawing for collision shapes and other debugging purposes. ## Working with Entities @@ -226,6 +227,18 @@ 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); ``` +## Prefabs and Hot Reload +```c++ +Entity* copy = entity->clone(); // New ids, not yet in a scene. +scene->addEntity(copy); + +Prefab::save(entity, "assets/prefabs/crate.json"); +Entity* crate = Pancake::spawn("assets/prefabs/crate.json", vec2(3.0f, 4.0f)); // Read again whenever the file changes. + +Pancake::hotReload(true); // Reload textures and audio when their files change, while the game runs. +``` +Prefabs don't include assets, so load any sprites or spritesheets they use first. + ## Debugging Tools ```c++ Pancake::console(true); // Open the console. Pancake::console() toggles it. diff --git a/ROADMAP.md b/ROADMAP.md index d120ef0..b4dd716 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,6 +29,6 @@ Tick an item off in the same branch that implements it. ## Batch 5: editor tooling -- [ ] `Entity::clone()` and prefabs: `Pancake::spawn("prefab.json", position)`. -- [ ] Inspector edit mode: drag to move, delete, duplicate, play/pause, save and load buttons. -- [ ] Hot reload of textures and audio when their files change. +- [x] `Entity::clone()` and prefabs: `Pancake::spawn("prefab.json", position)`, `Prefab::save`, console `clone` / `prefab` / `saveprefab`. +- [x] Inspector edit mode: drag to move, delete, duplicate (buttons, Delete, Ctrl+D), play/pause/step, save and load buttons. +- [x] Hot reload of textures and audio when their files change (`Pancake::hotReload`, console `hotreload` / `reload`). Prefabs are always read again when they change. diff --git a/examples/demo.cpp b/examples/demo.cpp index b4a8424..354d206 100644 --- a/examples/demo.cpp +++ b/examples/demo.cpp @@ -5,7 +5,7 @@ // Right drag pan the camera // Scroll zoom // D toggle physics debug drawing -// I toggle the inspector +// I toggle the inspector (drag to move, Delete, Ctrl+D to duplicate) // ` toggle the console (try "help") // P pause or resume // N step one physics frame (pauses) @@ -164,7 +164,8 @@ class Sandbox : public Component { // Keyboard shortcuts are ignored while typing into an ImGui window, such as the console. if (!ImGui::GetIO().WantCaptureKeyboard) { if (KeyListener::isKeyBeginDown(GLFW_KEY_ESCAPE)) {Pancake::stop();} - if (KeyListener::isKeyBeginDown(GLFW_KEY_D)) {Console::execute("debug");} + bool control = KeyListener::isKeyDown(GLFW_KEY_LEFT_CONTROL) || KeyListener::isKeyDown(GLFW_KEY_RIGHT_CONTROL); + if (KeyListener::isKeyBeginDown(GLFW_KEY_D) && !control) {Console::execute("debug");} // Ctrl+D duplicates in the inspector. 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();} diff --git a/include/pancake/asset/assetpool.hpp b/include/pancake/asset/assetpool.hpp index 63e5fd5..247edbb 100644 --- a/include/pancake/asset/assetpool.hpp +++ b/include/pancake/asset/assetpool.hpp @@ -16,6 +16,13 @@ namespace Pancake { void clear(); void destroy(); + // Hot reload: textures and audio loaded from files are read again when the files change. + // Prefabs are always read again when they change. Sprites and players keep using the same objects. + int reloadChanged(); // Reload now. Returns how many assets were reloaded. + void setHotReload(bool enabled); + bool hasHotReload(); + void update(float dt); // Checks twice a second while hot reload is on. Called by the main loop. + } namespace TexturePool { diff --git a/include/pancake/audio/audiowave.hpp b/include/pancake/audio/audiowave.hpp index aeae047..49dfb79 100644 --- a/include/pancake/audio/audiowave.hpp +++ b/include/pancake/audio/audiowave.hpp @@ -30,6 +30,7 @@ namespace Pancake { void setLooping(bool looping); void stop(); + bool reload(); // Stop playback and read the file again. False if it can't be read. }; diff --git a/include/pancake/core/engine.hpp b/include/pancake/core/engine.hpp index 741dd11..4a615f6 100644 --- a/include/pancake/core/engine.hpp +++ b/include/pancake/core/engine.hpp @@ -51,6 +51,11 @@ namespace Pancake { void fullscreen(); bool isFullscreen(); + void hotReload(bool state); // Reload textures and audio when their files change. Off by default. + + // Copy a prefab file into the current scene at a position. Returns nullptr if the file can't be loaded. + Entity* spawn(std::string filename, vec2 position); + Scene* getScene(); Entity* getEntity(int id); Component* getComponent(int id); diff --git a/include/pancake/core/entity.hpp b/include/pancake/core/entity.hpp index c13cf9c..68f0cef 100644 --- a/include/pancake/core/entity.hpp +++ b/include/pancake/core/entity.hpp @@ -25,6 +25,7 @@ namespace Pancake { bool dead; void init(int id, glm::vec2 position, glm::vec2 size, float radians, bool load); + static Entity* load(nlohmann::json j, bool keepIds); public: @@ -40,7 +41,9 @@ namespace Pancake { void start(); void update(float dt); nlohmann::json serialise(); - static Entity* load(nlohmann::json j); + static Entity* load(nlohmann::json j); // Keeps the ids in the json, as when loading a scene. + static Entity* instantiate(nlohmann::json j); // Gives the entity and its components new ids, as for a prefab. + Entity* clone(); // A copy of the serialisable parts, with new ids, not yet in a scene. void imgui(); void kill(); diff --git a/include/pancake/core/inspector.hpp b/include/pancake/core/inspector.hpp index a10aefa..38bb7ed 100644 --- a/include/pancake/core/inspector.hpp +++ b/include/pancake/core/inspector.hpp @@ -1,9 +1,12 @@ #pragma once +#include + namespace Pancake { - // An ImGui window for viewing and editing the scene. While open, left clicking in the - // game selects the entity under the mouse and shows the editor for each of its components. + // An ImGui window for viewing and editing the scene. While open, left clicking in the game selects the + // entity under the mouse and shows the editor for each of its components, and dragging moves it. + // Delete removes the selected entity and Ctrl+D duplicates it. The toolbar pauses, steps, saves and loads. namespace Inspector { void open(); void close(); @@ -14,6 +17,11 @@ namespace Pancake { int getSelected(); void select(int entityId); + + // Editing actions on the selected entity, as the buttons and shortcuts do. + int duplicateSelected(); // Selects and returns the copy's id, or -1. + bool deleteSelected(); + void moveSelected(glm::vec2 delta); // Bodies are stopped, so they don't fly off when let go. } } diff --git a/include/pancake/core/prefab.hpp b/include/pancake/core/prefab.hpp new file mode 100644 index 0000000..d6309d6 --- /dev/null +++ b/include/pancake/core/prefab.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "pancake/core/entity.hpp" + +namespace Pancake { + + // Prefabs are entities saved to json files and copied into scenes with new ids. Files are cached, and + // read again when they change. Assets the entity uses, such as sprites, must already be loaded. + namespace Prefab { + + bool save(Entity* entity, std::string filename); + Entity* load(std::string filename); // A new copy, not yet in a scene, or nullptr. + Entity* spawn(std::string filename, glm::vec2 position); // A new copy added to the current scene. + void clearCache(); + + } + +} diff --git a/include/pancake/graphics/texture.hpp b/include/pancake/graphics/texture.hpp index 55796a7..13d73f8 100644 --- a/include/pancake/graphics/texture.hpp +++ b/include/pancake/graphics/texture.hpp @@ -29,6 +29,8 @@ namespace Pancake { Texture(GLint internal, int width, int height, GLenum format, GLenum type); ~Texture(); + bool reload(); // Read the image file again into this texture, keeping its id. False if it can't be read. + void bind(); void unbind(); diff --git a/include/pancake/pancake.hpp b/include/pancake/pancake.hpp index c5922bb..3e288a4 100644 --- a/include/pancake/pancake.hpp +++ b/include/pancake/pancake.hpp @@ -23,6 +23,7 @@ #include "pancake/core/factory.hpp" #include "pancake/core/inspector.hpp" #include "pancake/core/listener.hpp" +#include "pancake/core/prefab.hpp" #include "pancake/core/scene.hpp" #include "pancake/core/spatial.hpp" #include "pancake/core/window.hpp" diff --git a/src/asset/assetpool.cpp b/src/asset/assetpool.cpp index 80bdbc9..4346995 100644 --- a/src/asset/assetpool.cpp +++ b/src/asset/assetpool.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -27,6 +28,37 @@ namespace Pancake { std::unordered_map, Font*, TupleHash, TupleEqual> fonts; std::unordered_map audio; + // When each file-backed asset's file was last modified, or the minimum if it didn't exist. + std::unordered_map textureTimes; + std::unordered_map audioTimes; + bool hotReload = false; + float hotReloadTimer = 0.0f; + const float HOT_RELOAD_INTERVAL = 0.5f; + + std::filesystem::file_time_type modifiedTime(const std::string& filename) { + std::error_code error; + std::filesystem::file_time_type time = std::filesystem::last_write_time(filename, error); + return error ? std::filesystem::file_time_type::min() : time; + } + + // Reloads assets whose files have changed, with reload returning whether it worked. + template + int reloadChangedIn(std::unordered_map& assets, std::unordered_map& times) { + int reloaded = 0; + for (auto& entry : times) { + std::filesystem::file_time_type now = modifiedTime(entry.first); + if (now == entry.second || now == std::filesystem::file_time_type::min()) {continue;} + entry.second = now; + auto asset = assets.find(entry.first); + if (asset == assets.end()) {continue;} + if (asset->second->reload()) { + std::cout << "Reloaded '" << entry.first << "'\n"; + reloaded++; + } + } + return reloaded; + } + } void AssetPool::init() { @@ -50,6 +82,27 @@ namespace Pancake { AudioPool::destroy(); } + int AssetPool::reloadChanged() { + return reloadChangedIn(textures, textureTimes) + reloadChangedIn(audio, audioTimes); + } + + void AssetPool::setHotReload(bool enabled) { + hotReload = enabled; + hotReloadTimer = 0.0f; + } + + bool AssetPool::hasHotReload() { + return hotReload; + } + + void AssetPool::update(float dt) { + if (!hotReload) {return;} + hotReloadTimer += dt; + if (hotReloadTimer < HOT_RELOAD_INTERVAL) {return;} + hotReloadTimer = 0.0f; + AssetPool::reloadChanged(); + } + void TexturePool::init() { // Add the missing texture to the pool. @@ -70,6 +123,7 @@ namespace Pancake { delete t; } textures.clear(); + textureTimes.clear(); } Texture* TexturePool::get(std::string name) { @@ -82,6 +136,7 @@ namespace Pancake { Texture* texture = new Texture(name); std::pair p(texture->getName(), texture); textures.insert(p); + textureTimes[name] = modifiedTime(name); return texture; } @@ -238,6 +293,7 @@ namespace Pancake { delete a; } audio.clear(); + audioTimes.clear(); } nlohmann::json AudioPool::serialise() { @@ -259,6 +315,7 @@ namespace Pancake { AudioWave* audioWave = new AudioWave(name); std::pair p(audioWave->getFilename(), audioWave); audio.insert(p); + audioTimes[name] = modifiedTime(name); return audioWave; } diff --git a/src/audio/audiowave.cpp b/src/audio/audiowave.cpp index cafa75b..4fb6c84 100644 --- a/src/audio/audiowave.cpp +++ b/src/audio/audiowave.cpp @@ -57,4 +57,12 @@ namespace Pancake { this->source.stop(); } + bool AudioWave::reload() { + this->source.stop(); + if (this->source.load(this->filename.c_str()) == SoLoud::SO_NO_ERROR) {return true;} + std::cout << "ERROR::AUDIOWAVE::RELOAD_FAILED: '" << this->filename << "'\n"; + this->source.loadMem(AUDIO_EMPTY, AUDIO_EMPTY_LEN); + return false; + } + } \ No newline at end of file diff --git a/src/core/console.cpp b/src/core/console.cpp index 695645b..e768770 100644 --- a/src/core/console.cpp +++ b/src/core/console.cpp @@ -8,6 +8,8 @@ #include "pancake/core/inspector.hpp" #include "pancake/core/window.hpp" #include "pancake/core/listener.hpp" +#include "pancake/core/prefab.hpp" +#include "pancake/asset/assetpool.hpp" #include "pancake/graphics/debugdraw.hpp" #include "pancake/graphics/spriterenderer.hpp" #include "pancake/physics/rigidbody.hpp" @@ -210,6 +212,47 @@ namespace Pancake { if (r != nullptr) {r->setVelocity(0.0f, 0.0f)->setAngularVelocity(0.0f);} }); + Console::registerCommand("clone", "clone [x y]: Copy an entity, at a position or on top of it.", [](std::vector args) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + float x, y; + bool positioned = args.size() == 3 && parseFloat(args[1], &x) && parseFloat(args[2], &y); + if (args.size() != 1 && !positioned) {Console::print("Usage: clone [x y]"); return;} + Entity* e = findEntity(args[0]); + if (e == nullptr) {Console::print("No entity " + args[0]); return;} + Entity* copy = e->clone(); + if (copy == nullptr) {Console::print("Could not clone entity " + args[0]); return;} + if (positioned) {copy->setPosition(glm::vec2(x, y));} + scene->addEntity(copy); + Console::print("Cloned entity " + std::to_string(copy->getId())); + }); + + Console::registerCommand("prefab", "prefab : Spawn a prefab.", [](std::vector args) { + float x, y; + if (args.size() != 3 || !parseFloat(args[1], &x) || !parseFloat(args[2], &y)) {Console::print("Usage: prefab "); return;} + Entity* e = Prefab::spawn(args[0], glm::vec2(x, y)); + if (e == nullptr) {Console::print("Could not load prefab " + args[0]); return;} + Console::print("Spawned entity " + std::to_string(e->getId())); + }); + + Console::registerCommand("saveprefab", "saveprefab : Save an entity as a prefab.", [](std::vector args) { + if (args.size() != 2) {Console::print("Usage: saveprefab "); return;} + Entity* e = findEntity(args[0]); + if (e == nullptr) {Console::print("No entity " + args[0]); return;} + if (Prefab::save(e, args[1])) {Console::print("Saved prefab " + args[1]);} + else {Console::print("Could not save prefab " + args[1]);} + }); + + Console::registerCommand("hotreload", "hotreload [on|off]: Reload textures and audio when their files change.", [](std::vector args) { + bool state = parseState(args, AssetPool::hasHotReload()); + AssetPool::setHotReload(state); + Console::print(std::string("Hot reload ") + (state ? "on" : "off")); + }); + + Console::registerCommand("reload", "Reload textures and audio whose files have changed.", [](std::vector args) { + Console::print("Reloaded " + std::to_string(AssetPool::reloadChanged()) + " assets"); + }); + Console::registerCommand("fps", "fps [on|off]: Show the frame rate.", [](std::vector args) { fpsOverlay = parseState(args, fpsOverlay); }); diff --git a/src/core/engine.cpp b/src/core/engine.cpp index 5978e04..279507b 100644 --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -3,6 +3,8 @@ #include "pancake/audio/audioengine.hpp" #include "pancake/core/window.hpp" #include "pancake/core/inspector.hpp" +#include "pancake/core/prefab.hpp" +#include "pancake/asset/assetpool.hpp" #include "pancake/core/factory.hpp" #include "pancake/core/component.hpp" @@ -137,6 +139,14 @@ namespace Pancake { return Window::isFullscreen(); } + void hotReload(bool state) { + AssetPool::setHotReload(state); + } + + Entity* spawn(std::string filename, vec2 position) { + return Prefab::spawn(filename, position); + } + Scene* getScene() { return Window::getScene(); } diff --git a/src/core/entity.cpp b/src/core/entity.cpp index 7024f1e..89c05f9 100644 --- a/src/core/entity.cpp +++ b/src/core/entity.cpp @@ -125,6 +125,18 @@ namespace Pancake { } Entity* Entity::load(nlohmann::json j) { + return Entity::load(j, true); + } + + Entity* Entity::instantiate(nlohmann::json j) { + return Entity::load(j, false); + } + + Entity* Entity::clone() { + return Entity::instantiate(this->serialise()); + } + + Entity* Entity::load(nlohmann::json j, bool keepIds) { if (!j.contains("id") || !j["id"].is_number_integer()) {return nullptr;} @@ -142,7 +154,8 @@ namespace Pancake { if (j.contains("name") && !j["name"].is_string()) {return nullptr;} Entity* e = new Entity(); - e->init(j["id"], glm::vec2(j["position"][0], j["position"][1]), glm::vec2(j["size"][0], j["size"][1]), j["rotation"], true); + int id = keepIds ? (int) j["id"] : e->getId(); + e->init(id, glm::vec2(j["position"][0], j["position"][1]), glm::vec2(j["size"][0], j["size"][1]), j["rotation"], true); // Loading only moves the id counter forwards. if (j.contains("name")) {e->name = j["name"];} if (j.contains("components") && j["components"].is_array()) { @@ -151,8 +164,10 @@ namespace Pancake { if (!element.contains("type") || !element["type"].is_string()) {continue;} Component* c = FACTORY(Component).create(element["type"]); if (c == nullptr) {continue;} + int fresh = c->getId(); // The id the constructor gave it, kept for new copies. if (!c->load(element)) {delete c; continue;} - else {e->addComponent(c);} + if (!keepIds) {c->setId(fresh);} + e->addComponent(c); } } } diff --git a/src/core/inspector.cpp b/src/core/inspector.cpp index 2dbbd5f..420ce58 100644 --- a/src/core/inspector.cpp +++ b/src/core/inspector.cpp @@ -7,6 +7,7 @@ #include "pancake/core/listener.hpp" #include "pancake/core/window.hpp" #include "pancake/graphics/debugdraw.hpp" +#include "pancake/physics/rigidbody.hpp" namespace Pancake { @@ -14,6 +15,53 @@ namespace Pancake { bool active = false; int selected = -1; // Stored as an id so a deleted entity is never dereferenced. + bool dragging = false; + char filename[256] = "scene.json"; + + int pick(); + + Entity* selectedEntity() { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return nullptr;} + Entity* e = scene->getEntity(selected); + if (e == nullptr || e->isDead()) {return nullptr;} + return e; + } + + void toolbar() { + + if (ImGui::Button(Window::isPaused() ? "Play" : "Pause")) {Window::setPaused(!Window::isPaused());} + ImGui::SameLine(); + if (ImGui::Button("Step")) {Window::step(1);} + + ImGui::SetNextItemWidth(-110.0f); + ImGui::InputText("##File", filename, sizeof(filename)); + ImGui::SameLine(); + if (ImGui::Button("Save")) {Window::save(filename);} + ImGui::SameLine(); + if (ImGui::Button("Load")) {Window::load(std::string(filename));} + if (ImGui::IsItemHovered()) {ImGui::SetTooltip("Adds the file's entities to the current scene, like the load command.");} + + } + + // Select on click, then move the selection with the mouse while the button is held. + void handleMouse() { + if (MouseListener::isMouseBeginDown(GLFW_MOUSE_BUTTON_LEFT) && !ImGui::GetIO().WantCaptureMouse) { + selected = pick(); + dragging = selectedEntity() != nullptr; + } + if (!MouseListener::isMouseDown(GLFW_MOUSE_BUTTON_LEFT)) {dragging = false;} + if (dragging && MouseListener::hasMouseMoved()) { + Inspector::moveSelected(glm::vec2(MouseListener::getWorldDx(), MouseListener::getWorldDy())); + } + } + + void handleKeys() { + if (ImGui::GetIO().WantCaptureKeyboard) {return;} + bool control = KeyListener::isKeyDown(GLFW_KEY_LEFT_CONTROL) || KeyListener::isKeyDown(GLFW_KEY_RIGHT_CONTROL); + if (KeyListener::isKeyBeginDown(GLFW_KEY_DELETE)) {Inspector::deleteSelected();} + if (control && KeyListener::isKeyBeginDown(GLFW_KEY_D)) {Inspector::duplicateSelected();} + } // Find the entity under the mouse, first by the picking texture, then by entity bounds. int pick() { @@ -76,16 +124,41 @@ namespace Pancake { selected = entityId; } + int duplicateSelected() { + Entity* e = selectedEntity(); + if (e == nullptr) {return -1;} + Entity* copy = e->clone(); + if (copy == nullptr) {return -1;} + copy->addPosition(glm::vec2(0.5f, 0.5f)); // Offset, so the copy can be seen and grabbed. + Window::getScene()->addEntity(copy); + selected = copy->getId(); + return selected; + } + + bool deleteSelected() { + Entity* e = selectedEntity(); + if (e == nullptr) {return false;} + e->kill(); + selected = -1; + return true; + } + + void moveSelected(glm::vec2 delta) { + Entity* e = selectedEntity(); + if (e == nullptr) {return;} + e->addPosition(delta); + Rigidbody* r = e->getComponent(); + if (r != nullptr) {r->setVelocity(0.0f, 0.0f)->setAngularVelocity(0.0f);} + } + void render() { if (!active) {return;} Scene* scene = Window::getScene(); if (scene == nullptr) {return;} - // Select the entity under the mouse when clicking on the game, not on a window. - if (MouseListener::isMouseBeginDown(GLFW_MOUSE_BUTTON_LEFT) && !ImGui::GetIO().WantCaptureMouse) { - selected = pick(); - } + handleMouse(); + handleKeys(); // Open on the right hand side, clear of the console. ImGuiIO& io = ImGui::GetIO(); @@ -93,6 +166,9 @@ namespace Pancake { ImGui::SetNextWindowSize(ImVec2(360, io.DisplaySize.y - 20.0f), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Inspector", &active)) {ImGui::End(); return;} + toolbar(); + ImGui::Separator(); + // Scene settings. if (ImGui::CollapsingHeader("Scene", ImGuiTreeNodeFlags_DefaultOpen)) { @@ -126,6 +202,10 @@ namespace Pancake { ImGui::Spacing(); ImGui::Separator(); ImGui::Text("Entity %d", entity->getId()); + ImGui::SameLine(); + if (ImGui::SmallButton("Duplicate")) {Inspector::duplicateSelected();} + ImGui::SameLine(); + if (ImGui::SmallButton("Delete")) {Inspector::deleteSelected();} ImGui::PushID(entity->getId()); entity->imgui(); ImGui::PopID(); diff --git a/src/core/prefab.cpp b/src/core/prefab.cpp new file mode 100644 index 0000000..2cdb175 --- /dev/null +++ b/src/core/prefab.cpp @@ -0,0 +1,85 @@ +#include +#include +#include +#include + +#include "pancake/core/prefab.hpp" +#include "pancake/core/window.hpp" + +namespace Pancake { + + namespace { + + struct CachedPrefab { + std::filesystem::file_time_type modified; + nlohmann::json json; + }; + + std::unordered_map cache; + + // The prefab's json, read again if the file has changed since it was cached. + bool read(std::string filename, nlohmann::json* out) { + + std::error_code error; + std::filesystem::file_time_type modified = std::filesystem::last_write_time(filename, error); + if (error) { + std::cout << "ERROR::PREFAB::FILE_NOT_FOUND: '" << filename << "'\n"; + return false; + } + + auto it = cache.find(filename); + if (it != cache.end() && it->second.modified == modified) {*out = it->second.json; return true;} + + std::ifstream file(filename); + nlohmann::json j = nlohmann::json::parse(file, nullptr, false); + if (j.is_discarded() || !j.is_object()) { + std::cout << "ERROR::PREFAB::INVALID_JSON: '" << filename << "'\n"; + return false; + } + + cache[filename] = {modified, j}; + *out = j; + return true; + + } + + } + + namespace Prefab { + + bool save(Entity* entity, std::string filename) { + if (entity == nullptr) {return false;} + std::ofstream file(filename); + if (!file.is_open()) { + std::cout << "ERROR::PREFAB::SAVE::FILE_NOT_OPENED: '" << filename << "'\n"; + return false; + } + file << entity->serialise().dump(4); + return file.good(); + } + + Entity* load(std::string filename) { + nlohmann::json j; + if (!read(filename, &j)) {return nullptr;} + Entity* e = Entity::instantiate(j); + if (e == nullptr) {std::cout << "ERROR::PREFAB::INVALID_ENTITY: '" << filename << "'\n";} + return e; + } + + Entity* spawn(std::string filename, glm::vec2 position) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return nullptr;} + Entity* e = Prefab::load(filename); + if (e == nullptr) {return nullptr;} + e->setPosition(position); + scene->addEntity(e); + return e; + } + + void clearCache() { + cache.clear(); + } + + } + +} diff --git a/src/core/window.cpp b/src/core/window.cpp index ad1223e..f036278 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -334,6 +334,7 @@ namespace Pancake { } if (dt > 0) { + AssetPool::update(dt); // Real time, so hot reload works while paused. unscaledDeltaTime = dt; fps = fps <= 0.0f ? 1.0f / dt : 0.95f * fps + 0.05f / dt; update(sceneDeltaTime(dt)); diff --git a/src/graphics/texture.cpp b/src/graphics/texture.cpp index add20b2..1d52da2 100644 --- a/src/graphics/texture.cpp +++ b/src/graphics/texture.cpp @@ -131,6 +131,34 @@ namespace Pancake { glDeleteTextures(1, &this->id); } + bool Texture::reload() { + + int width; + int height; + int channels; + stbi_set_flip_vertically_on_load(1); + unsigned char* image = stbi_load(this->name.c_str(), &width, &height, &channels, 0); + if (image == nullptr || (channels != 3 && channels != 4)) { + std::cout << "ERROR::TEXTURE::RELOAD_FAILED '" << this->name << "'\n"; + if (image != nullptr) {stbi_image_free(image);} + return false; + } + + // Rows of 3 channel images aren't always 4 byte aligned, which is OpenGL's default. + glBindTexture(GL_TEXTURE_2D, this->id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + GLenum format = channels == 3 ? GL_RGB : GL_RGBA; + glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, image); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + stbi_image_free(image); + + this->width = width; + this->height = height; + this->missingFlag = false; + return true; + + } + void Texture::bind() { glBindTexture(GL_TEXTURE_2D, this->id); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 014d67d..66e3217 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,6 +36,8 @@ pancake_test(test_entity) pancake_test(test_time) pancake_test(test_collision_events) pancake_test(test_camera) +pancake_test(test_prefab) pancake_engine_test(test_engine) pancake_engine_test(test_time_engine) pancake_engine_test(test_camera_engine) +pancake_engine_test(test_editor_engine) diff --git a/tests/test_editor_engine.cpp b/tests/test_editor_engine.cpp new file mode 100644 index 0000000..cb15787 --- /dev/null +++ b/tests/test_editor_engine.cpp @@ -0,0 +1,167 @@ +// Runs the real engine loop to check the inspector's editing actions, spawning prefabs, and hot reloading +// textures and audio from files the test writes and then changes. + +#include +#include +#include +#include +#include +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +namespace { + + const char* TEXTURE_FILE = "test_editor_texture.png"; + const char* AUDIO_FILE = "test_editor_audio.wav"; + const char* PREFAB_FILE = "test_editor_prefab.json"; + + int frame = 0; + int reloadStarted = -1; + unsigned int textureId = 0; + + void log(const char* message) { + std::printf("frame %d: %s\n", frame, message); + std::fflush(stdout); + } + + void writeImage(int size) { + std::vector pixels(size * size * 4, 255); + stbi_write_png(TEXTURE_FILE, size, size, 4, pixels.data(), size * 4); + } + + // An 8 bit mono PCM wave of silence. + void writeWave(uint32_t samples) { + uint32_t rate = 8000; + std::ofstream f(AUDIO_FILE, std::ios::binary); + auto u32 = [&f](uint32_t v) {f.write(reinterpret_cast(&v), 4);}; + auto u16 = [&f](uint16_t v) {f.write(reinterpret_cast(&v), 2);}; + f.write("RIFF", 4); u32(36 + samples); f.write("WAVE", 4); + f.write("fmt ", 4); u32(16); u16(1); u16(1); u32(rate); u32(rate); u16(1); u16(8); + f.write("data", 4); u32(samples); + std::vector data(samples, (char) 128); + f.write(data.data(), samples); + } + + // Moves a file's modified time on, so a change is seen even on file systems with coarse timestamps. + void touch(const char* filename, int seconds) { + std::filesystem::last_write_time(filename, std::filesystem::last_write_time(filename) + std::chrono::seconds(seconds)); + } + +} + +class Driver : public Component { + + public: + + Driver() : Component("Driver") {} + + void update(float dt) override { + + frame++; + Scene* scene = Pancake::getScene(); + + if (frame == 3) { + log("duplicate, move and delete through the inspector"); + Entity* crate = scene->findEntity("Crate"); + Inspector::select(crate->getId()); + int copyId = Inspector::duplicateSelected(); + CHECK(copyId >= 0 && copyId != crate->getId()); + CHECK(Inspector::getSelected() == copyId); + Entity* copy = scene->getEntity(copyId); + CHECK(copy != nullptr && copy->hasComponent()); + CHECK(copy->getPosition() == crate->getPosition() + vec2(0.5f, 0.5f)); + CHECK(scene->findEntities("Crate").size() == 2); + + copy->getComponent()->setVelocity(5.0f, 5.0f); + Inspector::moveSelected(vec2(3.0f, 0.0f)); + CHECK(copy->getPosition().x == crate->getPosition().x + 3.5f); + CHECK(copy->getComponent()->getVelocity() == vec2(0.0f)); // Stopped while held. + + CHECK(Inspector::deleteSelected()); + CHECK(copy->isDead()); + CHECK(Inspector::getSelected() == -1); + CHECK(!Inspector::deleteSelected()); + CHECK(Inspector::duplicateSelected() == -1); + } + + if (frame == 5) { + log("save a prefab and spawn it from code and the console"); + Console::execute("saveprefab Crate " + std::string(PREFAB_FILE)); + Entity* a = Pancake::spawn(PREFAB_FILE, vec2(-4.0f, 3.0f)); + CHECK(a != nullptr && a->getPosition() == vec2(-4.0f, 3.0f)); + Console::execute("prefab " + std::string(PREFAB_FILE) + " 4 3"); + Console::execute("clone Crate 0 6"); + CHECK(scene->findEntities("Crate").size() == 4); + CHECK(Pancake::spawn("no_such_prefab.json", vec2(0.0f)) == nullptr); + } + + if (frame == 6) { + log("load a texture and a sound from files, then change the files"); + writeImage(2); + writeWave(800); + Texture* texture = TexturePool::get(TEXTURE_FILE); + CHECK(!texture->isMissing() && texture->getWidth() == 2); + textureId = texture->getId(); + CHECK_NEAR(AudioPool::get(AUDIO_FILE)->getLength(), 0.1, 1e-3); + + Pancake::hotReload(true); + writeImage(4); + writeWave(1600); + touch(TEXTURE_FILE, 2); + touch(AUDIO_FILE, 2); + reloadStarted = frame; + } + + // Hot reload checks twice a second of real time, so wait for it. + if (reloadStarted > 0 && TexturePool::get(TEXTURE_FILE)->getWidth() == 4) { + log("the texture and sound were reloaded in place"); + Texture* texture = TexturePool::get(TEXTURE_FILE); + CHECK(texture->getId() == textureId); + CHECK(texture->getHeight() == 4); + CHECK_NEAR(AudioPool::get(AUDIO_FILE)->getLength(), 0.2, 1e-3); + CHECK(AssetPool::reloadChanged() == 0); // Nothing has changed since. + + // Reloading on request, with hot reload off. + Pancake::hotReload(false); + writeImage(8); + touch(TEXTURE_FILE, 4); + Console::execute("reload"); + CHECK(texture->getWidth() == 8); + reloadStarted = -1; + Pancake::stop(); + } + + if (reloadStarted > 0 && frame - reloadStarted > 5000) {log("hot reload never happened"); CHECK(false); Pancake::stop();} + + } + +}; + +void init(Scene* scene) { + Entity* crate = new Entity(vec2(0.0f, 0.0f)); + crate->setName("Crate"); + crate->addComponent(); + BoxCollider* box = new BoxCollider(); + box->setMass(1.0f); + crate->addComponent()->addCollider(box)->setGravityScale(0.0f); + scene->addEntity(crate); + + Entity* d = new Entity(); + d->addComponent(new Driver()); + scene->addEntity(d); +} + +int main() { + Pancake::load(init); + Pancake::start(); + CHECK(reloadStarted == -1 && frame > 6); + std::remove(TEXTURE_FILE); + std::remove(AUDIO_FILE); + std::remove(PREFAB_FILE); + std::printf("engine ran %d frames, %d failed checks\n", frame, PancakeTest::failures()); + return PancakeTest::failures() == 0 ? 0 : 1; +} diff --git a/tests/test_prefab.cpp b/tests/test_prefab.cpp new file mode 100644 index 0000000..01ea227 --- /dev/null +++ b/tests/test_prefab.cpp @@ -0,0 +1,125 @@ +// Cloning entities, and saving and loading prefabs, with no window. + +#include +#include +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +namespace { + + const char* PREFAB_FILE = "test_prefab_crate.json"; + + // A component that isn't saved, to check clones and prefabs leave it out. + struct Scratch : public Component { + Scratch() : Component("Scratch") {this->setSerialisable(false);} + }; + + Entity* crate() { + Entity* e = new Entity(glm::vec2(1.0f, 2.0f), glm::vec2(2.0f, 1.0f), 0.25f); + e->setName("Crate"); + BoxCollider* box = new BoxCollider(); + box->setSize(2.0f, 1.0f); + box->setMass(3.0f); + e->addComponent()->addCollider(box)->setCollisionLayer(1 << 4); + e->addComponent()->setAcceleration(0.0f, -2.0f); + e->addComponent(); + return e; + } + +} + +TEST("clones copy saved state with new entity and component ids") { + Entity* original = crate(); + Entity* copy = original->clone(); + CHECK(copy != nullptr); + if (copy == nullptr) {delete original; return;} + + CHECK(copy->getId() != original->getId()); + CHECK(copy->getName() == "Crate"); + CHECK(copy->getPosition() == original->getPosition()); + CHECK_NEAR(copy->getRotation(), 0.25, 1e-6); + CHECK(copy->getComponents().size() == 2); // The unsaved component is left out. + CHECK(!copy->hasComponent()); + + Rigidbody* r = copy->getComponent(); + CHECK(r != nullptr); + if (r != nullptr) { + CHECK(r->getId() != original->getComponent()->getId()); + CHECK_NEAR(r->getMass(), 3.0, 1e-6); + CHECK(r->getCollisionLayer() == (1u << 4)); + CHECK(r->getEntity() == copy); + } + + // Ids are new, and later entities don't collide with them. + Entity later; + CHECK(later.getId() != copy->getId()); + CHECK(copy->getComponent()->getId() != original->getComponent()->getId()); + + delete copy; + delete original; +} + +TEST("load keeps json ids while instantiate makes new ones") { + Entity* original = crate(); + nlohmann::json j = original->serialise(); + Entity* loaded = Entity::load(j); + Entity* instance = Entity::instantiate(j); + CHECK(loaded->getId() == original->getId()); + CHECK(instance->getId() != original->getId()); + CHECK(instance->getId() != loaded->getId()); + nlohmann::json bad = j; + bad.erase("position"); + CHECK(Entity::instantiate(bad) == nullptr); + delete instance; + delete loaded; + delete original; +} + +TEST("prefabs save, load as new copies, and are read again when the file changes") { + Entity* original = crate(); + CHECK(Prefab::save(original, PREFAB_FILE)); + + Entity* a = Prefab::load(PREFAB_FILE); + Entity* b = Prefab::load(PREFAB_FILE); + CHECK(a != nullptr && b != nullptr); + if (a == nullptr || b == nullptr) {delete original; return;} + CHECK(a->getId() != b->getId()); + CHECK(a->getId() != original->getId()); + CHECK(a->getPosition() == glm::vec2(1.0f, 2.0f)); + CHECK_NEAR(a->getComponent()->getMass(), 3.0, 1e-6); + + // Change the file, with a later modified time even on file systems with coarse timestamps. + original->setName("Heavy Crate"); + CHECK(Prefab::save(original, PREFAB_FILE)); + std::filesystem::last_write_time(PREFAB_FILE, std::filesystem::last_write_time(PREFAB_FILE) + std::chrono::seconds(2)); + Entity* c = Prefab::load(PREFAB_FILE); + CHECK(c != nullptr && c->getName() == "Heavy Crate"); + + delete a; + delete b; + delete c; + delete original; + std::remove(PREFAB_FILE); +} + +TEST("missing and malformed prefabs load as nullptr") { + CHECK(Prefab::load("no_such_prefab.json") == nullptr); + { + std::ofstream file(PREFAB_FILE); + file << "{ not json"; + } + Prefab::clearCache(); + CHECK(Prefab::load(PREFAB_FILE) == nullptr); + { + std::ofstream file(PREFAB_FILE); + file << "{\"id\": 1}"; + } + std::filesystem::last_write_time(PREFAB_FILE, std::filesystem::last_write_time(PREFAB_FILE) + std::chrono::seconds(4)); + CHECK(Prefab::load(PREFAB_FILE) == nullptr); + std::remove(PREFAB_FILE); +} + +RUN_TESTS()