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
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,18 @@ There is no external test framework. `tests/test.hpp` provides `TEST("name") {..
- `World::fixedUpdate` runs at 1/60 s, at most 5 steps per frame:
1. Force generators add forces.
2. `integrateVelocity` adds forces and world gravity 脳 `gravityScale`.
3. Pairs are found through the `SpatialHashGrid<Rigidbody*>` and ordered by id.
4. Narrow phase runs (`collision.cpp`) and `CollisionListener` components are notified.
3. Bodies moved by game code update their grid cells. Pairs are found through the `SpatialHashGrid<Rigidbody*>`, ordered by id, and skipped unless `canCollideWith` (each layer is in the other's mask).
4. Narrow phase runs (`collision.cpp`). Listener events (enter, contact) are queued, not sent.
5. Velocities are solved with warm-started sequential impulses, using a contact cache keyed by body id pair.
6. `integratePosition` moves the bodies.
7. Positions are corrected once.
8. Exits are found by comparing against `touching` (pairs touching after the last step), then events are sent in body-id-pair order. A pair joins `touching` only when its enter is sent, and `World::remove` sends exits for the removed body's pairs. So a listener can kill bodies mid-dispatch, and exits always match delivered enters.

This order matters: integrating positions before solving made stacks jitter and collapse.
- Collision features use one convention: the normal points from the first collider to the second, depth is the full penetration, and the point lies midway between the surfaces.
- `Scene::~Scene` calls `World::clearContacts()` before killing entities, so teardown sends no exits. `Entity::kill` and `~Entity` mark the entity dead before ending components, and listeners on dead entities or dead components are skipped.
- Forces are opt-in per body: `rigidbody->addForceGenerator("Gravity")` subscribes the body to the generator component of that type name.
- `World::raycast(ray, maxDistance)` walks grid cells along the ray.
- `World::raycast(ray, maxDistance, mask)` walks grid cells along the ray, skipping bodies whose layer isn't in the mask.
- Physics debug drawing is off by default (`World::setDebugDraw`, console `debug`).

**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.
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ The engine provides the following features:
- 2D graphics rendering using OpenGL.
- 2D rigidbody physics with box and circle colliders, combinable into compound bodies, solved with warm started sequential impulses so stacks stay stable.
- World gravity, and force generator components for custom forces that act only on the bodies that opt in.
- Collision listeners, sensors and raycasts.
- Collision listeners with enter and exit events, sensors, collision layers and masks, and raycasts.
- Spatial queries to find entities in an area, near a point or under the mouse.
- Audio playback and management using SoLoud.
- Input management for keyboard and mouse, with pressed, held and released events.
Expand Down Expand Up @@ -168,6 +168,27 @@ int level = 2;
Pancake::load([level](Scene* scene) { /* build the level */ });
```

## Collisions
```c++
class Coin : public Component, public CollisionListener {
public:
Coin() : Component("Coin") {}
void collisionEnter(Entity* with, CollisionManifold manifold) override {
if (with->getName() == "Player") {this->getEntity()->kill();} // Safe: listeners run after the step.
}
void collisionExit(Entity* with) override {} // Also sent when the other body is removed.
void collision(Entity* with, CollisionManifold manifold) override {} // Every contact, every step.
};

// Layers are bits. Two bodies collide only if each one's layer is in the other's mask.
const uint32_t PLAYER = 1 << 1, ENEMY = 1 << 2, PICKUP = 1 << 3;
player->setCollisionLayer(PLAYER)->setCollisionMask(ALL_LAYERS & ~PLAYER);
coin->setCollisionLayer(PICKUP)->setCollisionMask(PLAYER)->setSensor(true);

// Raycasts can skip layers too.
RaycastResult hit = scene->getPhysics()->raycast(Ray(origin, direction), 10.0f, ALL_LAYERS & ~PICKUP);
```

