Add area and flag on physics

This commit is contained in:
2026-07-20 12:31:14 -05:00
parent 373f1c1010
commit b9f06fef7a
27 changed files with 1956 additions and 114 deletions
+15 -1
View File
@@ -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": [
{
+1
View File
@@ -6,3 +6,4 @@
# Subdirs
add_subdirectory(display)
add_subdirectory(physics)
add_subdirectory(trigger)
+28 -104
View File
@@ -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();
}
@@ -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.
@@ -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
)
@@ -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();
}
@@ -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
);
@@ -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]
);
}
}
@@ -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
);
+3
View File
@@ -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"
+22
View File
@@ -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
);
+2
View File
@@ -8,5 +8,7 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
physicsworld.c
physicstest.c
physicsshape.c
physicsshapemesh.c
triggersystem.c
)
+131
View File
@@ -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();
}
+34
View File
@@ -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
);
+2
View File
@@ -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(
+4 -1
View File
@@ -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.
+153
View File
@@ -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);
}
}
}
+24
View File
@@ -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);
+2
View File
@@ -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();
}
+4 -4
View File
@@ -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.
*/
+24
View File
@@ -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);
}
+1
View File
@@ -8,3 +8,4 @@ include(dusktest)
# Tests
dusktest(test_entitymanager.c)
dusktest(test_entityposition.c)
dusktest(test_entitytrigger.c)
+211
View File
@@ -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);
}
+2
View File
@@ -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)
+153
View File
@@ -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);
}
+44
View File
@@ -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),
};
+195
View File
@@ -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);
}