Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()`.

Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions examples/demo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();}
Expand Down
7 changes: 7 additions & 0 deletions include/pancake/asset/assetpool.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions include/pancake/audio/audiowave.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.

};

Expand Down
5 changes: 5 additions & 0 deletions include/pancake/core/engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion include/pancake/core/entity.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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();

Expand Down
12 changes: 10 additions & 2 deletions include/pancake/core/inspector.hpp
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#pragma once

#include <glm/glm.hpp>

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();
Expand All @@ -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.
}

}
21 changes: 21 additions & 0 deletions include/pancake/core/prefab.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#pragma once

#include <string>
#include <glm/glm.hpp>

#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();

}

}
2 changes: 2 additions & 0 deletions include/pancake/graphics/texture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions include/pancake/pancake.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 57 additions & 0 deletions src/asset/assetpool.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <tuple>
#include <filesystem>
#include <utility>
#include <iostream>
#include <unordered_map>
Expand Down Expand Up @@ -27,6 +28,37 @@ namespace Pancake {
std::unordered_map<std::tuple<std::string, float>, Font*, TupleHash, TupleEqual> fonts;
std::unordered_map<std::string, AudioWave*> audio;

// When each file-backed asset's file was last modified, or the minimum if it didn't exist.
std::unordered_map<std::string, std::filesystem::file_time_type> textureTimes;
std::unordered_map<std::string, std::filesystem::file_time_type> 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<class T>
int reloadChangedIn(std::unordered_map<std::string, T*>& assets, std::unordered_map<std::string, std::filesystem::file_time_type>& 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() {
Expand All @@ -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.
Expand All @@ -70,6 +123,7 @@ namespace Pancake {
delete t;
}
textures.clear();
textureTimes.clear();
}

Texture* TexturePool::get(std::string name) {
Expand All @@ -82,6 +136,7 @@ namespace Pancake {
Texture* texture = new Texture(name);
std::pair<std::string, Texture*> p(texture->getName(), texture);
textures.insert(p);
textureTimes[name] = modifiedTime(name);
return texture;

}
Expand Down Expand Up @@ -238,6 +293,7 @@ namespace Pancake {
delete a;
}
audio.clear();
audioTimes.clear();
}

nlohmann::json AudioPool::serialise() {
Expand All @@ -259,6 +315,7 @@ namespace Pancake {
AudioWave* audioWave = new AudioWave(name);
std::pair<std::string, AudioWave*> p(audioWave->getFilename(), audioWave);
audio.insert(p);
audioTimes[name] = modifiedTime(name);
return audioWave;

}
Expand Down
8 changes: 8 additions & 0 deletions src/audio/audiowave.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
43 changes: 43 additions & 0 deletions src/core/console.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -210,6 +212,47 @@ namespace Pancake {
if (r != nullptr) {r->setVelocity(0.0f, 0.0f)->setAngularVelocity(0.0f);}
});

Console::registerCommand("clone", "clone <id|name> [x y]: Copy an entity, at a position or on top of it.", [](std::vector<std::string> 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 <id|name> [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 <file> <x> <y>: Spawn a prefab.", [](std::vector<std::string> args) {
float x, y;
if (args.size() != 3 || !parseFloat(args[1], &x) || !parseFloat(args[2], &y)) {Console::print("Usage: prefab <file> <x> <y>"); 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 <id|name> <file>: Save an entity as a prefab.", [](std::vector<std::string> args) {
if (args.size() != 2) {Console::print("Usage: saveprefab <id|name> <file>"); 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<std::string> 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<std::string> args) {
Console::print("Reloaded " + std::to_string(AssetPool::reloadChanged()) + " assets");
});

Console::registerCommand("fps", "fps [on|off]: Show the frame rate.", [](std::vector<std::string> args) {
fpsOverlay = parseState(args, fpsOverlay);
});
Expand Down
Loading
Loading