## Time, Timers and Tweens
```c++
Pancake::timeScale(0.25f); // Slow motion. Components get the scaled delta time.
Expand Down Expand Up @@ -208,6 +229,9 @@ Console::registerCommand("spawn", "spawn <x> <y>: 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`.
- Collision listeners are now called at the end of each physics step, after the solver, rather than during it. A body's velocity read in `collision` is the resolved one. Listeners can now kill entities safely.
- An entity is marked dead before its components end, so its own listeners don't receive collision exits while it is being killed.
- Bodies moved by game code, such as with `setPosition`, now collide from their new position in the next step rather than one step later.
- 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<void(Scene*)>` instead of a function pointer. Existing calls still compile.

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Tick an item off in the same branch that implements it.

## Batch 3: physics for gameplay

- [ ] Collision enter and exit events (`collisionEnter` / `collisionExit`), from diffing contact pairs between steps.
- [ ] Collision layers and masks on `Rigidbody`, checked in the broad phase, serialised and shown in the inspector.
- [x] Collision enter and exit events (`collisionEnter` / `collisionExit`), from diffing contact pairs between steps. Exits are also sent when a body is removed.
- [x] Collision layers and masks on `Rigidbody`, checked in the broad phase, serialised and shown in the inspector. Raycasts take a mask.

## Batch 4: camera and input devices

Expand Down
9 changes: 6 additions & 3 deletions examples/demo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,23 @@ class ImpactFlash : public Component, public CollisionListener {
private:

float flash;
float speed; // Last frame's speed. Collision events arrive after the step has resolved the impact.

public:

ImpactFlash() : Component("ImpactFlash") {
this->flash = 0.0f;
this->speed = 0.0f;
this->setImguiable(false);
}

void collision(Entity* with, CollisionManifold manifold) override {
Rigidbody* r = this->getEntity()->getComponent<Rigidbody>();
if (r != nullptr && glm::length(r->getVelocity()) > 6.0f) {this->flash = 0.15f;}
void collisionEnter(Entity* with, CollisionManifold manifold) override {
if (this->speed > 6.0f) {this->flash = 0.15f;}
}

void update(float dt) override {
Rigidbody* r = this->getEntity()->getComponent<Rigidbody>();
if (r != nullptr) {this->speed = glm::length(r->getVelocity());}
SpriteRenderer* sprite = this->getEntity()->getComponent<SpriteRenderer>();
if (sprite == nullptr) {return;}
if (this->flash > 0.0f) {
Expand Down
10 changes: 10 additions & 0 deletions include/pancake/physics/rigidbody.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <string>
#include <vector>
#include <utility>
#include <cstdint>
#include <unordered_set>

#include <glm/glm.hpp>
Expand Down Expand Up @@ -42,6 +43,10 @@ namespace Pancake {
bool massDirty;
bool momentDirty;

// Two bodies collide only if each one's layer bits are in the other's mask.
uint32_t collisionLayer;
uint32_t collisionMask;

bool sensor;
bool infiniteMass;
bool fixedOrientation;
Expand Down Expand Up @@ -72,6 +77,9 @@ namespace Pancake {
float getMomentOfInertia();
float getInverseMomentOfInertia();
bool isSensor();
uint32_t getCollisionLayer();
uint32_t getCollisionMask();
bool canCollideWith(Rigidbody* other);
bool hasForceGenerator(std::string type);
bool hasFixedOrientation();
bool hasInfiniteMass();
Expand Down Expand Up @@ -102,6 +110,8 @@ namespace Pancake {
Rigidbody* setFriction(float cof);
Rigidbody* setGravityScale(float scale);
Rigidbody* setSensor(bool sensor);
Rigidbody* setCollisionLayer(uint32_t layer); // Usually a single bit, e.g. 1 << 2.
Rigidbody* setCollisionMask(uint32_t mask); // The layers this body collides with.
Rigidbody* setFixedOrientation(bool orientation);
Rigidbody* setCentroidDirty();
Rigidbody* setBoundsDirty();
Expand Down
18 changes: 16 additions & 2 deletions include/pancake/physics/world.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <vector>
#include <string>
#include <utility>
#include <cstdint>
#include <unordered_map>
#include <unordered_set>
#include <nlohmann/json.hpp>
Expand All @@ -14,10 +15,17 @@

namespace Pancake {

// Components that implement this are told about their entity's collisions. Calls are made at the end of
// each physics step, so listeners can kill entities or change bodies safely. Sensors get them too.
class CollisionListener {
public: virtual void collision(Entity* with, CollisionManifold manifold) {}
public:
virtual void collision(Entity* with, CollisionManifold manifold) {} // Every contact, every step.
virtual void collisionEnter(Entity* with, CollisionManifold manifold) {} // The first step two bodies touch.
virtual void collisionExit(Entity* with) {} // The first step they don't, or one leaves the world.
};

const uint32_t ALL_LAYERS = 0xFFFFFFFF;

// The impulses applied at a contact point, kept between steps to warm start the solver.
struct CachedContact {
glm::vec2 point;
Expand Down Expand Up @@ -47,6 +55,9 @@ namespace Pancake {
float time;
bool debug;

// Body pairs touching at the end of the last step, keyed by ordered ids, to find enter and exit events.
std::unordered_map<std::pair<int, int>, std::pair<Rigidbody*, Rigidbody*>, IntPairHash, IntPairEqual> touching;

void fixedUpdate();

public:
Expand All @@ -64,6 +75,8 @@ namespace Pancake {
void add(Rigidbody* rigidbody);
bool has(Rigidbody* rigidbody);
void remove(Rigidbody* rigidbody);
bool isTouching(Rigidbody* a, Rigidbody* b);
void clearContacts(); // Forget contacts without exit events, e.g. when the whole scene is going away.

ForceGenerator* getForceGenerator(std::string type);
void addForceGenerator(ForceGenerator* force);
Expand All @@ -74,8 +87,9 @@ namespace Pancake {
void removeForceRegistration(std::string force, Rigidbody* rigidbody);

SpatialHashGrid<Rigidbody*>* getGrid();
// Only bodies whose collision layer is in the mask are hit.
RaycastResult raycast(Ray ray);
RaycastResult raycast(Ray ray, float maxDistance);
RaycastResult raycast(Ray ray, float maxDistance, uint32_t mask = ALL_LAYERS);

};

Expand Down
3 changes: 2 additions & 1 deletion src/core/entity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ namespace Pancake {
}

Entity::~Entity() {
this->dead = true; // Components ending now see the entity is going, as with kill().
Scene* scene = Window::getScene();
for (Component* c: this->components) {c->kill();} // Delete all the components.
for (Component* c: this->components) {
Expand Down Expand Up @@ -206,8 +207,8 @@ namespace Pancake {

void Entity::kill() {
if (this->dead) {return;}
this->dead = true; // Set first, so components ending now see the entity is dying, e.g. collision exits skip it.
for (Component* c: this->components) {c->kill();}
this->dead = true;
}

void Entity::setId(int id) {
Expand Down
2 changes: 2 additions & 0 deletions src/core/scene.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ namespace Pancake {
Scene::~Scene() {

// Kill all entities, so components unregister while the scene systems still exist.
// Forget contacts first, so no collision exits are sent to entities that are about to be deleted.
this->physics->clearContacts();
for (Entity* e : this->entities) {
e->kill();
}
Expand Down
38 changes: 38 additions & 0 deletions src/physics/rigidbody.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ namespace Pancake {
this->momentDirty = true;

this->sensor = false;
this->collisionLayer = 1;
this->collisionMask = 0xFFFFFFFF;
this->fixedOrientation = false;

}
Expand Down Expand Up @@ -78,6 +80,8 @@ namespace Pancake {
j.emplace("friction", this->friction);
j.emplace("gravityScale", this->gravityScale);
j.emplace("sensor", this->sensor);
j.emplace("collisionLayer", this->collisionLayer);
j.emplace("collisionMask", this->collisionMask);
j.emplace("fixedOrientation", this->fixedOrientation);

j.emplace("colliders", json::array());
Expand Down Expand Up @@ -139,6 +143,8 @@ namespace Pancake {

// Optional, as older save files predate world gravity.
if (j.contains("gravityScale") && j["gravityScale"].is_number()) {this->setGravityScale(j["gravityScale"]);}
if (j.contains("collisionLayer") && j["collisionLayer"].is_number_unsigned()) {this->setCollisionLayer(j["collisionLayer"]);}
if (j.contains("collisionMask") && j["collisionMask"].is_number_unsigned()) {this->setCollisionMask(j["collisionMask"]);}

if (j.contains("forceGenerators") && j["forceGenerators"].is_array()) {
for (auto element : j["forceGenerators"]) {
Expand Down Expand Up @@ -188,6 +194,16 @@ namespace Pancake {
bool fixedOrientation = this->fixedOrientation;
if (ImGui::Checkbox("Fixed Orientation", &fixedOrientation)) {this->setFixedOrientation(fixedOrientation);}

uint32_t layer = this->collisionLayer;
ImGui::Text("Layer: ");
ImGui::SameLine();
if (ImGui::InputScalar("##CollisionLayer", ImGuiDataType_U32, &layer, nullptr, nullptr, "%08X", ImGuiInputTextFlags_CharsHexadecimal)) {this->setCollisionLayer(layer);}

uint32_t mask = this->collisionMask;
ImGui::Text("Mask: ");
ImGui::SameLine();
if (ImGui::InputScalar("##CollisionMask", ImGuiDataType_U32, &mask, nullptr, nullptr, "%08X", ImGuiInputTextFlags_CharsHexadecimal)) {this->setCollisionMask(mask);}

}

std::vector<Collider*> Rigidbody::getColliders() {
Expand Down Expand Up @@ -354,6 +370,18 @@ namespace Pancake {
return this->sensor;
}

uint32_t Rigidbody::getCollisionLayer() {
return this->collisionLayer;
}

uint32_t Rigidbody::getCollisionMask() {
return this->collisionMask;
}

bool Rigidbody::canCollideWith(Rigidbody* other) {
return (this->collisionLayer & other->collisionMask) != 0 && (other->collisionLayer & this->collisionMask) != 0;
}

bool Rigidbody::hasForceGenerator(std::string type) {
auto it = this->forceGenerators.find(type);
return it != this->forceGenerators.end();
Expand Down Expand Up @@ -541,6 +569,16 @@ namespace Pancake {
return this;
}

Rigidbody* Rigidbody::setCollisionLayer(uint32_t layer) {
this->collisionLayer = layer;
return this;
}

Rigidbody* Rigidbody::setCollisionMask(uint32_t mask) {
this->collisionMask = mask;
return this;
}

Rigidbody* Rigidbody::setFixedOrientation(bool orientation) {
this->fixedOrientation = orientation;
return this;
Expand Down
Loading
Loading