From 78d80a8384162431f10b89f1e798c10345b4ac08 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:54:24 +0000 Subject: [PATCH] Add collision enter and exit events, and collision layers and masks Batch 3 of ROADMAP.md. - CollisionListener gets collisionEnter (the first step two bodies touch) and collisionExit (the first step they don't, or when one is removed from the world), alongside the existing per-contact collision. - All listener calls are now queued during the step and sent at its end, in body-id-pair order. A listener can kill entities safely: before, a body killed in a callback was still narrow-phased and solved for the rest of the step. A pair only counts as touching once its enter is sent, so a body removed mid-dispatch only sends exits for enters that were delivered. - Rigidbody collision layer and mask. Two bodies collide only if each one's layer is in the other's mask. Checked in the broad phase, serialised as optional fields, and editable in the inspector as hex. Raycasts take an optional mask. World::isTouching reports the current contact state. - Bodies moved by game code (setPosition, the tp command) update their grid cells at the start of each step. Before, the broad phase saw them in their old cells for one step, so they missed collisions in their new place. - Entity::kill and ~Entity mark the entity dead before ending components, and Scene::~Scene clears contacts first, so dying entities and scene teardown get no exit events. - The demo's impact flash uses collisionEnter and last frame's speed, since velocities read in callbacks are now post-solve. Tests: new unit test test_collision_events for enter, contact and exit events, exits on removal, killing bodies from listeners, layers and masks, raycast masks, and serialisation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015cNAkNqoDWA5gFReD1AsqV --- CLAUDE.md | 8 +- README.md | 26 +++- ROADMAP.md | 4 +- examples/demo.cpp | 9 +- include/pancake/physics/rigidbody.hpp | 10 ++ include/pancake/physics/world.hpp | 18 ++- src/core/entity.cpp | 3 +- src/core/scene.cpp | 2 + src/physics/rigidbody.cpp | 38 +++++ src/physics/world.cpp | 134 +++++++++++++---- tests/CMakeLists.txt | 1 + tests/test_collision_events.cpp | 206 ++++++++++++++++++++++++++ 12 files changed, 419 insertions(+), 40 deletions(-) create mode 100644 tests/test_collision_events.cpp diff --git a/CLAUDE.md b/CLAUDE.md index d0b38fd..97edf1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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`, 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. diff --git a/README.md b/README.md index 933e8f4..e7369a0 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -208,6 +229,9 @@ 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`. +- 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` instead of a function pointer. Existing calls still compile. diff --git a/ROADMAP.md b/ROADMAP.md index 0427e3b..76448a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/examples/demo.cpp b/examples/demo.cpp index 714b210..d2af9a5 100644 --- a/examples/demo.cpp +++ b/examples/demo.cpp @@ -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(); - 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(); + if (r != nullptr) {this->speed = glm::length(r->getVelocity());} SpriteRenderer* sprite = this->getEntity()->getComponent(); if (sprite == nullptr) {return;} if (this->flash > 0.0f) { diff --git a/include/pancake/physics/rigidbody.hpp b/include/pancake/physics/rigidbody.hpp index 2e5c807..5efc59a 100644 --- a/include/pancake/physics/rigidbody.hpp +++ b/include/pancake/physics/rigidbody.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -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; @@ -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(); @@ -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(); diff --git a/include/pancake/physics/world.hpp b/include/pancake/physics/world.hpp index 03a7148..0201d9d 100644 --- a/include/pancake/physics/world.hpp +++ b/include/pancake/physics/world.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -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; @@ -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, IntPairHash, IntPairEqual> touching; + void fixedUpdate(); public: @@ -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); @@ -74,8 +87,9 @@ namespace Pancake { void removeForceRegistration(std::string force, Rigidbody* rigidbody); SpatialHashGrid* 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); }; diff --git a/src/core/entity.cpp b/src/core/entity.cpp index 4240ab2..7024f1e 100644 --- a/src/core/entity.cpp +++ b/src/core/entity.cpp @@ -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) { @@ -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) { diff --git a/src/core/scene.cpp b/src/core/scene.cpp index 47afbec..cd087f1 100644 --- a/src/core/scene.cpp +++ b/src/core/scene.cpp @@ -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(); } diff --git a/src/physics/rigidbody.cpp b/src/physics/rigidbody.cpp index 6863425..05a9973 100644 --- a/src/physics/rigidbody.cpp +++ b/src/physics/rigidbody.cpp @@ -33,6 +33,8 @@ namespace Pancake { this->momentDirty = true; this->sensor = false; + this->collisionLayer = 1; + this->collisionMask = 0xFFFFFFFF; this->fixedOrientation = false; } @@ -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()); @@ -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"]) { @@ -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 Rigidbody::getColliders() { @@ -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(); @@ -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; diff --git a/src/physics/world.cpp b/src/physics/world.cpp index bd29bff..594b95f 100644 --- a/src/physics/world.cpp +++ b/src/physics/world.cpp @@ -18,6 +18,35 @@ namespace Pancake { const int MAX_STEPS_PER_UPDATE = 5; const float RESTITUTION_THRESHOLD = 1.0f; // Contacts slower than this do not bounce, which stops resting bodies jittering. + enum class EventKind {Enter, Contact, Exit}; + + // A listener call, collected during a step and made at its end. + struct CollisionEvent { + EventKind kind; + Rigidbody* a; + Rigidbody* b; + CollisionManifold manifold; // From a to b. Unused for exits. + }; + + // Calls f on each live listener of the body's entity, unless the entity is dying. + template + void notify(Rigidbody* body, F f) { + Entity* entity = body->getEntity(); + if (entity == nullptr || entity->isDead()) {return;} + for (Component* component : entity->getComponents()) { + if (component->isDead()) {continue;} + CollisionListener* listener = dynamic_cast(component); + if (listener != nullptr) {f(listener);} + } + } + + void notifyExit(Rigidbody* a, Rigidbody* b) { + Entity* ea = a->getEntity(); + Entity* eb = b->getEntity(); + notify(a, [eb](CollisionListener* l) {l->collisionExit(eb);}); + notify(b, [ea](CollisionListener* l) {l->collisionExit(ea);}); + } + inline float cross(glm::vec2 a, glm::vec2 b) { return a.x * b.y - a.y * b.x; } @@ -194,10 +223,16 @@ namespace Pancake { this->registry.updateForces(this->timeStep); for (Rigidbody* rigidbody : this->rigidbodies) {rigidbody->integrateVelocity(this->timeStep);} + // Bodies moved by game code since the last step, e.g. teleported, take their new place in the grid now, + // rather than after this step's broad phase. + for (Rigidbody* rigidbody : this->rigidbodies) {rigidbody->updateGridRegistration(false);} + // Required data structures to store collision data. std::unordered_set, IntPairHash> colliding; std::vector> collisions; std::vector> manifolds; + std::vector events; + std::unordered_map, std::pair, IntPairHash, IntPairEqual> current; for (const std::vector& bodies : *this->grid) { @@ -217,8 +252,9 @@ namespace Pancake { auto it = colliding.find(ids); if (it != colliding.end()) {continue;} - // Check if they have infinite mass. + // Check if they have infinite mass, or don't collide with each other's layers. if (rigidbody1->hasInfiniteMass() && rigidbody2->hasInfiniteMass()) {continue;} + if (!rigidbody1->canCollideWith(rigidbody2)) {continue;} // Get the colliders of each body. std::vector colliders1 = rigidbody1->getColliders(); @@ -247,21 +283,10 @@ namespace Pancake { // Add the ID pair so we dont collide them again. colliding.insert(ids); - // Update the listeners - for (CollisionManifold manifold : results) { - - for (Component* component : rigidbody1->getEntity()->getComponents()) { - CollisionListener* listener = dynamic_cast(component); - if (listener != nullptr) {listener->collision(rigidbody2->getEntity(), manifold);} - } - - CollisionManifold flipped = manifold.flip(); - for (Component* component : rigidbody2->getEntity()->getComponents()) { - CollisionListener* listener = dynamic_cast(component); - if (listener != nullptr) {listener->collision(rigidbody1->getEntity(), flipped);} - } - - } + // Queue the listener calls for the end of the step. + current[ids] = std::make_pair(rigidbody1, rigidbody2); + if (this->touching.find(ids) == this->touching.end()) {events.push_back({EventKind::Enter, rigidbody1, rigidbody2, results[0]});} + for (CollisionManifold manifold : results) {events.push_back({EventKind::Contact, rigidbody1, rigidbody2, manifold});} // Visualise the contacts. if (this->debug) { @@ -326,6 +351,47 @@ namespace Pancake { correctPositions(collisions[i].first, collisions[i].second, manifolds[i]); } + // Pairs that touched last step but not this one have separated. New pairs join the touching set as their + // enter events are sent, so a body removed by a listener only sends exits for enters that were delivered. + for (auto it = this->touching.begin(); it != this->touching.end();) { + if (current.find(it->first) == current.end()) { + events.push_back({EventKind::Exit, it->second.first, it->second.second, CollisionManifold()}); + it = this->touching.erase(it); + } else {it++;} + } + + // Notify listeners now the step is done, in id order so it doesn't depend on the grid's iteration order. + // A listener may remove bodies, which sends their exits right away, so skip events for removed bodies. + std::stable_sort(events.begin(), events.end(), [](const CollisionEvent& x, const CollisionEvent& y) { + return std::make_pair(x.a->getId(), x.b->getId()) < std::make_pair(y.a->getId(), y.b->getId()); + }); + for (CollisionEvent& event : events) { + if (event.kind == EventKind::Exit) {notifyExit(event.a, event.b); continue;} + if (!this->has(event.a) || !this->has(event.b)) {continue;} + Entity* ea = event.a->getEntity(); + Entity* eb = event.b->getEntity(); + CollisionManifold flipped = event.manifold.flip(); + if (event.kind == EventKind::Enter) { + std::pair ids = std::make_pair(event.a->getId(), event.b->getId()); + this->touching[ids] = std::make_pair(event.a, event.b); + notify(event.a, [&](CollisionListener* l) {l->collisionEnter(eb, event.manifold);}); + notify(event.b, [&](CollisionListener* l) {l->collisionEnter(ea, flipped);}); + } else { + notify(event.a, [&](CollisionListener* l) {l->collision(eb, event.manifold);}); + notify(event.b, [&](CollisionListener* l) {l->collision(ea, flipped);}); + } + } + + } + + bool World::isTouching(Rigidbody* a, Rigidbody* b) { + std::pair ids = std::make_pair(std::min(a->getId(), b->getId()), std::max(a->getId(), b->getId())); + return this->touching.find(ids) != this->touching.end(); + } + + void World::clearContacts() { + this->touching.clear(); + this->contactCache.clear(); } void World::render() { @@ -397,6 +463,14 @@ namespace Pancake { // Remove the rigidbody from the index and the spatial hash grid. if (!this->has(rigidbody)) {return;} + + // Bodies it was touching get exit events once it is gone. + std::vector> ended; + for (auto it = this->touching.begin(); it != this->touching.end();) { + if (it->second.first == rigidbody || it->second.second == rigidbody) {ended.push_back(it->second); it = this->touching.erase(it);} + else {it++;} + } + rigidbody->setWorld(nullptr); this->rigidbodiesIndex.erase(rigidbody); this->grid->remove(rigidbody); @@ -413,29 +487,32 @@ namespace Pancake { } this->rigidbodies.erase(this->rigidbodies.begin() + i); - return; + break; } } + for (auto& pair : ended) {notifyExit(pair.first, pair.second);} + } RaycastResult World::raycast(Ray ray) { - - // An unbounded ray could cross any number of cells, so test every body. - RaycastResult best; - for (Rigidbody* rigidbody : this->rigidbodies) { - RaycastResult current = Raycast::raycast(rigidbody, ray); - if (current.hit != nullptr && current.distance < best.distance) {best = current;} - } - - return best; + return this->raycast(ray, INFINITY); } - RaycastResult World::raycast(Ray ray, float maxDistance) { + RaycastResult World::raycast(Ray ray, float maxDistance, uint32_t mask) { - if (!std::isfinite(maxDistance)) {return this->raycast(ray);} + // An unbounded ray could cross any number of cells, so test every body. + if (!std::isfinite(maxDistance)) { + RaycastResult best; + for (Rigidbody* rigidbody : this->rigidbodies) { + if ((rigidbody->getCollisionLayer() & mask) == 0) {continue;} + RaycastResult current = Raycast::raycast(rigidbody, ray); + if (current.hit != nullptr && current.distance < best.distance) {best = current;} + } + return best; + } // Walk the spatial hash grid cells along the ray (Amanatides & Woo), testing the bodies in each cell. float size = (float) this->grid->getGridSize(); @@ -458,6 +535,7 @@ namespace Pancake { for (Rigidbody* rigidbody : this->grid->get(x, y)) { if (!tested.insert(rigidbody).second) {continue;} + if ((rigidbody->getCollisionLayer() & mask) == 0) {continue;} RaycastResult current = Raycast::raycast(rigidbody, ray); if (current.hit != nullptr && current.distance <= maxDistance && current.distance < best.distance) {best = current;} } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a7a6d50..8bcbaab 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,5 +34,6 @@ pancake_test(test_raycast) pancake_test(test_serialise) pancake_test(test_entity) pancake_test(test_time) +pancake_test(test_collision_events) pancake_engine_test(test_engine) pancake_engine_test(test_time_engine) diff --git a/tests/test_collision_events.cpp b/tests/test_collision_events.cpp new file mode 100644 index 0000000..0c3f36d --- /dev/null +++ b/tests/test_collision_events.cpp @@ -0,0 +1,206 @@ +// Collision enter, contact and exit events, collision layers and masks, and raycast masks. + +#include "test.hpp" +#include "helpers.hpp" + +using namespace Pancake; +using PancakeTest::Bodies; + +namespace { + + void step(World& world, int frames) { + for (int i = 0; i < frames; i++) {world.update(1.0f / 60.0f);} + } + + struct Recorder : public Component, public CollisionListener { + + int enters = 0; + int contacts = 0; + int exits = 0; + Entity* lastEnter = nullptr; + Entity* lastExit = nullptr; + glm::vec2 enterNormal = glm::vec2(0.0f); + bool killOnEnter = false; + + Recorder() : Component("Recorder") {} + + void collisionEnter(Entity* with, CollisionManifold manifold) override { + enters++; + lastEnter = with; + enterNormal = manifold.normal; + if (killOnEnter) {this->getEntity()->kill();} + } + + void collision(Entity* with, CollisionManifold manifold) override {contacts++;} + + void collisionExit(Entity* with) override { + exits++; + lastExit = with; + } + + }; + + Recorder* record(Rigidbody* body) { + Recorder* r = new Recorder(); + body->getEntity()->addComponent(r); + return r; + } + +} + +TEST("enter fires once, contacts every step, and exit when bodies separate") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* ground = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(4.0f, 1.0f), 0.0f); + Rigidbody* trigger = bodies.box(glm::vec2(0.0f, 0.9f), glm::vec2(1.0f, 1.0f), 1.0f); + trigger->setSensor(true); + Recorder* g = record(ground); + Recorder* t = record(trigger); + world.add(ground); + world.add(trigger); + + step(world, 3); + CHECK(g->enters == 1); + CHECK(t->enters == 1); + CHECK(g->lastEnter == trigger->getEntity()); + CHECK(t->lastEnter == ground->getEntity()); + CHECK_NEAR(g->enterNormal.y, -t->enterNormal.y, 1e-5); // Each side sees the normal pointing at the other. + CHECK(g->contacts >= 3); + CHECK(g->exits == 0); + CHECK(world.isTouching(ground, trigger)); + CHECK(world.isTouching(trigger, ground)); + + trigger->getEntity()->setPosition(glm::vec2(10.0f, 0.0f)); + step(world, 1); + CHECK(g->exits == 1); + CHECK(t->exits == 1); + CHECK(g->lastExit == trigger->getEntity()); + CHECK(!world.isTouching(ground, trigger)); + int contacts = g->contacts; + step(world, 2); + CHECK(g->exits == 1); + CHECK(g->contacts == contacts); + + trigger->getEntity()->setPosition(glm::vec2(0.0f, 0.9f)); + step(world, 1); + CHECK(g->enters == 2); +} + +TEST("removing a body sends exits to the bodies it touched, but not to itself") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* a = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + Rigidbody* b = bodies.box(glm::vec2(0.0f, 0.9f), glm::vec2(1.0f, 1.0f), 1.0f); + b->setSensor(true); + Recorder* ra = record(a); + Recorder* rb = record(b); + world.add(a); + world.add(b); + step(world, 1); + CHECK(ra->enters == 1); + + b->getEntity()->kill(); + CHECK(!world.has(b)); + CHECK(ra->exits == 1); + CHECK(ra->lastExit == b->getEntity()); + CHECK(rb->exits == 0); // Its entity is dying. + step(world, 1); + CHECK(ra->exits == 1); +} + +TEST("listeners can kill bodies during a step, and exits match delivered enters") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + // A coin overlapping two players. The first enter collects the coin, so the second player never touches it. + Rigidbody* player1 = bodies.box(glm::vec2(-0.6f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* player2 = bodies.box(glm::vec2(0.6f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* coin = bodies.circle(glm::vec2(0.0f, 0.0f), 0.3f, 1.0f); + coin->setSensor(true); + player1->setCollisionMask(ALL_LAYERS & ~1u); // Players don't push each other apart. + player2->setCollisionMask(ALL_LAYERS & ~1u); + player1->setCollisionLayer(1); + player2->setCollisionLayer(1); + coin->setCollisionLayer(2); + Recorder* p1 = record(player1); + Recorder* p2 = record(player2); + Recorder* c = record(coin); + c->killOnEnter = true; + world.add(player1); + world.add(player2); + world.add(coin); + + step(world, 1); + CHECK(c->enters == 1); + CHECK(!world.has(coin)); + CHECK(p1->enters + p2->enters == 1); + CHECK(p1->exits == p1->enters); + CHECK(p2->exits == p2->enters); + step(world, 2); + CHECK(p1->enters + p2->enters == 1); +} + +TEST("collision layers and masks filter pairs in both directions") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* a = bodies.box(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 1.0f), 1.0f); + Rigidbody* b = bodies.box(glm::vec2(0.0f, 0.9f), glm::vec2(1.0f, 1.0f), 1.0f); + CHECK(a->getCollisionLayer() == 1); + CHECK(a->getCollisionMask() == ALL_LAYERS); + CHECK(a->canCollideWith(b)); + + a->setCollisionLayer(1 << 1); + b->setCollisionMask(1 << 2); // b ignores a's layer, so neither collides, whatever a's mask is. + CHECK(!a->canCollideWith(b)); + CHECK(!b->canCollideWith(a)); + + Recorder* ra = record(a); + world.add(a); + world.add(b); + step(world, 2); + CHECK(ra->enters == 0); + CHECK_NEAR(b->getEntity()->getPosition().y, 0.9, 1e-6); // Nothing pushed them apart. + + b->setCollisionMask((1 << 1) | (1 << 2)); + CHECK(a->canCollideWith(b)); + step(world, 1); + CHECK(ra->enters == 1); + CHECK(b->getEntity()->getPosition().y > 0.9f); +} + +TEST("raycasts skip bodies outside the mask") { + World world(1.0f / 60.0f, glm::vec2(0.0f, 0.0f)); + Bodies bodies; + Rigidbody* near = bodies.box(glm::vec2(3.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + Rigidbody* far = bodies.box(glm::vec2(6.0f, 0.0f), glm::vec2(1.0f, 1.0f), 0.0f); + near->setCollisionLayer(1 << 3); + world.add(near); + world.add(far); + Ray ray(glm::vec2(0.0f, 0.0f), glm::vec2(1.0f, 0.0f)); + + CHECK(world.raycast(ray, 20.0f).hit == near->getEntity()); + CHECK(world.raycast(ray).hit == near->getEntity()); + CHECK(world.raycast(ray, 20.0f, ALL_LAYERS & ~(1u << 3)).hit == far->getEntity()); + CHECK(world.raycast(ray, INFINITY, 1).hit == far->getEntity()); + CHECK(world.raycast(ray, 20.0f, 1 << 4).hit == nullptr); +} + +TEST("collision layers and masks round trip, and are optional in older files") { + Entity e; + Rigidbody* r = new Rigidbody(); + r->setCollisionLayer(1 << 5)->setCollisionMask(0xF0F0F0F0); + e.addComponent(r); + nlohmann::json j = r->serialise(); + Rigidbody loaded; + CHECK(loaded.load(j)); + CHECK(loaded.getCollisionLayer() == (1u << 5)); + CHECK(loaded.getCollisionMask() == 0xF0F0F0F0); + + j.erase("collisionLayer"); + j.erase("collisionMask"); + Rigidbody older; + CHECK(older.load(j)); + CHECK(older.getCollisionLayer() == 1); + CHECK(older.getCollisionMask() == ALL_LAYERS); +} + +RUN_TESTS()