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