diff --git a/assets/scenes/test.json b/assets/scenes/test.json index bb07bfa4..39d70760 100644 --- a/assets/scenes/test.json +++ b/assets/scenes/test.json @@ -34,7 +34,7 @@ "displayState": { "cull": false, "depthTest": true, "blend": false } }, { "type": "PLAYER", "moveSpeed": 4, "jumpImpulse": 6 }, - { "type": "PHYSICS", "bodyType": "DYNAMIC" } + { "type": "PHYSICS", "bodyType": "DYNAMIC", "collideMask": 3 } ] }, { @@ -55,6 +55,20 @@ } ] }, + { + "name": "testArea", + "components": [ + { "type": "POSITION", "x": 2, "y": 1, "z": 0 }, + { + "type": "TRIGGER", + "collideMask": 2, + "shape": { + "type": "CUBE", + "halfExtentX": 1.5, "halfExtentY": 1.5, "halfExtentZ": 1.5 + } + } + ] + }, { "components": [ { diff --git a/src/dusk/entity/component/CMakeLists.txt b/src/dusk/entity/component/CMakeLists.txt index 01fe2dca..b2795eba 100644 --- a/src/dusk/entity/component/CMakeLists.txt +++ b/src/dusk/entity/component/CMakeLists.txt @@ -6,3 +6,4 @@ # Subdirs add_subdirectory(display) add_subdirectory(physics) +add_subdirectory(trigger) diff --git a/src/dusk/entity/component/physics/entityphysics.c b/src/dusk/entity/component/physics/entityphysics.c index 1db2dff4..0f157163 100644 --- a/src/dusk/entity/component/physics/entityphysics.c +++ b/src/dusk/entity/component/physics/entityphysics.c @@ -28,6 +28,7 @@ void entityPhysicsInit( phys->shape.data.cube.halfExtents[2] = 0.5f; phys->gravityScale = 1.0f; phys->onGround = false; + phys->collideMask = 0x1; } entityphysics_t *entityPhysicsGet( @@ -116,6 +117,25 @@ physicsbodytype_t entityPhysicsGetBodyType( return phys->type; } +void entityPhysicsSetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint32_t mask +) { + entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId); + phys->collideMask = mask; +} + +uint32_t entityPhysicsGetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId); + return phys->collideMask; +} + errorret_t entityPhysicsSerialize( entitymanager_t *mgr, const entityid_t entityId, @@ -141,66 +161,14 @@ errorret_t entityPhysicsSerialize( } yyjson_mut_obj_add_str(doc, json, "bodyType", bodyTypeName); - yyjson_mut_val *shape = yyjson_mut_obj(doc); - yyjson_mut_obj_add_val(doc, json, "shape", shape); - - switch(phys->shape.type) { - case PHYSICS_SHAPE_CUBE: - yyjson_mut_obj_add_str(doc, shape, "type", "CUBE"); - yyjson_mut_obj_add_real( - doc, shape, "halfExtentX", phys->shape.data.cube.halfExtents[0] - ); - yyjson_mut_obj_add_real( - doc, shape, "halfExtentY", phys->shape.data.cube.halfExtents[1] - ); - yyjson_mut_obj_add_real( - doc, shape, "halfExtentZ", phys->shape.data.cube.halfExtents[2] - ); - break; - case PHYSICS_SHAPE_SPHERE: - yyjson_mut_obj_add_str(doc, shape, "type", "SPHERE"); - yyjson_mut_obj_add_real( - doc, shape, "radius", phys->shape.data.sphere.radius - ); - break; - case PHYSICS_SHAPE_CAPSULE: - yyjson_mut_obj_add_str(doc, shape, "type", "CAPSULE"); - yyjson_mut_obj_add_real( - doc, shape, "radius", phys->shape.data.capsule.radius - ); - yyjson_mut_obj_add_real( - doc, shape, "halfHeight", phys->shape.data.capsule.halfHeight - ); - break; - case PHYSICS_SHAPE_PLANE: - yyjson_mut_obj_add_str(doc, shape, "type", "PLANE"); - yyjson_mut_obj_add_real( - doc, shape, "normalX", phys->shape.data.plane.normal[0] - ); - yyjson_mut_obj_add_real( - doc, shape, "normalY", phys->shape.data.plane.normal[1] - ); - yyjson_mut_obj_add_real( - doc, shape, "normalZ", phys->shape.data.plane.normal[2] - ); - yyjson_mut_obj_add_real( - doc, shape, "distance", phys->shape.data.plane.distance - ); - break; - case PHYSICS_SHAPE_CUSTOM: - errorThrow( - "Cannot serialize a PHYSICS_SHAPE_CUSTOM shape (callback/userData " - "are not representable in JSON)" - ); - default: - assertUnreachable("Unknown physics shape type"); - } + errorChain(physicsShapeSerialize(doc, json, &phys->shape)); yyjson_mut_obj_add_real(doc, json, "velocityX", phys->velocity[0]); yyjson_mut_obj_add_real(doc, json, "velocityY", phys->velocity[1]); yyjson_mut_obj_add_real(doc, json, "velocityZ", phys->velocity[2]); yyjson_mut_obj_add_real(doc, json, "gravityScale", phys->gravityScale); yyjson_mut_obj_add_bool(doc, json, "onGround", phys->onGround); + yyjson_mut_obj_add_uint(doc, json, "collideMask", phys->collideMask); errorOk(); } @@ -229,56 +197,9 @@ errorret_t entityPhysicsDeserialize( yyjson_val *shape = yyjson_obj_get(json, "shape"); if(shape) { - yyjson_val *shapeTypeVal = yyjson_obj_get(shape, "type"); - assertNotNull(shapeTypeVal, "Physics shape JSON missing 'type' field"); - const char_t *shapeTypeName = yyjson_get_str(shapeTypeVal); - yyjson_val *v; - - if(stringEquals(shapeTypeName, "CUBE")) { - physicsshape_t s = { .type = PHYSICS_SHAPE_CUBE }; - if((v = yyjson_obj_get(shape, "halfExtentX"))) { - s.data.cube.halfExtents[0] = (float_t)yyjson_get_num(v); - } - if((v = yyjson_obj_get(shape, "halfExtentY"))) { - s.data.cube.halfExtents[1] = (float_t)yyjson_get_num(v); - } - if((v = yyjson_obj_get(shape, "halfExtentZ"))) { - s.data.cube.halfExtents[2] = (float_t)yyjson_get_num(v); - } - entityPhysicsSetShape(mgr, entityId, componentId, s); - } else if(stringEquals(shapeTypeName, "SPHERE")) { - physicsshape_t s = { .type = PHYSICS_SHAPE_SPHERE }; - if((v = yyjson_obj_get(shape, "radius"))) { - s.data.sphere.radius = (float_t)yyjson_get_num(v); - } - entityPhysicsSetShape(mgr, entityId, componentId, s); - } else if(stringEquals(shapeTypeName, "CAPSULE")) { - physicsshape_t s = { .type = PHYSICS_SHAPE_CAPSULE }; - if((v = yyjson_obj_get(shape, "radius"))) { - s.data.capsule.radius = (float_t)yyjson_get_num(v); - } - if((v = yyjson_obj_get(shape, "halfHeight"))) { - s.data.capsule.halfHeight = (float_t)yyjson_get_num(v); - } - entityPhysicsSetShape(mgr, entityId, componentId, s); - } else if(stringEquals(shapeTypeName, "PLANE")) { - physicsshape_t s = { .type = PHYSICS_SHAPE_PLANE }; - if((v = yyjson_obj_get(shape, "normalX"))) { - s.data.plane.normal[0] = (float_t)yyjson_get_num(v); - } - if((v = yyjson_obj_get(shape, "normalY"))) { - s.data.plane.normal[1] = (float_t)yyjson_get_num(v); - } - if((v = yyjson_obj_get(shape, "normalZ"))) { - s.data.plane.normal[2] = (float_t)yyjson_get_num(v); - } - if((v = yyjson_obj_get(shape, "distance"))) { - s.data.plane.distance = (float_t)yyjson_get_num(v); - } - entityPhysicsSetShape(mgr, entityId, componentId, s); - } else { - errorThrow("Unknown physics shape type '%s'", shapeTypeName); - } + physicsshape_t s; + errorChain(physicsShapeDeserialize(shape, &s)); + entityPhysicsSetShape(mgr, entityId, componentId, s); } yyjson_val *v; @@ -307,6 +228,9 @@ errorret_t entityPhysicsDeserialize( if((v = yyjson_obj_get(json, "onGround"))) { phys->onGround = yyjson_get_bool(v); } + if((v = yyjson_obj_get(json, "collideMask"))) { + phys->collideMask = (uint32_t)yyjson_get_uint(v); + } errorOk(); } diff --git a/src/dusk/entity/component/physics/entityphysics.h b/src/dusk/entity/component/physics/entityphysics.h index abf8b059..2046996c 100644 --- a/src/dusk/entity/component/physics/entityphysics.h +++ b/src/dusk/entity/component/physics/entityphysics.h @@ -18,6 +18,14 @@ typedef struct { vec3 velocity; float_t gravityScale; bool_t onGround; + + /** + * Bitmask of collision layers/categories this body belongs to. Two + * bodies only collide with each other if (a.collideMask & b.collideMask) + * is non-zero -- i.e. they share at least one set bit. Defaults to 0x1 + * (only bit 0 set). + */ + uint32_t collideMask; } entityphysics_t; /** @@ -173,14 +181,44 @@ physicsbodytype_t entityPhysicsGetBodyType( const componentid_t componentId ); +/** + * Sets the collision mask of the entity's physics body. Two bodies only + * collide if their masks share at least one set bit. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param mask The new collision mask to set on the physics body. + */ +void entityPhysicsSetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint32_t mask +); + +/** + * Gets the collision mask of the entity's physics body. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The collision mask of the physics body. + */ +uint32_t entityPhysicsGetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + /** * Serializes the physics body into the given JSON object: "bodyType" * ("STATIC"/"DYNAMIC"/"KINEMATIC"), "shape" (an object with its own * "type" of "CUBE"/"SPHERE"/"CAPSULE"/"PLANE" plus that shape's fields), - * "velocity" ({"x","y","z"}), "gravityScale", and "onGround". Fails if - * the body's shape is PHYSICS_SHAPE_CUSTOM -- custom shapes carry a - * callback and opaque userData pointer that cannot be represented in - * JSON. + * "velocity" ({"x","y","z"}), "gravityScale", "onGround", and + * "collideMask". Fails if the body's shape is PHYSICS_SHAPE_CUSTOM -- + * custom shapes carry a callback and opaque userData pointer that cannot + * be represented in JSON. * * @param mgr The entity manager that owns the entity. * @param entityId The entity ID. diff --git a/src/dusk/entity/component/trigger/CMakeLists.txt b/src/dusk/entity/component/trigger/CMakeLists.txt new file mode 100644 index 00000000..83974422 --- /dev/null +++ b/src/dusk/entity/component/trigger/CMakeLists.txt @@ -0,0 +1,11 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# Sources +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + entitytrigger.c + entitytriggerevents.c +) diff --git a/src/dusk/entity/component/trigger/entitytrigger.c b/src/dusk/entity/component/trigger/entitytrigger.c new file mode 100644 index 00000000..0b722477 --- /dev/null +++ b/src/dusk/entity/component/trigger/entitytrigger.c @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entitytrigger.h" +#include "entity/entitymanager.h" +#include "util/memory.h" +#include "assert/assert.h" + +void entityTriggerInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + + memoryZero(trig, sizeof(entitytrigger_t)); + + trig->shape.type = PHYSICS_SHAPE_CUBE; + trig->shape.data.cube.halfExtents[0] = 0.5f; + trig->shape.data.cube.halfExtents[1] = 0.5f; + trig->shape.data.cube.halfExtents[2] = 0.5f; + trig->collideMask = 0x1; +} + +entitytrigger_t *entityTriggerGet( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + return componentGetData(mgr, entityId, componentId, COMPONENT_TYPE_TRIGGER); +} + +void entityTriggerSetShape( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const physicsshape_t shape +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + trig->shape = shape; +} + +physicsshape_t entityTriggerGetShape( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + return trig->shape; +} + +void entityTriggerSetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint32_t mask +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + trig->collideMask = mask; +} + +uint32_t entityTriggerGetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + return trig->collideMask; +} + +uint8_t entityTriggerGetOccupantCount( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + return trig->occupantCount; +} + +entityid_t entityTriggerGetOccupantEntityId( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint8_t index +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + if(index >= trig->occupantCount) return ENTITY_ID_INVALID; + return trig->occupants[index].entityId; +} + +bool_t entityTriggerIsOccupyingEntity( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityid_t otherEntityId +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + for(uint8_t i = 0; i < trig->occupantCount; i++) { + if(trig->occupants[i].entityId == otherEntityId) return true; + } + return false; +} + +errorret_t entityTriggerSerialize( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + yyjson_mut_doc *doc, + yyjson_mut_val *json +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + errorChain(physicsShapeSerialize(doc, json, &trig->shape)); + yyjson_mut_obj_add_uint(doc, json, "collideMask", trig->collideMask); + errorOk(); +} + +errorret_t entityTriggerDeserialize( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + yyjson_val *json +) { + yyjson_val *shape = yyjson_obj_get(json, "shape"); + if(shape) { + physicsshape_t s; + errorChain(physicsShapeDeserialize(shape, &s)); + entityTriggerSetShape(mgr, entityId, componentId, s); + } + + yyjson_val *v = yyjson_obj_get(json, "collideMask"); + if(v) { + entityTriggerSetCollideMask( + mgr, entityId, componentId, (uint32_t)yyjson_get_uint(v) + ); + } + + errorOk(); +} diff --git a/src/dusk/entity/component/trigger/entitytrigger.h b/src/dusk/entity/component/trigger/entitytrigger.h new file mode 100644 index 00000000..a0963b8b --- /dev/null +++ b/src/dusk/entity/component/trigger/entitytrigger.h @@ -0,0 +1,265 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entity/entitybase.h" +#include "physics/physicsshape.h" +#include "error/error.h" +#include "yyjson.h" + +/** Maximum number of entities a single trigger can track at once. */ +#define ENTITY_TRIGGER_OCCUPANTS_MAX 8 + +/** Maximum number of subscribers per trigger event (onEnter/onActive/...). */ +#define ENTITY_TRIGGER_CALLBACK_COUNT_MAX 4 + +/** + * Callback invoked for a trigger's onEnter/onActive/onMove/onLeave events. + * See entityTriggerOnEnterAdd() and its onActive/onMove/onLeave siblings. + * + * @param mgr The entity manager that owns both entities. + * @param triggerEntityId The entity ID of the trigger volume. + * @param triggerComponentId The TRIGGER component ID. + * @param otherEntityId The entity ID that entered/is inside/left the + * trigger volume. + * @param otherComponentId The other entity's POSITION component ID. + * @param user The user pointer passed to the matching *Add() call. + */ +typedef void (*triggercallback_t)( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +); + +/** Tracks one entity currently overlapping a trigger volume. */ +typedef struct { + /** The occupying entity's ID. */ + entityid_t entityId; + /** The occupying entity's POSITION component ID. */ + componentid_t componentId; + /** The occupying entity's local position as of the last step, used to + * detect movement (see entityTriggerOnMoveAdd()). */ + vec3 lastPosition; +} entitytriggeroccupant_t; + +typedef struct { + /** The trigger volume's shape, in the owning entity's local space. */ + physicsshape_t shape; + + /** + * Bitmask of collision layers/categories this trigger detects. An + * entity's PHYSICS body is only tested against this trigger if + * (trigger.collideMask & body.collideMask) is non-zero -- i.e. they + * share at least one set bit. Defaults to 0x1 (only bit 0 set). See + * entityPhysicsSetCollideMask(). + */ + uint32_t collideMask; + + /** Number of entities currently tracked in occupants. */ + uint8_t occupantCount; + /** Entities currently overlapping this trigger. */ + entitytriggeroccupant_t occupants[ENTITY_TRIGGER_OCCUPANTS_MAX]; + + /** Fired the step an entity begins overlapping this trigger. */ + uint8_t onEnterCount; + triggercallback_t onEnter[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + void *onEnterUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + + /** Fired every step an entity continues overlapping this trigger, + * starting the step after onEnter fired. */ + uint8_t onActiveCount; + triggercallback_t onActive[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + void *onActiveUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + + /** Fired alongside onActive on any step where the entity's position + * changed since the previous step. */ + uint8_t onMoveCount; + triggercallback_t onMove[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + void *onMoveUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + + /** Fired the step an entity stops overlapping this trigger. */ + uint8_t onLeaveCount; + triggercallback_t onLeave[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; + void *onLeaveUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX]; +} entitytrigger_t; + +/** + * Initializes the trigger component: defaults to a 1x1x1 cube volume, no + * tracked occupants, and no event subscribers. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void entityTriggerInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Gets the underlying trigger structure (temporarily) for the given + * entity. Prefer the dedicated getters/setters where possible. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The trigger component data for the given entity and component ID. + */ +entitytrigger_t *entityTriggerGet( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Sets the shape of the trigger volume. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param shape The new shape to set on the trigger volume. + */ +void entityTriggerSetShape( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const physicsshape_t shape +); + +/** + * Gets the shape of the trigger volume. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The shape of the trigger volume. + */ +physicsshape_t entityTriggerGetShape( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Sets the collision mask of the trigger volume. An entity's PHYSICS body + * is only detected if their masks share at least one set bit. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param mask The new collision mask to set on the trigger volume. + */ +void entityTriggerSetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint32_t mask +); + +/** + * Gets the collision mask of the trigger volume. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The collision mask of the trigger volume. + */ +uint32_t entityTriggerGetCollideMask( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Gets the number of entities currently overlapping this trigger. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The number of currently tracked occupants. + */ +uint8_t entityTriggerGetOccupantCount( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Gets the entity ID of the occupant at the given index, in [0, + * entityTriggerGetOccupantCount()). Order is not stable across steps. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param index The occupant index. + * @return The occupying entity's ID, or ENTITY_ID_INVALID if index is out + * of range. + */ +entityid_t entityTriggerGetOccupantEntityId( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const uint8_t index +); + +/** + * Checks whether a specific entity is currently overlapping this trigger. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param otherEntityId The entity ID to check for. + * @return True if otherEntityId is a currently tracked occupant. + */ +bool_t entityTriggerIsOccupyingEntity( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityid_t otherEntityId +); + +/** + * Serializes the trigger's shape (see physicsShapeSerialize()) and + * "collideMask" into the given JSON object. Occupants and event + * subscribers are runtime state and are not serialized. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param doc The mutable JSON document to allocate values from. + * @param json The JSON object to write the component's fields into. + * @return Error state. + */ +errorret_t entityTriggerSerialize( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + yyjson_mut_doc *doc, + yyjson_mut_val *json +); + +/** + * Reads "shape" and "collideMask" (see entityTriggerSerialize()) from the + * given JSON object and applies whichever are present, leaving the rest + * at their current values. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param json The JSON object to read the component's fields from. + * @return Error state. + */ +errorret_t entityTriggerDeserialize( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + yyjson_val *json +); diff --git a/src/dusk/entity/component/trigger/entitytriggerevents.c b/src/dusk/entity/component/trigger/entitytriggerevents.c new file mode 100644 index 00000000..753f6e25 --- /dev/null +++ b/src/dusk/entity/component/trigger/entitytriggerevents.c @@ -0,0 +1,221 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entitytriggerevents.h" +#include "assert/assert.h" + +void entityTriggerOnEnterAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + assertTrue( + trig->onEnterCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX, + "Trigger onEnter callback slots full" + ); + trig->onEnter[trig->onEnterCount] = callback; + trig->onEnterUser[trig->onEnterCount] = user; + trig->onEnterCount++; +} + +void entityTriggerOnEnterRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + for(uint8_t i = 0; i < trig->onEnterCount; i++) { + if(trig->onEnter[i] != callback) continue; + trig->onEnterCount--; + for(uint8_t j = i; j < trig->onEnterCount; j++) { + trig->onEnter[j] = trig->onEnter[j + 1]; + trig->onEnterUser[j] = trig->onEnterUser[j + 1]; + } + return; + } +} + +void entityTriggerOnActiveAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + assertTrue( + trig->onActiveCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX, + "Trigger onActive callback slots full" + ); + trig->onActive[trig->onActiveCount] = callback; + trig->onActiveUser[trig->onActiveCount] = user; + trig->onActiveCount++; +} + +void entityTriggerOnActiveRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + for(uint8_t i = 0; i < trig->onActiveCount; i++) { + if(trig->onActive[i] != callback) continue; + trig->onActiveCount--; + for(uint8_t j = i; j < trig->onActiveCount; j++) { + trig->onActive[j] = trig->onActive[j + 1]; + trig->onActiveUser[j] = trig->onActiveUser[j + 1]; + } + return; + } +} + +void entityTriggerOnMoveAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + assertTrue( + trig->onMoveCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX, + "Trigger onMove callback slots full" + ); + trig->onMove[trig->onMoveCount] = callback; + trig->onMoveUser[trig->onMoveCount] = user; + trig->onMoveCount++; +} + +void entityTriggerOnMoveRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + for(uint8_t i = 0; i < trig->onMoveCount; i++) { + if(trig->onMove[i] != callback) continue; + trig->onMoveCount--; + for(uint8_t j = i; j < trig->onMoveCount; j++) { + trig->onMove[j] = trig->onMove[j + 1]; + trig->onMoveUser[j] = trig->onMoveUser[j + 1]; + } + return; + } +} + +void entityTriggerOnLeaveAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + assertTrue( + trig->onLeaveCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX, + "Trigger onLeave callback slots full" + ); + trig->onLeave[trig->onLeaveCount] = callback; + trig->onLeaveUser[trig->onLeaveCount] = user; + trig->onLeaveCount++; +} + +void entityTriggerOnLeaveRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +) { + entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId); + for(uint8_t i = 0; i < trig->onLeaveCount; i++) { + if(trig->onLeave[i] != callback) continue; + trig->onLeaveCount--; + for(uint8_t j = i; j < trig->onLeaveCount; j++) { + trig->onLeave[j] = trig->onLeave[j + 1]; + trig->onLeaveUser[j] = trig->onLeaveUser[j + 1]; + } + return; + } +} + +void entityTriggerFireEnter( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +) { + entitytrigger_t *trig = entityTriggerGet( + mgr, triggerEntityId, triggerComponentId + ); + for(uint8_t i = 0; i < trig->onEnterCount; i++) { + trig->onEnter[i]( + mgr, triggerEntityId, triggerComponentId, + otherEntityId, otherComponentId, trig->onEnterUser[i] + ); + } +} + +void entityTriggerFireActive( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +) { + entitytrigger_t *trig = entityTriggerGet( + mgr, triggerEntityId, triggerComponentId + ); + for(uint8_t i = 0; i < trig->onActiveCount; i++) { + trig->onActive[i]( + mgr, triggerEntityId, triggerComponentId, + otherEntityId, otherComponentId, trig->onActiveUser[i] + ); + } +} + +void entityTriggerFireMove( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +) { + entitytrigger_t *trig = entityTriggerGet( + mgr, triggerEntityId, triggerComponentId + ); + for(uint8_t i = 0; i < trig->onMoveCount; i++) { + trig->onMove[i]( + mgr, triggerEntityId, triggerComponentId, + otherEntityId, otherComponentId, trig->onMoveUser[i] + ); + } +} + +void entityTriggerFireLeave( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +) { + entitytrigger_t *trig = entityTriggerGet( + mgr, triggerEntityId, triggerComponentId + ); + for(uint8_t i = 0; i < trig->onLeaveCount; i++) { + trig->onLeave[i]( + mgr, triggerEntityId, triggerComponentId, + otherEntityId, otherComponentId, trig->onLeaveUser[i] + ); + } +} diff --git a/src/dusk/entity/component/trigger/entitytriggerevents.h b/src/dusk/entity/component/trigger/entitytriggerevents.h new file mode 100644 index 00000000..d71ba5e4 --- /dev/null +++ b/src/dusk/entity/component/trigger/entitytriggerevents.h @@ -0,0 +1,220 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entitytrigger.h" + +/** + * Subscribes a callback to a trigger's onEnter event, fired the step an + * entity begins overlapping the trigger volume. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback to invoke. + * @param user Arbitrary pointer forwarded to the callback unchanged. + */ +void entityTriggerOnEnterAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +); + +/** + * Unsubscribes a previously added onEnter callback. No-op if not + * currently subscribed. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback that was passed to entityTriggerOnEnterAdd(). + */ +void entityTriggerOnEnterRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +); + +/** + * Subscribes a callback to a trigger's onActive event, fired every step an + * entity continues overlapping the trigger volume (starting the step + * after onEnter fired). + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback to invoke. + * @param user Arbitrary pointer forwarded to the callback unchanged. + */ +void entityTriggerOnActiveAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +); + +/** + * Unsubscribes a previously added onActive callback. No-op if not + * currently subscribed. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback that was passed to entityTriggerOnActiveAdd(). + */ +void entityTriggerOnActiveRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +); + +/** + * Subscribes a callback to a trigger's onMove event, fired alongside + * onActive on any step where the overlapping entity's position changed + * since the previous step. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback to invoke. + * @param user Arbitrary pointer forwarded to the callback unchanged. + */ +void entityTriggerOnMoveAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +); + +/** + * Unsubscribes a previously added onMove callback. No-op if not currently + * subscribed. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback that was passed to entityTriggerOnMoveAdd(). + */ +void entityTriggerOnMoveRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +); + +/** + * Subscribes a callback to a trigger's onLeave event, fired the step an + * entity stops overlapping the trigger volume. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback to invoke. + * @param user Arbitrary pointer forwarded to the callback unchanged. + */ +void entityTriggerOnLeaveAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback, + void *user +); + +/** + * Unsubscribes a previously added onLeave callback. No-op if not + * currently subscribed. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param callback The callback that was passed to entityTriggerOnLeaveAdd(). + */ +void entityTriggerOnLeaveRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const triggercallback_t callback +); + +/** + * Internal. Invokes every subscribed onEnter callback for a trigger. + * Called by triggerSystemStep() when an entity begins overlapping. + * + * @param mgr The entity manager that owns both entities. + * @param triggerEntityId The entity ID of the trigger volume. + * @param triggerComponentId The TRIGGER component ID. + * @param otherEntityId The entity ID that entered the trigger volume. + * @param otherComponentId The other entity's POSITION component ID. + */ +void entityTriggerFireEnter( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +); + +/** + * Internal. Invokes every subscribed onActive callback for a trigger. + * Called by triggerSystemStep() for every entity still overlapping. + * + * @param mgr The entity manager that owns both entities. + * @param triggerEntityId The entity ID of the trigger volume. + * @param triggerComponentId The TRIGGER component ID. + * @param otherEntityId The entity ID still inside the trigger volume. + * @param otherComponentId The other entity's POSITION component ID. + */ +void entityTriggerFireActive( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +); + +/** + * Internal. Invokes every subscribed onMove callback for a trigger. + * Called by triggerSystemStep() when an overlapping entity's position + * changed since the previous step. + * + * @param mgr The entity manager that owns both entities. + * @param triggerEntityId The entity ID of the trigger volume. + * @param triggerComponentId The TRIGGER component ID. + * @param otherEntityId The entity ID that moved within the trigger volume. + * @param otherComponentId The other entity's POSITION component ID. + */ +void entityTriggerFireMove( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +); + +/** + * Internal. Invokes every subscribed onLeave callback for a trigger. + * Called by triggerSystemStep() when an entity stops overlapping. + * + * @param mgr The entity manager that owns both entities. + * @param triggerEntityId The entity ID of the trigger volume. + * @param triggerComponentId The TRIGGER component ID. + * @param otherEntityId The entity ID that left the trigger volume. + * @param otherComponentId The other entity's POSITION component ID. + */ +void entityTriggerFireLeave( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId +); diff --git a/src/dusk/entity/componentlist.h b/src/dusk/entity/componentlist.h index 5e74e8cf..4f7d8bcd 100644 --- a/src/dusk/entity/componentlist.h +++ b/src/dusk/entity/componentlist.h @@ -9,6 +9,7 @@ #include "entity/component/display/entitycamera.h" #include "entity/component/display/entityrenderable.h" #include "entity/component/physics/entityphysics.h" +#include "entity/component/trigger/entitytrigger.h" // Name (Uppercase) // Structure @@ -28,6 +29,8 @@ X(RENDERABLE, entityrenderable_t, renderable, entityRenderableSerialize, entityRenderableDeserialize) X(PHYSICS, entityphysics_t, physics, entityPhysicsInit, NULL, NULL, entityPhysicsSerialize, entityPhysicsDeserialize) +X(TRIGGER, entitytrigger_t, trigger, entityTriggerInit, NULL, NULL, + entityTriggerSerialize, entityTriggerDeserialize) // Game-specific components #include "entity/gamecomponentlist.h" diff --git a/src/dusk/game/game.h b/src/dusk/game/game.h index 1da43eae..2b7041ff 100644 --- a/src/dusk/game/game.h +++ b/src/dusk/game/game.h @@ -7,6 +7,7 @@ #pragma once #include "error/error.h" +#include "entity/entitybase.h" /** * Initializes the game. Called once, after the engine and all of its @@ -30,3 +31,24 @@ errorret_t gameUpdate(void); * @return An error code indicating success or failure. */ errorret_t gameDispose(void); + +/** + * Test-only TRIGGER onEnter callback, subscribed by gameInit() to the + * "testArea" entity in the test scene (see assets/scenes/test.json). + * Prints a debug message identifying whichever entity just entered. + * + * @param mgr The entity manager that owns both entities. + * @param triggerEntityId The entity ID of the "testArea" trigger volume. + * @param triggerComponentId The TRIGGER component ID. + * @param otherEntityId The entity ID that entered the trigger volume. + * @param otherComponentId The other entity's POSITION component ID. + * @param user Unused. + */ +void gameTestAreaOnEnter( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +); diff --git a/src/dusk/physics/CMakeLists.txt b/src/dusk/physics/CMakeLists.txt index 1de6a434..cf11a8fa 100644 --- a/src/dusk/physics/CMakeLists.txt +++ b/src/dusk/physics/CMakeLists.txt @@ -8,5 +8,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC physicsworld.c physicstest.c + physicsshape.c physicsshapemesh.c + triggersystem.c ) diff --git a/src/dusk/physics/physicsshape.c b/src/dusk/physics/physicsshape.c new file mode 100644 index 00000000..eabfe94a --- /dev/null +++ b/src/dusk/physics/physicsshape.c @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "physicsshape.h" +#include "assert/assert.h" +#include "util/string.h" + +errorret_t physicsShapeSerialize( + yyjson_mut_doc *doc, + yyjson_mut_val *parentJson, + const physicsshape_t *shape +) { + yyjson_mut_val *shapeJson = yyjson_mut_obj(doc); + yyjson_mut_obj_add_val(doc, parentJson, "shape", shapeJson); + + switch(shape->type) { + case PHYSICS_SHAPE_CUBE: + yyjson_mut_obj_add_str(doc, shapeJson, "type", "CUBE"); + yyjson_mut_obj_add_real( + doc, shapeJson, "halfExtentX", shape->data.cube.halfExtents[0] + ); + yyjson_mut_obj_add_real( + doc, shapeJson, "halfExtentY", shape->data.cube.halfExtents[1] + ); + yyjson_mut_obj_add_real( + doc, shapeJson, "halfExtentZ", shape->data.cube.halfExtents[2] + ); + break; + case PHYSICS_SHAPE_SPHERE: + yyjson_mut_obj_add_str(doc, shapeJson, "type", "SPHERE"); + yyjson_mut_obj_add_real( + doc, shapeJson, "radius", shape->data.sphere.radius + ); + break; + case PHYSICS_SHAPE_CAPSULE: + yyjson_mut_obj_add_str(doc, shapeJson, "type", "CAPSULE"); + yyjson_mut_obj_add_real( + doc, shapeJson, "radius", shape->data.capsule.radius + ); + yyjson_mut_obj_add_real( + doc, shapeJson, "halfHeight", shape->data.capsule.halfHeight + ); + break; + case PHYSICS_SHAPE_PLANE: + yyjson_mut_obj_add_str(doc, shapeJson, "type", "PLANE"); + yyjson_mut_obj_add_real( + doc, shapeJson, "normalX", shape->data.plane.normal[0] + ); + yyjson_mut_obj_add_real( + doc, shapeJson, "normalY", shape->data.plane.normal[1] + ); + yyjson_mut_obj_add_real( + doc, shapeJson, "normalZ", shape->data.plane.normal[2] + ); + yyjson_mut_obj_add_real( + doc, shapeJson, "distance", shape->data.plane.distance + ); + break; + case PHYSICS_SHAPE_CUSTOM: + errorThrow( + "Cannot serialize a PHYSICS_SHAPE_CUSTOM shape (callback/userData " + "are not representable in JSON)" + ); + default: + assertUnreachable("Unknown physics shape type"); + } + + errorOk(); +} + +errorret_t physicsShapeDeserialize( + yyjson_val *shapeJson, + physicsshape_t *outShape +) { + yyjson_val *typeVal = yyjson_obj_get(shapeJson, "type"); + assertNotNull(typeVal, "Physics shape JSON missing 'type' field"); + const char_t *typeName = yyjson_get_str(typeVal); + yyjson_val *v; + + if(stringEquals(typeName, "CUBE")) { + physicsshape_t s = { .type = PHYSICS_SHAPE_CUBE }; + if((v = yyjson_obj_get(shapeJson, "halfExtentX"))) { + s.data.cube.halfExtents[0] = (float_t)yyjson_get_num(v); + } + if((v = yyjson_obj_get(shapeJson, "halfExtentY"))) { + s.data.cube.halfExtents[1] = (float_t)yyjson_get_num(v); + } + if((v = yyjson_obj_get(shapeJson, "halfExtentZ"))) { + s.data.cube.halfExtents[2] = (float_t)yyjson_get_num(v); + } + *outShape = s; + } else if(stringEquals(typeName, "SPHERE")) { + physicsshape_t s = { .type = PHYSICS_SHAPE_SPHERE }; + if((v = yyjson_obj_get(shapeJson, "radius"))) { + s.data.sphere.radius = (float_t)yyjson_get_num(v); + } + *outShape = s; + } else if(stringEquals(typeName, "CAPSULE")) { + physicsshape_t s = { .type = PHYSICS_SHAPE_CAPSULE }; + if((v = yyjson_obj_get(shapeJson, "radius"))) { + s.data.capsule.radius = (float_t)yyjson_get_num(v); + } + if((v = yyjson_obj_get(shapeJson, "halfHeight"))) { + s.data.capsule.halfHeight = (float_t)yyjson_get_num(v); + } + *outShape = s; + } else if(stringEquals(typeName, "PLANE")) { + physicsshape_t s = { .type = PHYSICS_SHAPE_PLANE }; + if((v = yyjson_obj_get(shapeJson, "normalX"))) { + s.data.plane.normal[0] = (float_t)yyjson_get_num(v); + } + if((v = yyjson_obj_get(shapeJson, "normalY"))) { + s.data.plane.normal[1] = (float_t)yyjson_get_num(v); + } + if((v = yyjson_obj_get(shapeJson, "normalZ"))) { + s.data.plane.normal[2] = (float_t)yyjson_get_num(v); + } + if((v = yyjson_obj_get(shapeJson, "distance"))) { + s.data.plane.distance = (float_t)yyjson_get_num(v); + } + *outShape = s; + } else { + errorThrow("Unknown physics shape type '%s'", typeName); + } + + errorOk(); +} diff --git a/src/dusk/physics/physicsshape.h b/src/dusk/physics/physicsshape.h index 712639e2..21c4ca74 100644 --- a/src/dusk/physics/physicsshape.h +++ b/src/dusk/physics/physicsshape.h @@ -7,6 +7,8 @@ #pragma once #include "dusk.h" +#include "error/error.h" +#include "yyjson.h" typedef enum { PHYSICS_SHAPE_CUBE, @@ -107,3 +109,35 @@ typedef struct physicsshape_t { physicshapetype_t type; physicsshapedata_t data; } physicsshape_t; + +/** + * Serializes a shape descriptor into a nested "shape" object on the given + * parent JSON object: {"type": "CUBE"/"SPHERE"/"CAPSULE"/"PLANE", ...that + * shape's own fields}. Fails if shape->type is PHYSICS_SHAPE_CUSTOM -- + * custom shapes carry a callback and opaque userData pointer that cannot + * be represented in JSON. + * + * @param doc The mutable JSON document to allocate values from. + * @param parentJson The JSON object to write the "shape" key into. + * @param shape The shape descriptor to serialize. + * @return Error state. + */ +errorret_t physicsShapeSerialize( + yyjson_mut_doc *doc, + yyjson_mut_val *parentJson, + const physicsshape_t *shape +); + +/** + * Deserializes a shape descriptor from a "shape" JSON object (see + * physicsShapeSerialize()). Requires a "type" field; any other field left + * absent defaults to zero on the returned shape. + * + * @param shapeJson The "shape" JSON object to read from. + * @param outShape Destination shape descriptor, fully overwritten. + * @return Error state. + */ +errorret_t physicsShapeDeserialize( + yyjson_val *shapeJson, + physicsshape_t *outShape +); diff --git a/src/dusk/physics/physicsworld.c b/src/dusk/physics/physicsworld.c index 9f181451..817267fb 100644 --- a/src/dusk/physics/physicsworld.c +++ b/src/dusk/physics/physicsworld.c @@ -101,6 +101,7 @@ void physicsWorldStep( entityid_t j = otherIndices[oj]; if(!positions[j]) continue; entityphysics_t *otherPhys = physBodies[j]; + if(!(phys->collideMask & otherPhys->collideMask)) continue; vec3 normal; float_t depth; if(!physicsTestShapeVsShape( @@ -136,6 +137,7 @@ void physicsWorldStep( if(!positions[j]) continue; entityphysics_t *physB = physBodies[j]; float_t *posB = positions[j]->position; + if(!(physA->collideMask & physB->collideMask)) continue; vec3 normal; float_t depth; if(!physicsTestShapeVsShape( diff --git a/src/dusk/physics/physicsworld.h b/src/dusk/physics/physicsworld.h index adb40136..e91941a6 100644 --- a/src/dusk/physics/physicsworld.h +++ b/src/dusk/physics/physicsworld.h @@ -34,7 +34,10 @@ void physicsWorldInit(physicsworld_t *world); * Steps every PHYSICS-component entity in mgr forward by dt: integrates * gravity into dynamic bodies, resolves dynamic-vs-static/kinematic and * dynamic-vs-dynamic overlap, then rebuilds each moved entity's transform. - * A no-op if mgr has no entities with a PHYSICS component. + * A pair is skipped entirely (no overlap test, no resolution) unless + * their collideMask bitmasks share at least one set bit (see + * entityPhysicsSetCollideMask()). A no-op if mgr has no entities with a + * PHYSICS component. * * @param world The physics world configuration (gravity) to step with. * @param mgr The entity manager whose PHYSICS-component entities to step. diff --git a/src/dusk/physics/triggersystem.c b/src/dusk/physics/triggersystem.c new file mode 100644 index 00000000..471e4440 --- /dev/null +++ b/src/dusk/physics/triggersystem.c @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "triggersystem.h" +#include "assert/assert.h" +#include "entity/entitymanager.h" +#include "entity/component/display/entityposition.h" +#include "entity/component/physics/entityphysics.h" +#include "entity/component/trigger/entitytrigger.h" +#include "entity/component/trigger/entitytriggerevents.h" +#include "physicstest.h" + +/** + * Position deltas smaller than this (squared distance) are not considered + * movement, avoiding onMove firing from floating point noise. + */ +#define TRIGGER_SYSTEM_MOVE_EPSILON_SQ 0.000001f + +void triggerSystemStep(entitymanager_t *mgr) { + assertNotNull(mgr, "Entity manager cannot be null"); + + entityid_t trigEnts[ENTITY_COUNT_MAX]; + componentid_t trigComps[ENTITY_COUNT_MAX]; + entityid_t trigCount = componentGetEntitiesWithComponent( + mgr, COMPONENT_TYPE_TRIGGER, trigEnts, trigComps + ); + if(trigCount == 0) return; + + entityid_t physEnts[ENTITY_COUNT_MAX]; + componentid_t physComps[ENTITY_COUNT_MAX]; + entityid_t physCount = componentGetEntitiesWithComponent( + mgr, COMPONENT_TYPE_PHYSICS, physEnts, physComps + ); + + for(entityid_t ti = 0; ti < trigCount; ti++) { + entityid_t trigEntityId = trigEnts[ti]; + componentid_t trigComponentId = trigComps[ti]; + + componentid_t trigPosComp = entityGetComponent( + mgr, trigEntityId, COMPONENT_TYPE_POSITION + ); + if(trigPosComp == COMPONENT_ID_INVALID) continue; + + entitytrigger_t *trig = entityTriggerGet( + mgr, trigEntityId, trigComponentId + ); + entityposition_t *trigPos = entityPositionGet( + mgr, trigEntityId, trigPosComp + ); + entityPositionEnsurePRS(trigPos); + + entityid_t currentEntityIds[ENTITY_TRIGGER_OCCUPANTS_MAX]; + componentid_t currentComponentIds[ENTITY_TRIGGER_OCCUPANTS_MAX]; + vec3 currentPositions[ENTITY_TRIGGER_OCCUPANTS_MAX]; + uint8_t currentCount = 0; + + for(entityid_t oi = 0; oi < physCount; oi++) { + entityid_t otherEntityId = physEnts[oi]; + if(otherEntityId == trigEntityId) continue; + + entityphysics_t *otherPhys = entityPhysicsGet( + mgr, otherEntityId, physComps[oi] + ); + if(!(trig->collideMask & otherPhys->collideMask)) continue; + + componentid_t otherPosComp = entityGetComponent( + mgr, otherEntityId, COMPONENT_TYPE_POSITION + ); + if(otherPosComp == COMPONENT_ID_INVALID) continue; + + entityposition_t *otherPos = entityPositionGet( + mgr, otherEntityId, otherPosComp + ); + entityPositionEnsurePRS(otherPos); + + vec3 normal; float_t depth; + if(!physicsTestShapeVsShape( + trigPos->position, &trig->shape, + otherPos->position, &otherPhys->shape, + normal, &depth + )) continue; + + assertTrue( + currentCount < ENTITY_TRIGGER_OCCUPANTS_MAX, + "Trigger occupant capacity exceeded" + ); + currentEntityIds[currentCount] = otherEntityId; + currentComponentIds[currentCount] = otherPosComp; + glm_vec3_copy(otherPos->position, currentPositions[currentCount]); + currentCount++; + } + + for(uint8_t ci = 0; ci < currentCount; ci++) { + bool_t found = false; + uint8_t foundIndex = 0; + for(uint8_t pi = 0; pi < trig->occupantCount; pi++) { + if(trig->occupants[pi].entityId != currentEntityIds[ci]) continue; + found = true; + foundIndex = pi; + break; + } + + if(!found) { + entityTriggerFireEnter( + mgr, trigEntityId, trigComponentId, + currentEntityIds[ci], currentComponentIds[ci] + ); + continue; + } + + entityTriggerFireActive( + mgr, trigEntityId, trigComponentId, + currentEntityIds[ci], currentComponentIds[ci] + ); + if( + glm_vec3_distance2( + trig->occupants[foundIndex].lastPosition, currentPositions[ci] + ) > TRIGGER_SYSTEM_MOVE_EPSILON_SQ + ) { + entityTriggerFireMove( + mgr, trigEntityId, trigComponentId, + currentEntityIds[ci], currentComponentIds[ci] + ); + } + } + + for(uint8_t pi = 0; pi < trig->occupantCount; pi++) { + bool_t stillIn = false; + for(uint8_t ci = 0; ci < currentCount; ci++) { + if(currentEntityIds[ci] != trig->occupants[pi].entityId) continue; + stillIn = true; + break; + } + if(stillIn) continue; + + entityTriggerFireLeave( + mgr, trigEntityId, trigComponentId, + trig->occupants[pi].entityId, trig->occupants[pi].componentId + ); + } + + trig->occupantCount = currentCount; + for(uint8_t ci = 0; ci < currentCount; ci++) { + trig->occupants[ci].entityId = currentEntityIds[ci]; + trig->occupants[ci].componentId = currentComponentIds[ci]; + glm_vec3_copy(currentPositions[ci], trig->occupants[ci].lastPosition); + } + } +} diff --git a/src/dusk/physics/triggersystem.h b/src/dusk/physics/triggersystem.h new file mode 100644 index 00000000..c73570c2 --- /dev/null +++ b/src/dusk/physics/triggersystem.h @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entity/entitybase.h" + +/** + * Steps every TRIGGER-component entity in mgr: tests each PHYSICS-component + * entity's shape against the trigger's shape (via physicsTestShapeVsShape, + * no push-out or velocity resolution is applied), and fires + * onEnter/onActive/onMove/onLeave based on the change in overlap state + * since the previous call. A PHYSICS entity is only tested against a + * trigger if their collideMask bitmasks share at least one set bit (see + * entityTriggerSetCollideMask() and entityPhysicsSetCollideMask()). + * Entities missing a POSITION component (trigger or candidate) are + * skipped. A no-op if mgr has no TRIGGER-component entities. + * + * @param mgr The entity manager whose TRIGGER-component entities to step. + */ +void triggerSystemStep(entitymanager_t *mgr); diff --git a/src/dusk/scene/scene.c b/src/dusk/scene/scene.c index 23ac9740..264e7031 100644 --- a/src/dusk/scene/scene.c +++ b/src/dusk/scene/scene.c @@ -14,6 +14,7 @@ #include "entity/component/display/entitycamera.h" #include "entity/component/display/entityposition.h" #include "entity/component/display/entityrenderable.h" +#include "physics/triggersystem.h" #include "ui/ui.h" #include "console/console.h" @@ -87,6 +88,7 @@ errorret_t sceneFixedUpdate(void) { scene_t *scene = &SCENE_MANAGER.scenes[SCENE_MANAGER.active]; entityManagerUpdate(&scene->entities); physicsWorldStep(&scene->physics, &scene->entities, TIME.delta); + triggerSystemStep(&scene->entities); errorOk(); } diff --git a/src/dusk/scene/scene.h b/src/dusk/scene/scene.h index db9daec9..f675f700 100644 --- a/src/dusk/scene/scene.h +++ b/src/dusk/scene/scene.h @@ -99,10 +99,10 @@ errorret_t sceneUpdate(void); /** * Ticks the active scene's entities (update callbacks, then a physics - * step) by one fixed timestep (TIME.delta). Called by sceneUpdate() at - * the fixed-timestep cadence described there -- never once per rendered - * frame on a dynamic-time platform, so game logic and physics stay - * frame-rate independent and deterministic. + * step, then a trigger step) by one fixed timestep (TIME.delta). Called + * by sceneUpdate() at the fixed-timestep cadence described there -- never + * once per rendered frame on a dynamic-time platform, so game logic and + * physics stay frame-rate independent and deterministic. * * @return An error if the update failed, or errorOk() if it succeeded. */ diff --git a/src/duskrpg/game/game.c b/src/duskrpg/game/game.c index baef87b6..41467eeb 100644 --- a/src/duskrpg/game/game.c +++ b/src/duskrpg/game/game.c @@ -34,6 +34,19 @@ errorret_t gameInit(void) { assetUnlockEntry(sceneEntry); errorChain(ret); + // Test-only: print a debug message whenever an entity enters "testArea" + // (see assets/scenes/test.json), exercising the TRIGGER component. + entitymanager_t *mgr = sceneGetEntities(testSceneId); + entityid_t testAreaEntity = entityFindByName(mgr, "testArea"); + if(testAreaEntity != ENTITY_ID_INVALID) { + componentid_t testAreaTrig = entityGetComponent( + mgr, testAreaEntity, COMPONENT_TYPE_TRIGGER + ); + entityTriggerOnEnterAdd( + mgr, testAreaEntity, testAreaTrig, gameTestAreaOnEnter, NULL + ); + } + sceneSetActive(testSceneId); errorOk(); } @@ -45,3 +58,14 @@ errorret_t gameUpdate(void) { errorret_t gameDispose(void) { errorOk(); } + +void gameTestAreaOnEnter( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + logDebug("testArea: entity %d entered", otherEntityId); +} diff --git a/test/entity/CMakeLists.txt b/test/entity/CMakeLists.txt index 3389c384..b0a616a1 100644 --- a/test/entity/CMakeLists.txt +++ b/test/entity/CMakeLists.txt @@ -8,3 +8,4 @@ include(dusktest) # Tests dusktest(test_entitymanager.c) dusktest(test_entityposition.c) +dusktest(test_entitytrigger.c) diff --git a/test/entity/test_entitytrigger.c b/test/entity/test_entitytrigger.c new file mode 100644 index 00000000..3a0098d4 --- /dev/null +++ b/test/entity/test_entitytrigger.c @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "util/memory.h" +#include "entity/entitymanager.h" +#include "entity/component/trigger/entitytrigger.h" +#include "entity/component/trigger/entitytriggerevents.h" + +static uint32_t CALLBACK_ENTER_COUNT; +static uint32_t CALLBACK_ACTIVE_COUNT; +static uint32_t CALLBACK_MOVE_COUNT; +static uint32_t CALLBACK_LEAVE_COUNT; +static entityid_t CALLBACK_LAST_OTHER; +static void *CALLBACK_LAST_USER; + +static void test_resetCallbackCounts(void) { + CALLBACK_ENTER_COUNT = 0; + CALLBACK_ACTIVE_COUNT = 0; + CALLBACK_MOVE_COUNT = 0; + CALLBACK_LEAVE_COUNT = 0; + CALLBACK_LAST_OTHER = ENTITY_ID_INVALID; + CALLBACK_LAST_USER = NULL; +} + +static void test_onEnter( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + CALLBACK_ENTER_COUNT++; + CALLBACK_LAST_OTHER = otherEntityId; + CALLBACK_LAST_USER = user; +} + +static void test_onActive( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + CALLBACK_ACTIVE_COUNT++; +} + +static void test_onMove( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + CALLBACK_MOVE_COUNT++; +} + +static void test_onLeave( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + CALLBACK_LEAVE_COUNT++; +} + +static void test_entityTriggerDefaultsAndAccessors(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t trig = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_TRIGGER + ); + + // Defaults: 1x1x1 cube, only bit 0 of the collide mask set, no occupants. + physicsshape_t shape = entityTriggerGetShape(&mgr, entity, trig); + assert_int_equal(shape.type, PHYSICS_SHAPE_CUBE); + assert_float_equal(shape.data.cube.halfExtents[0], 0.5f, 0.0001f); + assert_int_equal(entityTriggerGetCollideMask(&mgr, entity, trig), 0x1); + assert_int_equal(entityTriggerGetOccupantCount(&mgr, entity, trig), 0); + assert_int_equal( + entityTriggerGetOccupantEntityId(&mgr, entity, trig, 0), + ENTITY_ID_INVALID + ); + assert_false(entityTriggerIsOccupyingEntity(&mgr, entity, trig, 5)); + + physicsshape_t sphere = { + .type = PHYSICS_SHAPE_SPHERE, + .data.sphere.radius = 2.0f + }; + entityTriggerSetShape(&mgr, entity, trig, sphere); + physicsshape_t got = entityTriggerGetShape(&mgr, entity, trig); + assert_int_equal(got.type, PHYSICS_SHAPE_SPHERE); + assert_float_equal(got.data.sphere.radius, 2.0f, 0.0001f); + + entityTriggerSetCollideMask(&mgr, entity, trig, 0x2); + assert_int_equal(entityTriggerGetCollideMask(&mgr, entity, trig), 0x2); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityTriggerSerializeRoundTrip(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t trig = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_TRIGGER + ); + + physicsshape_t sphere = { + .type = PHYSICS_SHAPE_SPHERE, + .data.sphere.radius = 3.0f + }; + entityTriggerSetShape(&mgr, entity, trig, sphere); + entityTriggerSetCollideMask(&mgr, entity, trig, 0x4); + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + assert_true( + errorIsOk(entityTriggerSerialize(&mgr, entity, trig, doc, root)) + ); + + size_t len = 0; + char_t *jsonStr = yyjson_mut_write(doc, 0, &len); + assert_non_null(jsonStr); + yyjson_mut_doc_free(doc); + + yyjson_doc *readDoc = yyjson_read(jsonStr, len, 0); + free(jsonStr); + assert_non_null(readDoc); + + entityid_t entity2 = entityManagerAdd(&mgr); + componentid_t trig2 = entityAddComponent( + &mgr, entity2, COMPONENT_TYPE_TRIGGER + ); + assert_true(errorIsOk(entityTriggerDeserialize( + &mgr, entity2, trig2, yyjson_doc_get_root(readDoc) + ))); + yyjson_doc_free(readDoc); + + physicsshape_t got = entityTriggerGetShape(&mgr, entity2, trig2); + assert_int_equal(got.type, PHYSICS_SHAPE_SPHERE); + assert_float_equal(got.data.sphere.radius, 3.0f, 0.0001f); + assert_int_equal(entityTriggerGetCollideMask(&mgr, entity2, trig2), 0x4); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityTriggerEventSubscription(void **state) { + test_resetCallbackCounts(); + + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t trig = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_TRIGGER + ); + + int32_t userValue = 42; + entityTriggerOnEnterAdd(&mgr, entity, trig, test_onEnter, &userValue); + entityTriggerOnActiveAdd(&mgr, entity, trig, test_onActive, NULL); + entityTriggerOnMoveAdd(&mgr, entity, trig, test_onMove, NULL); + entityTriggerOnLeaveAdd(&mgr, entity, trig, test_onLeave, NULL); + + entityTriggerFireEnter(&mgr, entity, trig, 7, 0); + assert_int_equal(CALLBACK_ENTER_COUNT, 1); + assert_int_equal(CALLBACK_LAST_OTHER, 7); + assert_ptr_equal(CALLBACK_LAST_USER, &userValue); + + entityTriggerFireActive(&mgr, entity, trig, 7, 0); + assert_int_equal(CALLBACK_ACTIVE_COUNT, 1); + + entityTriggerFireMove(&mgr, entity, trig, 7, 0); + assert_int_equal(CALLBACK_MOVE_COUNT, 1); + + entityTriggerFireLeave(&mgr, entity, trig, 7, 0); + assert_int_equal(CALLBACK_LEAVE_COUNT, 1); + + // After removing, the callback no longer fires. + entityTriggerOnEnterRemove(&mgr, entity, trig, test_onEnter); + entityTriggerFireEnter(&mgr, entity, trig, 8, 0); + assert_int_equal(CALLBACK_ENTER_COUNT, 1); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_entityTriggerDefaultsAndAccessors), + cmocka_unit_test(test_entityTriggerSerializeRoundTrip), + cmocka_unit_test(test_entityTriggerEventSubscription), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/physics/CMakeLists.txt b/test/physics/CMakeLists.txt index 6e9f6a0a..af8c65e5 100644 --- a/test/physics/CMakeLists.txt +++ b/test/physics/CMakeLists.txt @@ -9,3 +9,5 @@ include(dusktest) dusktest(test_physicstest.c) dusktest(test_physicsworld.c) dusktest(test_physicsshapemesh.c) +dusktest(test_physicsshape.c) +dusktest(test_triggersystem.c) diff --git a/test/physics/test_physicsshape.c b/test/physics/test_physicsshape.c new file mode 100644 index 00000000..091e3142 --- /dev/null +++ b/test/physics/test_physicsshape.c @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "util/memory.h" +#include "physics/physicsshape.h" + +static yyjson_val *test_roundTripToImmutable( + yyjson_mut_doc *doc, + yyjson_mut_val *root, + yyjson_doc **outReadDoc +) { + size_t len = 0; + char_t *jsonStr = yyjson_mut_write(doc, 0, &len); + assert_non_null(jsonStr); + yyjson_mut_doc_free(doc); + + *outReadDoc = yyjson_read(jsonStr, len, 0); + free(jsonStr); + assert_non_null(*outReadDoc); + return yyjson_doc_get_root(*outReadDoc); +} + +static void test_physicsShapeCubeRoundTrip(void **state) { + physicsshape_t shape = { + .type = PHYSICS_SHAPE_CUBE, + .data.cube.halfExtents = { 1.0f, 2.0f, 3.0f } + }; + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + assert_true(errorIsOk(physicsShapeSerialize(doc, root, &shape))); + + yyjson_doc *readDoc; + yyjson_val *root2 = test_roundTripToImmutable(doc, root, &readDoc); + + physicsshape_t got; + assert_true(errorIsOk( + physicsShapeDeserialize(yyjson_obj_get(root2, "shape"), &got) + )); + yyjson_doc_free(readDoc); + + assert_int_equal(got.type, PHYSICS_SHAPE_CUBE); + assert_float_equal(got.data.cube.halfExtents[0], 1.0f, 0.0001f); + assert_float_equal(got.data.cube.halfExtents[1], 2.0f, 0.0001f); + assert_float_equal(got.data.cube.halfExtents[2], 3.0f, 0.0001f); +} + +static void test_physicsShapeSphereRoundTrip(void **state) { + physicsshape_t shape = { + .type = PHYSICS_SHAPE_SPHERE, + .data.sphere.radius = 4.5f + }; + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + assert_true(errorIsOk(physicsShapeSerialize(doc, root, &shape))); + + yyjson_doc *readDoc; + yyjson_val *root2 = test_roundTripToImmutable(doc, root, &readDoc); + + physicsshape_t got; + assert_true(errorIsOk( + physicsShapeDeserialize(yyjson_obj_get(root2, "shape"), &got) + )); + yyjson_doc_free(readDoc); + + assert_int_equal(got.type, PHYSICS_SHAPE_SPHERE); + assert_float_equal(got.data.sphere.radius, 4.5f, 0.0001f); +} + +static void test_physicsShapeCapsuleRoundTrip(void **state) { + physicsshape_t shape = { + .type = PHYSICS_SHAPE_CAPSULE, + .data.capsule = { .radius = 0.5f, .halfHeight = 1.5f } + }; + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + assert_true(errorIsOk(physicsShapeSerialize(doc, root, &shape))); + + yyjson_doc *readDoc; + yyjson_val *root2 = test_roundTripToImmutable(doc, root, &readDoc); + + physicsshape_t got; + assert_true(errorIsOk( + physicsShapeDeserialize(yyjson_obj_get(root2, "shape"), &got) + )); + yyjson_doc_free(readDoc); + + assert_int_equal(got.type, PHYSICS_SHAPE_CAPSULE); + assert_float_equal(got.data.capsule.radius, 0.5f, 0.0001f); + assert_float_equal(got.data.capsule.halfHeight, 1.5f, 0.0001f); +} + +static void test_physicsShapePlaneRoundTrip(void **state) { + physicsshape_t shape = { + .type = PHYSICS_SHAPE_PLANE, + .data.plane = { .normal = { 0.0f, 1.0f, 0.0f }, .distance = 2.0f } + }; + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + assert_true(errorIsOk(physicsShapeSerialize(doc, root, &shape))); + + yyjson_doc *readDoc; + yyjson_val *root2 = test_roundTripToImmutable(doc, root, &readDoc); + + physicsshape_t got; + assert_true(errorIsOk( + physicsShapeDeserialize(yyjson_obj_get(root2, "shape"), &got) + )); + yyjson_doc_free(readDoc); + + assert_int_equal(got.type, PHYSICS_SHAPE_PLANE); + assert_float_equal(got.data.plane.normal[1], 1.0f, 0.0001f); + assert_float_equal(got.data.plane.distance, 2.0f, 0.0001f); +} + +static void test_physicsShapeDeserializeUnknownTypeFails(void **state) { + const char_t *json = "{\"shape\":{\"type\":\"NOT_A_SHAPE\"}}"; + yyjson_doc *readDoc = yyjson_read(json, strlen(json), YYJSON_READ_NOFLAG); + assert_non_null(readDoc); + + physicsshape_t got; + errorret_t ret = physicsShapeDeserialize( + yyjson_obj_get(yyjson_doc_get_root(readDoc), "shape"), &got + ); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + + yyjson_doc_free(readDoc); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_physicsShapeCubeRoundTrip), + cmocka_unit_test(test_physicsShapeSphereRoundTrip), + cmocka_unit_test(test_physicsShapeCapsuleRoundTrip), + cmocka_unit_test(test_physicsShapePlaneRoundTrip), + cmocka_unit_test(test_physicsShapeDeserializeUnknownTypeFails), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/physics/test_physicsworld.c b/test/physics/test_physicsworld.c index 14325b56..0dd5efbf 100644 --- a/test/physics/test_physicsworld.c +++ b/test/physics/test_physicsworld.c @@ -191,6 +191,49 @@ static void test_physicsWorldDynamicVsDynamicSeparates(void **state) { assert_int_equal(memoryGetAllocatedCount(), 0); } +static void test_physicsWorldCollideMaskPreventsCollision(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + physicsworld_t world; + physicsWorldInit(&world); + + entityid_t entityA = entityManagerAdd(&mgr); + componentid_t posA = entityAddComponent( + &mgr, entityA, COMPONENT_TYPE_POSITION + ); + componentid_t physA = entityAddComponent( + &mgr, entityA, COMPONENT_TYPE_PHYSICS + ); + vec3 posAStart = { 0.0f, 0.0f, 0.3f }; + entityPositionSetLocalPosition(&mgr, entityA, posA, posAStart); + entityPhysicsGet(&mgr, entityA, physA)->gravityScale = 0.0f; + entityPhysicsSetCollideMask(&mgr, entityA, physA, 0x1); + + entityid_t entityB = entityManagerAdd(&mgr); + componentid_t posB = entityAddComponent( + &mgr, entityB, COMPONENT_TYPE_POSITION + ); + componentid_t physB = entityAddComponent( + &mgr, entityB, COMPONENT_TYPE_PHYSICS + ); + vec3 posBStart = { 0.0f, 0.0f, -0.3f }; + entityPositionSetLocalPosition(&mgr, entityB, posB, posBStart); + entityPhysicsGet(&mgr, entityB, physB)->gravityScale = 0.0f; + entityPhysicsSetCollideMask(&mgr, entityB, physB, 0x2); + + // Overlapping by 0.4 on Z, but masks share no bits -- no separation. + physicsWorldStep(&world, &mgr, 1.0f / 60.0f); + + vec3 finalA, finalB; + entityPositionGetWorldPosition(&mgr, entityA, posA, finalA); + entityPositionGetWorldPosition(&mgr, entityB, posB, finalB); + assert_float_equal(finalA[2], posAStart[2], 0.0001f); + assert_float_equal(finalB[2], posBStart[2], 0.0001f); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + static void test_physicsWorldStepNoPhysicsEntitiesIsNoop(void **state) { entitymanager_t mgr; entityManagerInit(&mgr); @@ -210,6 +253,7 @@ int main(int argc, char **argv) { cmocka_unit_test(test_physicsWorldGravityIntegratesDynamicBody), cmocka_unit_test(test_physicsWorldRestsOnStaticFloor), cmocka_unit_test(test_physicsWorldDynamicVsDynamicSeparates), + cmocka_unit_test(test_physicsWorldCollideMaskPreventsCollision), cmocka_unit_test(test_physicsWorldStepNoPhysicsEntitiesIsNoop), }; diff --git a/test/physics/test_triggersystem.c b/test/physics/test_triggersystem.c new file mode 100644 index 00000000..1376ac25 --- /dev/null +++ b/test/physics/test_triggersystem.c @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "util/memory.h" +#include "entity/entitymanager.h" +#include "entity/component/display/entityposition.h" +#include "entity/component/physics/entityphysics.h" +#include "entity/component/trigger/entitytrigger.h" +#include "entity/component/trigger/entitytriggerevents.h" +#include "physics/triggersystem.h" + +static uint32_t TS_ENTER_COUNT; +static uint32_t TS_ACTIVE_COUNT; +static uint32_t TS_MOVE_COUNT; +static uint32_t TS_LEAVE_COUNT; + +static void test_resetCounts(void) { + TS_ENTER_COUNT = 0; + TS_ACTIVE_COUNT = 0; + TS_MOVE_COUNT = 0; + TS_LEAVE_COUNT = 0; +} + +static void ts_onEnter( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + TS_ENTER_COUNT++; +} + +static void ts_onActive( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + TS_ACTIVE_COUNT++; +} + +static void ts_onMove( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + TS_MOVE_COUNT++; +} + +static void ts_onLeave( + entitymanager_t *mgr, + const entityid_t triggerEntityId, + const componentid_t triggerComponentId, + const entityid_t otherEntityId, + const componentid_t otherComponentId, + void *user +) { + TS_LEAVE_COUNT++; +} + +static void test_triggerSystemLifecycle(void **state) { + test_resetCounts(); + + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t trigEntity = entityManagerAdd(&mgr); + entityAddComponent(&mgr, trigEntity, COMPONENT_TYPE_POSITION); + componentid_t trig = entityAddComponent( + &mgr, trigEntity, COMPONENT_TYPE_TRIGGER + ); + entityTriggerOnEnterAdd(&mgr, trigEntity, trig, ts_onEnter, NULL); + entityTriggerOnActiveAdd(&mgr, trigEntity, trig, ts_onActive, NULL); + entityTriggerOnMoveAdd(&mgr, trigEntity, trig, ts_onMove, NULL); + entityTriggerOnLeaveAdd(&mgr, trigEntity, trig, ts_onLeave, NULL); + + entityid_t otherEntity = entityManagerAdd(&mgr); + componentid_t otherPos = entityAddComponent( + &mgr, otherEntity, COMPONENT_TYPE_POSITION + ); + entityAddComponent(&mgr, otherEntity, COMPONENT_TYPE_PHYSICS); + + // Starts far away: no overlap, no events. + vec3 farPos = { 10.0f, 0.0f, 0.0f }; + entityPositionSetLocalPosition(&mgr, otherEntity, otherPos, farPos); + triggerSystemStep(&mgr); + assert_int_equal(TS_ENTER_COUNT, 0); + assert_int_equal(entityTriggerGetOccupantCount(&mgr, trigEntity, trig), 0); + + // Moves fully inside: onEnter fires once, not onActive. + vec3 insidePos = { 0.0f, 0.0f, 0.0f }; + entityPositionSetLocalPosition(&mgr, otherEntity, otherPos, insidePos); + triggerSystemStep(&mgr); + assert_int_equal(TS_ENTER_COUNT, 1); + assert_int_equal(TS_ACTIVE_COUNT, 0); + assert_int_equal(entityTriggerGetOccupantCount(&mgr, trigEntity, trig), 1); + assert_true( + entityTriggerIsOccupyingEntity(&mgr, trigEntity, trig, otherEntity) + ); + + // Stays put: onActive fires, no onMove (position unchanged). + triggerSystemStep(&mgr); + assert_int_equal(TS_ENTER_COUNT, 1); + assert_int_equal(TS_ACTIVE_COUNT, 1); + assert_int_equal(TS_MOVE_COUNT, 0); + + // Shifts slightly but still overlapping: onActive + onMove both fire. + vec3 shiftedPos = { 0.1f, 0.0f, 0.0f }; + entityPositionSetLocalPosition(&mgr, otherEntity, otherPos, shiftedPos); + triggerSystemStep(&mgr); + assert_int_equal(TS_ACTIVE_COUNT, 2); + assert_int_equal(TS_MOVE_COUNT, 1); + + // Leaves: onLeave fires, occupant list empties. + entityPositionSetLocalPosition(&mgr, otherEntity, otherPos, farPos); + triggerSystemStep(&mgr); + assert_int_equal(TS_LEAVE_COUNT, 1); + assert_int_equal(entityTriggerGetOccupantCount(&mgr, trigEntity, trig), 0); + assert_false( + entityTriggerIsOccupyingEntity(&mgr, trigEntity, trig, otherEntity) + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_triggerSystemCollideMaskFilter(void **state) { + test_resetCounts(); + + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t trigEntity = entityManagerAdd(&mgr); + entityAddComponent(&mgr, trigEntity, COMPONENT_TYPE_POSITION); + componentid_t trig = entityAddComponent( + &mgr, trigEntity, COMPONENT_TYPE_TRIGGER + ); + entityTriggerSetCollideMask(&mgr, trigEntity, trig, 0x1); + entityTriggerOnEnterAdd(&mgr, trigEntity, trig, ts_onEnter, NULL); + + entityid_t otherEntity = entityManagerAdd(&mgr); + entityAddComponent(&mgr, otherEntity, COMPONENT_TYPE_POSITION); + componentid_t otherPhys = entityAddComponent( + &mgr, otherEntity, COMPONENT_TYPE_PHYSICS + ); + entityPhysicsSetCollideMask(&mgr, otherEntity, otherPhys, 0x2); + + // Fully overlapping (both default to the origin), but masks share no + // bits -- must not be detected. + triggerSystemStep(&mgr); + assert_int_equal(TS_ENTER_COUNT, 0); + assert_int_equal(entityTriggerGetOccupantCount(&mgr, trigEntity, trig), 0); + + // Sharing bit 0x1 now -- detected. + entityPhysicsSetCollideMask(&mgr, otherEntity, otherPhys, 0x3); + triggerSystemStep(&mgr); + assert_int_equal(TS_ENTER_COUNT, 1); + assert_int_equal(entityTriggerGetOccupantCount(&mgr, trigEntity, trig), 1); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_triggerSystemStepNoTriggersIsNoop(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityManagerAdd(&mgr); + triggerSystemStep(&mgr); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_triggerSystemLifecycle), + cmocka_unit_test(test_triggerSystemCollideMaskFilter), + cmocka_unit_test(test_triggerSystemStepNoTriggersIsNoop), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}