From 1fa5cd316ed0f908e8007a28104e6622915e85f2 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Sat, 18 Jul 2026 19:05:51 -0500 Subject: [PATCH] Restore ECS --- src/dusk/CMakeLists.txt | 1 + src/dusk/engine/engine.c | 37 + src/dusk/engine/engine.h | 8 + src/dusk/entity/CMakeLists.txt | 15 + src/dusk/entity/component.c | 152 ++++ src/dusk/entity/component.h | 129 ++++ .../dusk/entity/component}/CMakeLists.txt | 7 +- .../entity/component/display/CMakeLists.txt | 12 + .../entity/component/display/entitycamera.c | 140 ++++ .../entity/component/display/entitycamera.h | 121 ++++ .../entity/component/display/entityposition.c | 657 ++++++++++++++++++ .../entity/component/display/entityposition.h | 445 ++++++++++++ .../component/display/entityrenderable.c | 150 ++++ .../component/display/entityrenderable.h | 196 ++++++ src/dusk/entity/componentlist.h | 22 + src/dusk/entity/entity.c | 186 +++++ src/dusk/entity/entity.h | 157 +++++ src/dusk/entity/entitybase.h | 24 + src/dusk/entity/entitymanager.c | 56 ++ src/dusk/entity/entitymanager.h | 46 ++ src/dusk/scene/scene.c | 147 +++- src/dusk/scene/scene.h | 80 ++- src/dusk/scene/scenebase.h | 14 + test/CMakeLists.txt | 3 +- test/{rpg/overworld => entity}/CMakeLists.txt | 6 +- test/entity/test_entitymanager.c | 97 +++ test/entity/test_entityposition.c | 130 ++++ test/rpg/CMakeLists.txt | 14 - test/rpg/entity/test_entity.c | 105 --- test/rpg/entity/test_entitydir.c | 84 --- test/rpg/overworld/test_maparea.c | 126 ---- test/rpg/overworld/test_tileshape.c | 140 ---- test/rpg/physics/test_physicsbody.c | 78 --- test/rpg/physics/test_physicsworld.c | 432 ------------ test/rpg/test_rpg.c | 16 - test/{rpg/entity => scene}/CMakeLists.txt | 5 +- test/scene/test_scene.c | 125 ++++ 37 files changed, 3130 insertions(+), 1033 deletions(-) create mode 100644 src/dusk/entity/CMakeLists.txt create mode 100644 src/dusk/entity/component.c create mode 100644 src/dusk/entity/component.h rename {test/rpg/physics => src/dusk/entity/component}/CMakeLists.txt (61%) create mode 100644 src/dusk/entity/component/display/CMakeLists.txt create mode 100644 src/dusk/entity/component/display/entitycamera.c create mode 100644 src/dusk/entity/component/display/entitycamera.h create mode 100644 src/dusk/entity/component/display/entityposition.c create mode 100644 src/dusk/entity/component/display/entityposition.h create mode 100644 src/dusk/entity/component/display/entityrenderable.c create mode 100644 src/dusk/entity/component/display/entityrenderable.h create mode 100644 src/dusk/entity/componentlist.h create mode 100644 src/dusk/entity/entity.c create mode 100644 src/dusk/entity/entity.h create mode 100644 src/dusk/entity/entitybase.h create mode 100644 src/dusk/entity/entitymanager.c create mode 100644 src/dusk/entity/entitymanager.h create mode 100644 src/dusk/scene/scenebase.h rename test/{rpg/overworld => entity}/CMakeLists.txt (71%) create mode 100644 test/entity/test_entitymanager.c create mode 100644 test/entity/test_entityposition.c delete mode 100644 test/rpg/CMakeLists.txt delete mode 100644 test/rpg/entity/test_entity.c delete mode 100644 test/rpg/entity/test_entitydir.c delete mode 100644 test/rpg/overworld/test_maparea.c delete mode 100644 test/rpg/overworld/test_tileshape.c delete mode 100644 test/rpg/physics/test_physicsbody.c delete mode 100644 test/rpg/physics/test_physicsworld.c delete mode 100644 test/rpg/test_rpg.c rename test/{rpg/entity => scene}/CMakeLists.txt (71%) create mode 100644 test/scene/test_scene.c diff --git a/src/dusk/CMakeLists.txt b/src/dusk/CMakeLists.txt index 727a756e..8c01ff34 100644 --- a/src/dusk/CMakeLists.txt +++ b/src/dusk/CMakeLists.txt @@ -58,6 +58,7 @@ add_subdirectory(assert) add_subdirectory(asset) add_subdirectory(console) add_subdirectory(display) +add_subdirectory(entity) add_subdirectory(log) add_subdirectory(engine) add_subdirectory(error) diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index fdc786d2..567c7d03 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -12,6 +12,8 @@ #include "locale/localemanager.h" #include "display/display.h" #include "scene/scene.h" +#include "entity/entitymanager.h" +#include "entity/component/display/entityposition.h" #include "asset/asset.h" #include "ui/ui.h" #include "assert/assert.h" @@ -46,6 +48,31 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) { errorChain(networkInit()); errorChain(sceneInit()); + // Test: a spinning cube, viewed by a static camera. + ENGINE.testSceneId = sceneCreate(); + sceneSetActive(ENGINE.testSceneId); + entitymanager_t *testEntities = sceneGetEntities(ENGINE.testSceneId); + + entityid_t testCamera = entityManagerAdd(testEntities); + componentid_t testCameraPosition = entityAddComponent( + testEntities, testCamera, COMPONENT_TYPE_POSITION + ); + entityAddComponent(testEntities, testCamera, COMPONENT_TYPE_CAMERA); + entityPositionLookAt( + testEntities, testCamera, testCameraPosition, + (vec3){ 3.0f, 3.0f, 3.0f }, + (vec3){ 0.0f, 0.0f, 0.0f }, + (vec3){ 0.0f, 1.0f, 0.0f } + ); + + ENGINE.testCubeEntity = entityManagerAdd(testEntities); + ENGINE.testCubePositionComponent = entityAddComponent( + testEntities, ENGINE.testCubeEntity, COMPONENT_TYPE_POSITION + ); + entityAddComponent( + testEntities, ENGINE.testCubeEntity, COMPONENT_TYPE_RENDERABLE + ); + networkRequestConnection( engineNetworkOnConnected, engineNetworkOnFailed, @@ -83,6 +110,16 @@ errorret_t engineUpdate(void) { if(dialogType == SYSTEM_DIALOG_TYPE_NONE) { inputUpdate(); consoleUpdate(); + + // Test: spin the cube. + ENGINE.testCubeRotation += TIME.delta; + entityPositionSetLocalRotation( + sceneGetEntities(ENGINE.testSceneId), + ENGINE.testCubeEntity, + ENGINE.testCubePositionComponent, + (vec3){ 0.0f, ENGINE.testCubeRotation, 0.0f } + ); + errorChain(sceneUpdate()); errorChain(assetUpdate()); errorChain(uiUpdate()); diff --git a/src/dusk/engine/engine.h b/src/dusk/engine/engine.h index 4f066404..369d31cb 100644 --- a/src/dusk/engine/engine.h +++ b/src/dusk/engine/engine.h @@ -10,6 +10,8 @@ // Important to be included first: #include "display/display.h" #include "error/error.h" +#include "scene/scenebase.h" +#include "entity/entitybase.h" typedef struct { bool_t running; @@ -20,6 +22,12 @@ typedef struct { // Test: disconnects the network 10 seconds after it connects. bool_t networkDisconnectTestPending; float_t networkDisconnectTestAt; + + // Test: a spinning cube, viewed by a static camera. + sceneid_t testSceneId; + entityid_t testCubeEntity; + componentid_t testCubePositionComponent; + float_t testCubeRotation; } engine_t; extern engine_t ENGINE; diff --git a/src/dusk/entity/CMakeLists.txt b/src/dusk/entity/CMakeLists.txt new file mode 100644 index 00000000..e886dae7 --- /dev/null +++ b/src/dusk/entity/CMakeLists.txt @@ -0,0 +1,15 @@ +# 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 + entity.c + entitymanager.c + component.c +) + +# Subdirs +add_subdirectory(component) diff --git a/src/dusk/entity/component.c b/src/dusk/entity/component.c new file mode 100644 index 00000000..58512710 --- /dev/null +++ b/src/dusk/entity/component.c @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entitymanager.h" +#include "assert/assert.h" +#include "util/memory.h" + +componentdefinition_t COMPONENT_DEFINITIONS[] = { + [COMPONENT_TYPE_NULL] = { 0 }, + + #define X(enm, type, field, iMethod, dMethod, rMethod) \ + [COMPONENT_TYPE_##enm] = { \ + .enumName = #enm, \ + .name = #field, \ + .init = iMethod, \ + .dispose = dMethod, \ + .render = rMethod \ + }, + + #include "componentlist.h" + #undef X + + [COMPONENT_TYPE_COUNT] = { 0 } +}; + +void componentInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const componenttype_t type +) { + assertNotNull(mgr, "Entity manager cannot be null"); + assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB"); + assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB"); + assertTrue(type < COMPONENT_TYPE_COUNT, "Component type OOB"); + assertTrue(type != COMPONENT_TYPE_NULL, "Cannot initialize null component"); + + componentindex_t index = componentGetIndex(entityId, componentId); + component_t *cmp = &mgr->components[index]; + memoryZero(cmp, sizeof(component_t)); + + cmp->type = type; + if(COMPONENT_DEFINITIONS[type].init) { + COMPONENT_DEFINITIONS[type].init(mgr, entityId, componentId); + } +} + +void * componentGetData( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const componenttype_t type +) { + assertNotNull(mgr, "Entity manager cannot be null"); + assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB"); + assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB"); + assertTrue(type < COMPONENT_TYPE_COUNT, "Component type OOB"); + assertTrue(type != COMPONENT_TYPE_NULL, "Cannot get data of null component"); + + componentindex_t index = componentGetIndex(entityId, componentId); + component_t *cmp = &mgr->components[index]; + assertTrue(cmp->type == type, "Component type mismatch"); + + return &cmp->data; +} + +componentindex_t componentGetIndex( + const entityid_t entityId, + const componentid_t componentId +) { + assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB"); + assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB"); + return (entityId * ENTITY_COMPONENT_COUNT_MAX) + componentId; +} + +entityid_t componentGetEntitiesWithComponent( + entitymanager_t *mgr, + const componenttype_t type, + entityid_t outEntities[ENTITY_COUNT_MAX], + componentid_t outComponents[ENTITY_COUNT_MAX] +) { + assertNotNull(mgr, "Entity manager cannot be null"); + assertTrue(type < COMPONENT_TYPE_COUNT, "Component type OOB"); + assertTrue(type != COMPONENT_TYPE_NULL, "Cannot check NULL type"); + assertNotNull(outEntities, "Output entities array cannot be null"); + assertNotNull(outComponents, "Output components array cannot be null"); + + entityid_t written = 0; + for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) { + componentid_t used = mgr->entitiesWithComponent[ + type * ENTITY_COUNT_MAX + i + ]; + if(used == COMPONENT_ID_INVALID) continue; + assertTrue( + mgr->components[componentGetIndex(i, used)].type == type, + "Component type mismatch in entitiesWithComponent lookup" + ); + assertTrue( + (mgr->entities[i].state & ENTITY_STATE_ACTIVE) != 0, + "Inactive entity in entitiesWithComponent lookup" + ); + assertTrue( + used < ENTITY_COMPONENT_COUNT_MAX, + "Component ID OOB in entitiesWithComponent lookup" + ); + assertTrue( + componentGetIndex(i,used) < ENTITY_COUNT_MAX*ENTITY_COMPONENT_COUNT_MAX, + "Component index OOB in entitiesWithComponent lookup" + ); + outComponents[written] = used; + outEntities[written++] = i; + } + return written; +} + +errorret_t componentRenderAll(entitymanager_t *mgr) { + assertNotNull(mgr, "Entity manager cannot be null"); + for(entityid_t eid = 0; eid < ENTITY_COUNT_MAX; eid++) { + if(!(mgr->entities[eid].state & ENTITY_STATE_ACTIVE)) continue; + for(componentid_t cid = 0; cid < ENTITY_COMPONENT_COUNT_MAX; cid++) { + component_t *cmp = &mgr->components[componentGetIndex(eid, cid)]; + if(cmp->type == COMPONENT_TYPE_NULL) continue; + if(!COMPONENT_DEFINITIONS[cmp->type].render) continue; + errorChain(COMPONENT_DEFINITIONS[cmp->type].render(mgr, eid, cid)); + } + } + errorOk(); +} + +void componentDispose( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + assertNotNull(mgr, "Entity manager cannot be null"); + assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB"); + assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB"); + + componentindex_t index = componentGetIndex(entityId, componentId); + component_t *cmp = &mgr->components[index]; + if(cmp->type == COMPONENT_TYPE_NULL) return; + + if(COMPONENT_DEFINITIONS[cmp->type].dispose) { + COMPONENT_DEFINITIONS[cmp->type].dispose(mgr, entityId, componentId); + } + + cmp->type = COMPONENT_TYPE_NULL; +} diff --git a/src/dusk/entity/component.h b/src/dusk/entity/component.h new file mode 100644 index 00000000..b380646a --- /dev/null +++ b/src/dusk/entity/component.h @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entitybase.h" + +#define X(enumName, type, field, init, dispose, render) \ + // do nothing +#include "componentlist.h" +#undef X + +typedef union { + #define X(enumName, type, field, init, dispose, render) type field; + #include "componentlist.h" + #undef X +} componentdata_t; + +typedef struct { + const char_t *enumName; + const char_t *name; + void (*init)(entitymanager_t *, const entityid_t, const componentid_t); + void (*dispose)(entitymanager_t *, const entityid_t, const componentid_t); + errorret_t (*render)(entitymanager_t *, const entityid_t, const componentid_t); +} componentdefinition_t; + +typedef enum { + COMPONENT_TYPE_NULL, + + #define X(enumName, type, field, init, dispose, render) \ + COMPONENT_TYPE_##enumName, + #include "componentlist.h" + #undef X + + COMPONENT_TYPE_COUNT +} componenttype_t; + +typedef struct { + componenttype_t type; + componentdata_t data; +} component_t; + +extern componentdefinition_t COMPONENT_DEFINITIONS[]; + +/** + * Initializes a component of the given type for the entity with component ID. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param type The type of the component to initialize. + */ +void componentInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const componenttype_t type +); + +/** + * Gets the pointer to the data of a component for the entity with component ID. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param type The type of the component to get, only used for assertion. + * @return A pointer to the component data. + */ +void * componentGetData( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const componenttype_t type +); + +/** + * Gets the index of a component for the entity with component ID. + * + * @param entityId The entity ID. + * @param componentId The component ID. + * @return The index of the component in the component array. + */ +componentindex_t componentGetIndex( + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Gets the entity IDs of all entities with a component of the given type. + * + * @param mgr The entity manager to search. + * @param type The type of the component to get entities for. + * @param outEntities An array to write the entity IDs to, must be at least + * ENTITY_COUNT_MAX in size. + * @param outComponents An array to write the component IDs to. + * @return The number of entity IDs written to outEntities. + */ +entityid_t componentGetEntitiesWithComponent( + entitymanager_t *mgr, + const componenttype_t type, + entityid_t outEntities[ENTITY_COUNT_MAX], + componentid_t outComponents[ENTITY_COUNT_MAX] +); + +/** + * Disposes of a component for the entity with component ID. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void componentDispose( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Calls the render callback on every active component that defines one. + * Iterates all active entities and all their component slots. No-op for + * components whose definition has render == NULL. + * + * @param mgr The entity manager to render. + * @return Error state. + */ +errorret_t componentRenderAll(entitymanager_t *mgr); diff --git a/test/rpg/physics/CMakeLists.txt b/src/dusk/entity/component/CMakeLists.txt similarity index 61% rename from test/rpg/physics/CMakeLists.txt rename to src/dusk/entity/component/CMakeLists.txt index d115da20..f1938885 100644 --- a/test/rpg/physics/CMakeLists.txt +++ b/src/dusk/entity/component/CMakeLists.txt @@ -3,10 +3,5 @@ # This software is released under the MIT License. # https://opensource.org/licenses/MIT -include(dusktest) - -# Tests -dusktest(test_physicsbody.c) -dusktest(test_physicsworld.c) - # Subdirs +add_subdirectory(display) diff --git a/src/dusk/entity/component/display/CMakeLists.txt b/src/dusk/entity/component/display/CMakeLists.txt new file mode 100644 index 00000000..c6b8538b --- /dev/null +++ b/src/dusk/entity/component/display/CMakeLists.txt @@ -0,0 +1,12 @@ +# 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 + entityposition.c + entitycamera.c + entityrenderable.c +) diff --git a/src/dusk/entity/component/display/entitycamera.c b/src/dusk/entity/component/display/entitycamera.c new file mode 100644 index 00000000..06697054 --- /dev/null +++ b/src/dusk/entity/component/display/entitycamera.c @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entity/entitymanager.h" +#include "entity/entity.h" +#include "entity/component/display/entityposition.h" +#include "display/screen/screen.h" + +void entityCameraInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entitycamera_t *cam = (entitycamera_t *)componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_CAMERA + ); + cam->nearClip = 0.1f; + cam->farClip = 5000.0f; + cam->projType = ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE; + cam->perspective.fov = glm_rad(45.0f); +} + +void entityCameraGetProjection( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + mat4 out +) { + entitycamera_t *cam = (entitycamera_t *)componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_CAMERA + ); + + if( + cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE || + cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED + ) { + glm_mat4_identity(out); + glm_perspective( + cam->perspective.fov, + SCREEN.aspect, + cam->nearClip, + cam->farClip, + out + ); + + if(cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED) { + out[1][1] *= -1.0f; + } + } else if(cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_ORTHOGRAPHIC) { + glm_mat4_identity(out); + glm_ortho( + cam->orthographic.left, + cam->orthographic.right, + cam->orthographic.top, + cam->orthographic.bottom, + cam->nearClip, + cam->farClip, + out + ); + } +} + +entityid_t entityCameraGetCurrent(entitymanager_t *mgr) { + entityid_t camEnts[ENTITY_COUNT_MAX]; + componentid_t camComps[ENTITY_COUNT_MAX]; + entityid_t count = componentGetEntitiesWithComponent( + mgr, COMPONENT_TYPE_CAMERA, camEnts, camComps + ); + if(count == 0) return ENTITY_ID_INVALID; + return camEnts[0]; +} + +void entityCameraGetForward( + entitymanager_t *mgr, + const entityid_t entityId, + vec2 out +) { + componentid_t posComp = entityGetComponent( + mgr, entityId, COMPONENT_TYPE_POSITION + ); + mat4 transform; + entityPositionGetTransform(mgr, entityId, posComp, transform); + // transform is an object->world matrix; column 2 is the entity's local Z + // axis expressed in world space. Cameras look down their local -Z. + float_t fx = -transform[2][0]; + float_t fz = -transform[2][2]; + float_t len = sqrtf(fx * fx + fz * fz); + if(len > 1e-6f) { fx /= len; fz /= len; } + out[0] = fx; + out[1] = fz; +} + +void entityCameraGetRight( + entitymanager_t *mgr, + const entityid_t entityId, + vec2 out +) { + componentid_t posComp = entityGetComponent( + mgr, entityId, COMPONENT_TYPE_POSITION + ); + mat4 transform; + entityPositionGetTransform(mgr, entityId, posComp, transform); + // transform is an object->world matrix; column 0 is the entity's local X + // (right) axis expressed in world space. + float_t rx = transform[0][0]; + float_t rz = transform[0][2]; + float_t len = sqrtf(rx * rx + rz * rz); + if(len > 1e-6f) { rx /= len; rz /= len; } + out[0] = rx; + out[1] = rz; +} + +void entityCameraLookAtPixelPerfect( + entitymanager_t *mgr, + const entityid_t ent, + const componentid_t posComp, + const componentid_t camComp, + const vec3 point, + const vec3 eyeOffset, + const float_t scale +) { + entitycamera_t *cam = (entitycamera_t *)componentGetData( + mgr, ent, camComp, COMPONENT_TYPE_CAMERA + ); + float_t dist = ( + (float_t)SCREEN.height / (2.0f * scale * tanf(cam->perspective.fov * 0.5f)) + ); + + vec3 eye = { + point[0] + eyeOffset[0], + point[1] + dist + eyeOffset[1], + point[2] + eyeOffset[2] + }; + vec3 up = { 0.0f, 0.0f, -1.0f }; + entityPositionLookAt(mgr, ent, posComp, eye, (float_t *)point, up); +} diff --git a/src/dusk/entity/component/display/entitycamera.h b/src/dusk/entity/component/display/entitycamera.h new file mode 100644 index 00000000..3205dea5 --- /dev/null +++ b/src/dusk/entity/component/display/entitycamera.h @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entity/entitybase.h" + +typedef enum { + ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE, + ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED, + ENTITY_CAMERA_PROJECTION_TYPE_ORTHOGRAPHIC +} entitycameraprojectiontype_t; + +typedef struct { + union { + struct { + float_t fov; + } perspective; + + struct { + float_t left; + float_t right; + float_t top; + float_t bottom; + } orthographic; + }; + + float_t nearClip; + float_t farClip; + entitycameraprojectiontype_t projType; +} entitycamera_t; + +/** + * Initializes an entity camera component. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void entityCameraInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Renders out the projection matrix for the given camera. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param out The output projection matrix. + */ +void entityCameraGetProjection( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + mat4 out +); + +/** + * Returns the entity ID of the first active camera, or ENTITY_ID_INVALID if + * none are active. + * + * @param mgr The entity manager to search. + */ +entityid_t entityCameraGetCurrent(entitymanager_t *mgr); + +/** + * Gets the camera's horizontal forward direction (XZ plane) from its + * position component. Automatically finds the position component on the + * entity. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The camera entity ID. + * @param out Output vec2: {forwardX, forwardZ} normalized. + */ +void entityCameraGetForward( + entitymanager_t *mgr, + const entityid_t entityId, + vec2 out +); + +/** + * Gets the camera's horizontal right direction (XZ plane) from its position + * component. Automatically finds the position component on the entity. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The camera entity ID. + * @param out Output vec2: {rightX, rightZ} normalized. + */ +void entityCameraGetRight( + entitymanager_t *mgr, + const entityid_t entityId, + vec2 out +); + +/** + * Positions the camera to look at a 3D point at a pixel-perfect distance + * derived from the camera's FOV and screen height. + * + * @param mgr The entity manager that owns the entity. + * @param ent The camera entity ID. + * @param posComp The position component ID. + * @param camComp The camera component ID. + * @param point World position to look at. + * @param eyeOffset Offset added to the eye position only (not the target). + * @param scale Pixels per world unit. 1.0 = pixel perfect, 2.0 = 2px per unit. + */ +void entityCameraLookAtPixelPerfect( + entitymanager_t *mgr, + const entityid_t ent, + const componentid_t posComp, + const componentid_t camComp, + const vec3 point, + const vec3 eyeOffset, + const float_t scale +); diff --git a/src/dusk/entity/component/display/entityposition.c b/src/dusk/entity/component/display/entityposition.c new file mode 100644 index 00000000..f1a4b5cb --- /dev/null +++ b/src/dusk/entity/component/display/entityposition.c @@ -0,0 +1,657 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entity/entitymanager.h" + +void entityPositionInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + + pos->flags = 0; + pos->parentEntityId = ENTITY_ID_INVALID; + pos->parentComponentId = COMPONENT_ID_INVALID; + pos->childCount = 0; + glm_vec3_zero(pos->position); + glm_vec3_zero(pos->rotation); + glm_vec3_one(pos->scale); + glm_mat4_identity(pos->localTransform); + glm_mat4_identity(pos->worldTransform); +} + +void entityPositionLookAt( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 eye, + vec3 target, + vec3 up +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + // glm_lookat() produces a view matrix (world -> eye space). Every other + // setter treats localTransform as this entity's placement in world space + // (eye -> world), so invert it here to keep that meaning consistent -- + // callers that need a view matrix (e.g. scene rendering) invert it back. + mat4 view; + glm_lookat(eye, target, up, view); + glm_mat4_inv(view, pos->localTransform); + // localTransform is now authoritative; PRS cache is stale. + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_PRS_DIRTY) + & ~(ENTITY_POSITION_FLAG_ROTATION_DIRTY | + ENTITY_POSITION_FLAG_POSITION_DIRTY); + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionGetTransform( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + mat4 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsureWorld(mgr, pos); + glm_mat4_copy( + pos->parentEntityId == ENTITY_ID_INVALID + ? pos->localTransform : pos->worldTransform, + dest + ); +} + +void entityPositionGetLocalTransform( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + mat4 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsureLocal(pos); + glm_mat4_copy(pos->localTransform, dest); +} + +void entityPositionGetLocalPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsurePRS(pos); + glm_vec3_copy(pos->position, dest); +} + +void entityPositionGetWorldPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + if(pos->parentEntityId == ENTITY_ID_INVALID) { + entityPositionEnsurePRS(pos); + glm_vec3_copy(pos->position, dest); + return; + } + entityPositionEnsureWorld(mgr, pos); + dest[0] = pos->worldTransform[3][0]; + dest[1] = pos->worldTransform[3][1]; + dest[2] = pos->worldTransform[3][2]; +} + +void entityPositionSetWorldPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 position +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + if(pos->parentEntityId == ENTITY_ID_INVALID) { + glm_vec3_copy(position, pos->position); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); + return; + } + entityposition_t *parent = componentGetData( + mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsureWorld(mgr, parent); + mat4 invParent; + glm_mat4_inv(parent->worldTransform, invParent); + vec3 localPos; + glm_mat4_mulv3(invParent, position, 1.0f, localPos); + glm_vec3_copy(localPos, pos->position); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionSetLocalPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 position +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + glm_vec3_copy(position, pos->position); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionGetLocalRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsurePRS(pos); + glm_vec3_copy(pos->rotation, dest); +} + +void entityPositionGetWorldRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + if(pos->parentEntityId == ENTITY_ID_INVALID) { + entityPositionEnsurePRS(pos); + glm_vec3_copy(pos->rotation, dest); + return; + } + entityPositionEnsureWorld(mgr, pos); + const float_t (*wt)[4] = pos->worldTransform; + const float_t sx = sqrtf( + wt[0][0]*wt[0][0] + wt[0][1]*wt[0][1] + wt[0][2]*wt[0][2] + ); + const float_t sy = sqrtf( + wt[1][0]*wt[1][0] + wt[1][1]*wt[1][1] + wt[1][2]*wt[1][2] + ); + const float_t sz = sqrtf( + wt[2][0]*wt[2][0] + wt[2][1]*wt[2][1] + wt[2][2]*wt[2][2] + ); + const float_t r00 = sx > 0.0f ? wt[0][0]/sx : 0.0f; + const float_t r10 = sy > 0.0f ? wt[1][0]/sy : 0.0f; + const float_t r20 = sz > 0.0f ? wt[2][0]/sz : 0.0f; + const float_t r01 = sx > 0.0f ? wt[0][1]/sx : 0.0f; + const float_t r11 = sy > 0.0f ? wt[1][1]/sy : 0.0f; + const float_t r21 = sz > 0.0f ? wt[2][1]/sz : 0.0f; + const float_t r22 = sz > 0.0f ? wt[2][2]/sz : 0.0f; + const float_t sinBeta = glm_clamp(r20, -1.0f, 1.0f); + dest[1] = asinf(sinBeta); + const float_t cosBeta = cosf(dest[1]); + if(fabsf(cosBeta) > 1e-6f) { + dest[0] = atan2f(-r21, r22); + dest[2] = atan2f(-r10, r00); + } else { + dest[2] = 0.0f; + dest[0] = (sinBeta > 0.0f) ? atan2f(r01, r11) : -atan2f(r01, r11); + } +} + +void entityPositionSetLocalRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 rotation +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + glm_vec3_copy(rotation, pos->rotation); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionSetWorldRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 rotation +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + if(pos->parentEntityId == ENTITY_ID_INVALID) { + glm_vec3_copy(rotation, pos->rotation); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); + return; + } + entityposition_t *parent = componentGetData( + mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsureWorld(mgr, parent); + + // Build target world rotation matrix (unit scale) from XYZ euler. + const float_t c0 = cosf(rotation[0]), s0 = sinf(rotation[0]); + const float_t c1 = cosf(rotation[1]), s1 = sinf(rotation[1]); + const float_t c2 = cosf(rotation[2]), s2 = sinf(rotation[2]); + const float_t s0s1 = s0*s1, c0s1 = c0*s1; + // Named wr[col_stored][row_stored] matching cglm column-major layout. + const float_t wr00 = c1*c2; + const float_t wr01 = c0*s2 + s0s1*c2; + const float_t wr02 = s0*s2 - c0s1*c2; + const float_t wr10 = -c1*s2; + const float_t wr11 = c0*c2 - s0s1*s2; + const float_t wr12 = s0*c2 + c0s1*s2; + const float_t wr20 = s1; + const float_t wr21 = -s0*c1; + const float_t wr22 = c0*c1; + + // Normalize parent world columns to extract pure rotation. + const float_t (*pt)[4] = parent->worldTransform; + const float_t psx = sqrtf( + pt[0][0]*pt[0][0] + pt[0][1]*pt[0][1] + pt[0][2]*pt[0][2] + ); + const float_t psy = sqrtf( + pt[1][0]*pt[1][0] + pt[1][1]*pt[1][1] + pt[1][2]*pt[1][2] + ); + const float_t psz = sqrtf( + pt[2][0]*pt[2][0] + pt[2][1]*pt[2][1] + pt[2][2]*pt[2][2] + ); + const float_t pr00 = psx > 0.f ? pt[0][0]/psx : 0.f; + const float_t pr01 = psx > 0.f ? pt[0][1]/psx : 0.f; + const float_t pr02 = psx > 0.f ? pt[0][2]/psx : 0.f; + const float_t pr10 = psy > 0.f ? pt[1][0]/psy : 0.f; + const float_t pr11 = psy > 0.f ? pt[1][1]/psy : 0.f; + const float_t pr12 = psy > 0.f ? pt[1][2]/psy : 0.f; + const float_t pr20 = psz > 0.f ? pt[2][0]/psz : 0.f; + const float_t pr21 = psz > 0.f ? pt[2][1]/psz : 0.f; + const float_t pr22 = psz > 0.f ? pt[2][2]/psz : 0.f; + + // local_R = parent_R^T * world_R (R^-1 == R^T for orthogonal matrices). + // Compute only the 7 entries of the local rotation matrix needed for XYZ + // euler extraction (stored column-major: [col][row] = math [row][col]). + // sinBeta = stored[2][0] = math[0][2] + // r21/r22 = stored[2][1..2] = math[1..2][2] + // r10/r00 = stored[1][0], stored[0][0] = math[0][1], math[0][0] + // gimbal = stored[0][1], stored[1][1] = math[1][0], math[1][1] + const float_t lr00 = pr00*wr00 + pr01*wr10 + pr02*wr20; // math[0][0] + const float_t lr10 = pr00*wr01 + pr01*wr11 + pr02*wr21; // math[0][1] + const float_t lr20 = pr00*wr02 + pr01*wr12 + pr02*wr22; // [0][2] -> sinBeta + const float_t lr01 = pr10*wr00 + pr11*wr10 + pr12*wr20; // math[1][0] + const float_t lr11 = pr10*wr01 + pr11*wr11 + pr12*wr21; // math[1][1] + const float_t lr21 = pr10*wr02 + pr11*wr12 + pr12*wr22; // [1][2] -> r21 + const float_t lr22 = pr20*wr02 + pr21*wr12 + pr22*wr22; // [2][2] -> r22 + + const float_t sinBeta = glm_clamp(lr20, -1.0f, 1.0f); + pos->rotation[1] = asinf(sinBeta); + const float_t cosBeta = cosf(pos->rotation[1]); + if(fabsf(cosBeta) > 1e-6f) { + pos->rotation[0] = atan2f(-lr21, lr22); + pos->rotation[2] = atan2f(-lr10, lr00); + } else { + pos->rotation[2] = 0.0f; + pos->rotation[0] = (sinBeta > 0.0f) + ? atan2f(lr01, lr11) : -atan2f(lr01, lr11); + } + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionGetLocalScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsurePRS(pos); + glm_vec3_copy(pos->scale, dest); +} + +void entityPositionGetWorldScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + if(pos->parentEntityId == ENTITY_ID_INVALID) { + entityPositionEnsurePRS(pos); + glm_vec3_copy(pos->scale, dest); + return; + } + entityPositionEnsureWorld(mgr, pos); + const float_t (*wt)[4] = pos->worldTransform; + dest[0] = sqrtf(wt[0][0]*wt[0][0] + wt[0][1]*wt[0][1] + wt[0][2]*wt[0][2]); + dest[1] = sqrtf(wt[1][0]*wt[1][0] + wt[1][1]*wt[1][1] + wt[1][2]*wt[1][2]); + dest[2] = sqrtf(wt[2][0]*wt[2][0] + wt[2][1]*wt[2][1] + wt[2][2]*wt[2][2]); +} + +void entityPositionSetLocalScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 scale +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + glm_vec3_copy(scale, pos->scale); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionSetWorldScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 scale +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + if(pos->parentEntityId == ENTITY_ID_INVALID) { + glm_vec3_copy(scale, pos->scale); + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); + return; + } + entityposition_t *parent = componentGetData( + mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION + ); + entityPositionEnsureWorld(mgr, parent); + const float_t (*pt)[4] = parent->worldTransform; + const float_t psx = sqrtf( + pt[0][0]*pt[0][0] + pt[0][1]*pt[0][1] + pt[0][2]*pt[0][2] + ); + const float_t psy = sqrtf( + pt[1][0]*pt[1][0] + pt[1][1]*pt[1][1] + pt[1][2]*pt[1][2] + ); + const float_t psz = sqrtf( + pt[2][0]*pt[2][0] + pt[2][1]*pt[2][1] + pt[2][2]*pt[2][2] + ); + pos->scale[0] = psx > 0.0f ? scale[0] / psx : scale[0]; + pos->scale[1] = psy > 0.0f ? scale[1] / psy : scale[1]; + pos->scale[2] = psz > 0.0f ? scale[2] / psz : scale[2]; + pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY) + & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionSetParent( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityid_t parentEntityId, + const componentid_t parentComponentId +) { + entityposition_t *pos = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); + + // Remove from old parent's child list. + if(pos->parentEntityId != ENTITY_ID_INVALID) { + entityposition_t *oldParent = componentGetData( + mgr, pos->parentEntityId, pos->parentComponentId, + COMPONENT_TYPE_POSITION + ); + for(uint8_t i = 0; i < oldParent->childCount; i++) { + if( + oldParent->childEntityIds[i] == entityId && + oldParent->childComponentIds[i] == componentId + ) { + oldParent->childCount--; + for(uint8_t j = i; j < oldParent->childCount; j++) { + oldParent->childEntityIds[j] = oldParent->childEntityIds[j + 1]; + oldParent->childComponentIds[j] = oldParent->childComponentIds[j + 1]; + } + break; + } + } + } + + pos->parentEntityId = parentEntityId; + pos->parentComponentId = parentComponentId; + + // Register with new parent. + if(parentEntityId != ENTITY_ID_INVALID) { + entityposition_t *parent = componentGetData( + mgr, parentEntityId, parentComponentId, COMPONENT_TYPE_POSITION + ); + if(parent->childCount < ENTITY_POSITION_CHILDREN_MAX) { + parent->childEntityIds[parent->childCount] = entityId; + parent->childComponentIds[parent->childCount] = componentId; + parent->childCount++; + } + } + + entityPositionMarkDirty(mgr, pos); +} + +entityposition_t *entityPositionGet( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + return componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_POSITION + ); +} + +void entityPositionRebuild(entitymanager_t *mgr, entityposition_t *pos) { + pos->flags = ( + pos->flags | + ENTITY_POSITION_FLAG_ROTATION_DIRTY | + ENTITY_POSITION_FLAG_POSITION_DIRTY + ) & ~ENTITY_POSITION_FLAG_PRS_DIRTY; + entityPositionMarkDirty(mgr, pos); +} + +void entityPositionMarkDirty(entitymanager_t *mgr, entityposition_t *pos) { + if(pos->flags & ENTITY_POSITION_FLAG_WORLD_DIRTY) return; + pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY; + for(uint8_t i = 0; i < pos->childCount; i++) { + entityposition_t *child = componentGetData( + mgr, pos->childEntityIds[i], pos->childComponentIds[i], + COMPONENT_TYPE_POSITION + ); + entityPositionMarkDirty(mgr, child); + } +} + +void entityPositionDisposeDeep( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityposition_t *pos = entityPositionGet(mgr, entityId, componentId); + + // Detach from parent so the parent's child list stays consistent. + if(pos->parentEntityId != ENTITY_ID_INVALID) { + entityPositionSetParent( + mgr, entityId, componentId, ENTITY_ID_INVALID, COMPONENT_ID_INVALID + ); + } + + // Copy the child list before disposing self (entityDispose invalidates + // pos). + uint8_t childCount = pos->childCount; + entityid_t childEntityIds[ENTITY_POSITION_CHILDREN_MAX]; + componentid_t childComponentIds[ENTITY_POSITION_CHILDREN_MAX]; + for(uint8_t i = 0; i < childCount; i++) { + childEntityIds[i] = pos->childEntityIds[i]; + childComponentIds[i] = pos->childComponentIds[i]; + // Sever child's parent link so it won't try to modify our disposed + // data. + entityposition_t *child = entityPositionGet( + mgr, childEntityIds[i], childComponentIds[i] + ); + child->parentEntityId = ENTITY_ID_INVALID; + child->parentComponentId = COMPONENT_ID_INVALID; + } + + entityDispose(mgr, entityId); + + for(uint8_t i = 0; i < childCount; i++) { + entityPositionDisposeDeep(mgr, childEntityIds[i], childComponentIds[i]); + } +} + +void entityPositionDecompose(entityposition_t *pos) { + // Translation: column 3 + pos->position[0] = pos->localTransform[3][0]; + pos->position[1] = pos->localTransform[3][1]; + pos->position[2] = pos->localTransform[3][2]; + + // Scale: length of each basis column (xyz only) + pos->scale[0] = sqrtf( + pos->localTransform[0][0] * pos->localTransform[0][0] + + pos->localTransform[0][1] * pos->localTransform[0][1] + + pos->localTransform[0][2] * pos->localTransform[0][2] + ); + pos->scale[1] = sqrtf( + pos->localTransform[1][0] * pos->localTransform[1][0] + + pos->localTransform[1][1] * pos->localTransform[1][1] + + pos->localTransform[1][2] * pos->localTransform[1][2] + ); + pos->scale[2] = sqrtf( + pos->localTransform[2][0] * pos->localTransform[2][0] + + pos->localTransform[2][1] * pos->localTransform[2][1] + + pos->localTransform[2][2] * pos->localTransform[2][2] + ); + + // Normalize columns to isolate the rotation matrix (no mat4 needed). + const float_t invS0 = pos->scale[0] > 0.0f ? 1.0f / pos->scale[0] : 0.0f; + const float_t invS1 = pos->scale[1] > 0.0f ? 1.0f / pos->scale[1] : 0.0f; + const float_t invS2 = pos->scale[2] > 0.0f ? 1.0f / pos->scale[2] : 0.0f; + + const float_t r00 = pos->localTransform[0][0] * invS0; + const float_t r01 = pos->localTransform[0][1] * invS0; + const float_t r02 = pos->localTransform[0][2] * invS0; + const float_t r10 = pos->localTransform[1][0] * invS1; + const float_t r11 = pos->localTransform[1][1] * invS1; + const float_t r20 = pos->localTransform[2][0] * invS2; + const float_t r21 = pos->localTransform[2][1] * invS2; + const float_t r22 = pos->localTransform[2][2] * invS2; + + // Extract XYZ euler angles (R = Rx * Ry * Rz, column-major) + const float_t sinBeta = glm_clamp(r20, -1.0f, 1.0f); + pos->rotation[1] = asinf(sinBeta); + const float_t cosBeta = cosf(pos->rotation[1]); + + if(fabsf(cosBeta) > 1e-6f) { + pos->rotation[0] = atan2f(-r21, r22); + pos->rotation[2] = atan2f(-r10, r00); + } else { + // Gimbal lock: pin Z to 0, recover X. + pos->rotation[2] = 0.0f; + pos->rotation[0] = (sinBeta > 0.0f) + ? atan2f(r01, r11) + : -atan2f(r01, r11); + } +} + +void entityPositionEnsurePRS(entityposition_t *pos) { + if(!(pos->flags & ENTITY_POSITION_FLAG_PRS_DIRTY)) return; + entityPositionDecompose(pos); + pos->flags &= ~ENTITY_POSITION_FLAG_PRS_DIRTY; +} + +void entityPositionEnsureLocal(entityposition_t *pos) { + const uint8_t dirty = pos->flags & ( + ENTITY_POSITION_FLAG_ROTATION_DIRTY | ENTITY_POSITION_FLAG_POSITION_DIRTY + ); + if(!dirty) return; + + if(dirty & ENTITY_POSITION_FLAG_ROTATION_DIRTY) { + // Rotation or scale changed: rebuild cols 0-2 analytically (XYZ euler). + const float_t c0 = cosf(pos->rotation[0]), s0 = sinf(pos->rotation[0]); + const float_t c1 = cosf(pos->rotation[1]), s1 = sinf(pos->rotation[1]); + const float_t c2 = cosf(pos->rotation[2]), s2 = sinf(pos->rotation[2]); + const float_t s0s1 = s0 * s1; + const float_t c0s1 = c0 * s1; + + pos->localTransform[0][0] = c1 * c2 * pos->scale[0]; + pos->localTransform[0][1] = (c0 * s2 + s0s1 * c2) * pos->scale[0]; + pos->localTransform[0][2] = (s0 * s2 - c0s1 * c2) * pos->scale[0]; + pos->localTransform[0][3] = 0.0f; + + pos->localTransform[1][0] = -c1 * s2 * pos->scale[1]; + pos->localTransform[1][1] = (c0 * c2 - s0s1 * s2) * pos->scale[1]; + pos->localTransform[1][2] = (s0 * c2 + c0s1 * s2) * pos->scale[1]; + pos->localTransform[1][3] = 0.0f; + + pos->localTransform[2][0] = s1 * pos->scale[2]; + pos->localTransform[2][1] = -s0 * c1 * pos->scale[2]; + pos->localTransform[2][2] = c0 * c1 * pos->scale[2]; + pos->localTransform[2][3] = 0.0f; + } + + if(dirty & ENTITY_POSITION_FLAG_POSITION_DIRTY) { + // Only position changed: update column 3 only (no trig needed). + pos->localTransform[3][0] = pos->position[0]; + pos->localTransform[3][1] = pos->position[1]; + pos->localTransform[3][2] = pos->position[2]; + pos->localTransform[3][3] = 1.0f; + } + + pos->flags &= ~( + ENTITY_POSITION_FLAG_ROTATION_DIRTY | ENTITY_POSITION_FLAG_POSITION_DIRTY + ); +} + +void entityPositionEnsureWorld(entitymanager_t *mgr, entityposition_t *pos) { + if(!(pos->flags & ENTITY_POSITION_FLAG_WORLD_DIRTY)) return; + entityPositionEnsureLocal(pos); + + if(pos->parentEntityId != ENTITY_ID_INVALID) { + // Parented: world = parent.world x local. worldTransform must be + // written because children (and this node's getters) read it. + entityposition_t *parent = componentGetData( + mgr, pos->parentEntityId, pos->parentComponentId, + COMPONENT_TYPE_POSITION + ); + entityPositionEnsureWorld(mgr, parent); + glm_mat4_mul( + parent->worldTransform, pos->localTransform, pos->worldTransform + ); + } else if(pos->childCount > 0) { + // Parentless root with children: children need a valid worldTransform + // to multiply against, but world == local, so just copy. + glm_mat4_copy(pos->localTransform, pos->worldTransform); + } + // Parentless leaf: world == local. Getters read localTransform directly; + // no copy needed. + + pos->flags &= ~ENTITY_POSITION_FLAG_WORLD_DIRTY; +} diff --git a/src/dusk/entity/component/display/entityposition.h b/src/dusk/entity/component/display/entityposition.h new file mode 100644 index 00000000..0979c850 --- /dev/null +++ b/src/dusk/entity/component/display/entityposition.h @@ -0,0 +1,445 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entity/entitybase.h" + +/** Maximum number of child position components this node can track. */ +#define ENTITY_POSITION_CHILDREN_MAX 8 + +/** + * PRS cache is stale. localTransform was written directly (e.g. lookAt) and + * position/rotation/scale need to be decomposed before they can be read. + */ +#define ENTITY_POSITION_FLAG_PRS_DIRTY (1 << 0) + +/** + * Columns 0-2 of localTransform are stale. Rotation or scale changed; the + * basis vectors need to be rebuilt analytically before the matrix can be used. + * Does not imply column 3 (translation) is stale. + */ +#define ENTITY_POSITION_FLAG_ROTATION_DIRTY (1 << 1) + +/** + * Column 3 of localTransform is stale. Position changed; only the + * translation column needs to be written. Does not imply columns 0-2 are + * stale. + */ +#define ENTITY_POSITION_FLAG_POSITION_DIRTY (1 << 2) + +/** + * worldTransform is stale. Either the local matrix changed or an ancestor + * moved; worldTransform must be recomputed before world data can be read. + */ +#define ENTITY_POSITION_FLAG_WORLD_DIRTY (1 << 3) + +typedef struct { + /* + * Hot fields - flag checks, parent/child traversal (markDirty, ensureWorld) + * only touch these. Kept at the front so they share the first cache line. + */ + + /** ENTITY_POSITION_FLAG_* bitmask; describes which caches are stale. */ + uint8_t flags; + /** Entity ID of the parent node, or ENTITY_ID_INVALID if none. */ + entityid_t parentEntityId; + /** Component ID of the parent position, or COMPONENT_ID_INVALID if none. */ + componentid_t parentComponentId; + /** Number of currently registered children. */ + uint8_t childCount; + /** Entity IDs of child nodes. */ + entityid_t childEntityIds[ENTITY_POSITION_CHILDREN_MAX]; + /** Component IDs of child position components. */ + componentid_t childComponentIds[ENTITY_POSITION_CHILDREN_MAX]; + + /* + * Warm fields - read/written by PRS getters/setters. + * Accessed more often than the matrices but less often than flags. + */ + + /** Cached local position (XYZ). Stale when PRS_DIRTY is set. */ + vec3 position; + /** Cached local rotation (XYZ euler, radians). Stale when PRS_DIRTY. */ + vec3 rotation; + /** Cached local scale (XYZ). Stale when PRS_DIRTY is set. */ + vec3 scale; + + /* + * Cold fields - only touched when actually rebuilding transforms. + */ + + /** Local transform matrix, rebuilt lazily from position/rotation/scale. */ + mat4 localTransform; + /** World transform matrix, recomputed lazily from the parent chain. */ + mat4 worldTransform; +} entityposition_t; + +/** + * Initializes the entity position component, setting identity transforms and + * zeroing all parent/child state. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + */ +void entityPositionInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Positions and orients the entity at eye, facing target. Stores this as + * the entity's normal world-space placement (consistent with every other + * setter), not as a view matrix -- invert entityPositionGetTransform()'s + * result to get a view matrix for rendering. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param eye The eye/camera position. + * @param target The target point to look at. + * @param up The up vector. + */ +void entityPositionLookAt( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 eye, + vec3 target, + vec3 up +); + +/** + * Gets the world-space transform matrix, recomputing it lazily if dirty. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination matrix. + */ +void entityPositionGetTransform( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + mat4 dest +); + +/** + * Gets the local transform matrix (does not include parent transforms). + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination matrix. + */ +void entityPositionGetLocalTransform( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + mat4 dest +); + +/** + * Gets the cached local position (XYZ). Decomposes localTransform into PRS + * first if ENTITY_POSITION_FLAG_PRS_DIRTY is set; never triggers a matrix + * rebuild or world-transform update. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination vector. + */ +void entityPositionGetLocalPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +); + +/** + * Gets the world-space position. For parentless entities this is the same as + * the local position. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination vector. + */ +void entityPositionGetWorldPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +); + +/** + * Sets the world-space position. For parentless entities this is equivalent + * to entityPositionSetLocalPosition. For parented entities the position is + * converted to local space via the inverted parent world transform. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param position The desired world-space position. + */ +void entityPositionSetWorldPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 position +); + +/** + * Sets the local position, marks localTransform and worldTransform (self + + * descendants) dirty. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param position The new local position. + */ +void entityPositionSetLocalPosition( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 position +); + +/** + * Gets the cached local euler rotation (XYZ, radians). Decomposes + * localTransform first if ENTITY_POSITION_FLAG_PRS_DIRTY is set. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination vector. + */ +void entityPositionGetLocalRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +); + +/** + * Gets the world-space euler rotation (XYZ, radians) by decomposing the + * world transform. For parentless entities this is the same as local + * rotation. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination vector. + */ +void entityPositionGetWorldRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +); + +/** + * Sets the local euler rotation (XYZ, radians) and marks transforms dirty. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param rotation The new local rotation. + */ +void entityPositionSetLocalRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 rotation +); + +/** + * Sets the world-space euler rotation (XYZ, radians). For parentless + * entities this is equivalent to entityPositionSetLocalRotation. For + * parented entities the rotation is converted to local space by removing + * the parent world rotation. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param rotation The desired world-space euler rotation. + */ +void entityPositionSetWorldRotation( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 rotation +); + +/** + * Gets the cached local scale. Decomposes localTransform first if + * ENTITY_POSITION_FLAG_PRS_DIRTY is set. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination vector. + */ +void entityPositionGetLocalScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +); + +/** + * Gets the world-space scale by extracting column lengths from the world + * transform. For parentless entities this is the same as local scale. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param dest Destination vector. + */ +void entityPositionGetWorldScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 dest +); + +/** + * Sets the local scale and marks transforms dirty. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param scale The new local scale. + */ +void entityPositionSetLocalScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 scale +); + +/** + * Sets the world-space scale. For parentless entities this is equivalent to + * entityPositionSetLocalScale. For parented entities the scale is converted + * to local space by dividing by the parent world scale (assumes no shear). + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @param scale The desired world-space scale. + */ +void entityPositionSetWorldScale( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + vec3 scale +); + +/** + * Sets the parent of this entity's position component. + * Pass ENTITY_ID_INVALID / COMPONENT_ID_INVALID to detach from any parent. + * + * @param mgr The entity manager that owns both entities. + * @param entityId The child entity ID. + * @param componentId The child component ID. + * @param parentEntityId The parent entity ID. + * @param parentComponentId The parent component ID. + */ +void entityPositionSetParent( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityid_t parentEntityId, + const componentid_t parentComponentId +); + +/** + * Returns a direct pointer to the entity position component data. + * After modifying localTransform directly, call entityPositionMarkDirty() to + * set ENTITY_POSITION_FLAG_WORLD_DIRTY on self and descendants. After + * modifying PRS directly, call entityPositionRebuild() instead. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity ID. + * @param componentId The component ID. + * @return Pointer to the component data. + */ +entityposition_t *entityPositionGet( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Signals that the PRS cache was modified externally. Sets both + * ENTITY_POSITION_FLAG_ROTATION_DIRTY and ENTITY_POSITION_FLAG_POSITION_DIRTY + * so all of localTransform is rebuilt lazily on the next read, clears + * ENTITY_POSITION_FLAG_PRS_DIRTY, propagates ENTITY_POSITION_FLAG_WORLD_DIRTY + * to self and all descendants. + * + * @param mgr The entity manager that owns pos and its descendants. + * @param pos The position component whose PRS was modified. + */ +void entityPositionRebuild(entitymanager_t *mgr, entityposition_t *pos); + +/** + * Sets ENTITY_POSITION_FLAG_WORLD_DIRTY on this node and all descendants, + * indicating that worldTransform must be recomputed before it is read. + * Call this after modifying localTransform directly. + * + * @param mgr The entity manager that owns pos and its descendants. + * @param pos The position component to mark dirty. + */ +void entityPositionMarkDirty(entitymanager_t *mgr, entityposition_t *pos); + +/** + * Disposes this entity and all of its position-component descendants + * recursively. Detaches from any parent before destroying. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The root entity ID. + * @param componentId The root position component ID. + */ +void entityPositionDisposeDeep( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Decomposes the local transform matrix back into the position, rotation + * (XYZ euler, radians), and scale cache fields. + * + * @param pos The position component to decompose. + */ +void entityPositionDecompose(entityposition_t *pos); + +/** + * Internal. Decomposes localTransform into the PRS cache if + * ENTITY_POSITION_FLAG_PRS_DIRTY is set. + * + * @param pos The position component to update. + */ +void entityPositionEnsurePRS(entityposition_t *pos); + +/** + * Internal. Rebuilds localTransform from the PRS cache, touching only the + * columns flagged as stale (ROTATION_DIRTY and/or POSITION_DIRTY). + * + * @param pos The position component to update. + */ +void entityPositionEnsureLocal(entityposition_t *pos); + +/** + * Internal. Recomputes worldTransform from the parent chain if + * ENTITY_POSITION_FLAG_WORLD_DIRTY is set. + * + * @param mgr The entity manager that owns pos and its ancestors. + * @param pos The position component to update. + */ +void entityPositionEnsureWorld(entitymanager_t *mgr, entityposition_t *pos); diff --git a/src/dusk/entity/component/display/entityrenderable.c b/src/dusk/entity/component/display/entityrenderable.c new file mode 100644 index 00000000..6f320358 --- /dev/null +++ b/src/dusk/entity/component/display/entityrenderable.c @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entityrenderable.h" +#include "entity/entitymanager.h" +#include "display/shader/shadermaterial.h" +#include "display/shader/shaderunlit.h" +#include "display/display.h" +#include "display/mesh/cube.h" +#include "util/memory.h" +#include "assert/assert.h" + +void entityRenderableInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityrenderable_t *r = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE + ); + memoryZero(r, sizeof(entityrenderable_t)); + r->type = ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL; + r->data.material.shaderType = SHADER_LIST_SHADER_UNLIT; + r->data.material.material.unlit.color = COLOR_WHITE; + r->data.material.meshes[0] = &CUBE_MESH_SIMPLE; + r->data.material.meshOffsets[0] = 0; + r->data.material.meshCounts[0] = -1; + r->data.material.meshCount = 1; + r->data.material.state.flags = DISPLAY_STATE_FLAG_DEPTH_TEST; +} + +void entityRenderableDispose( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { +} + +void entityRenderableSetType( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityrenderabletype_t type +) { + entityrenderable_t *r = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE + ); + r->type = type; +} + +void entityRenderableSetPriority( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const int8_t priority +) { + entityrenderable_t *r = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE + ); + r->priority = priority; +} + +void entityRenderableSetDraw( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + errorret_t (*draw)( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user + ), + void *user +) { + assertNotNull(draw, "Draw callback cannot be null"); + entityrenderable_t *r = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE + ); + r->type = ENTITY_RENDERABLE_TYPE_CUSTOM; + r->data.custom.draw = draw; + r->data.custom.drawUser = user; +} + +errorret_t entityRenderableDraw( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +) { + entityrenderable_t *r = componentGetData( + mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE + ); + switch(r->type) { + case ENTITY_RENDERABLE_TYPE_SPRITEBATCH: + return entityRenderableDrawSpritebatch(&r->data.spritebatch); + case ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL: + return entityRenderableDrawMaterial(&r->data.material); + case ENTITY_RENDERABLE_TYPE_CUSTOM: + return entityRenderableDrawCustom( + mgr, entityId, componentId, &r->data.custom + ); + default: + assertUnreachable("Invalid renderable type"); + } +} + +errorret_t entityRenderableDrawSpritebatch( + const entityrenderablespritebatch_t *sb +) { + if(sb->spriteCount == 0) errorOk(); + + errorChain(displaySetState((displaystate_t){ + .flags = DISPLAY_STATE_FLAG_BLEND + })); + + spriteBatchClear(); + shadermaterial_t mat; + memoryZero(&mat, sizeof(shadermaterial_t)); + mat.unlit.texture = sb->texture; + mat.unlit.color = COLOR_WHITE; + errorChain(spriteBatchBuffer( + sb->sprites, sb->spriteCount, + SHADER_LIST_DEFS[SHADER_LIST_SHADER_UNLIT].shader, mat + )); + return spriteBatchFlush(); +} + +errorret_t entityRenderableDrawMaterial(const entityrenderablematerial_t *m) { + errorChain(displaySetState(m->state)); + shader_t *shader = SHADER_LIST_DEFS[m->shaderType].shader; + assertNotNull(shader, "Shader cannot be null for material type"); + errorChain(shaderBind(shader)); + errorChain(shaderSetMaterial(shader, &m->material)); + for(uint8_t i = 0; i < m->meshCount; i++) { + errorChain(meshDraw(m->meshes[i], m->meshOffsets[i], m->meshCounts[i])); + } + errorOk(); +} + +errorret_t entityRenderableDrawCustom( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityrenderablecustom_t *custom +) { + return custom->draw(mgr, entityId, componentId, custom->drawUser); +} diff --git a/src/dusk/entity/component/display/entityrenderable.h b/src/dusk/entity/component/display/entityrenderable.h new file mode 100644 index 00000000..fa0d5037 --- /dev/null +++ b/src/dusk/entity/component/display/entityrenderable.h @@ -0,0 +1,196 @@ +/** + * 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 "display/mesh/mesh.h" +#include "display/shader/shadermaterial.h" +#include "display/spritebatch/spritebatch.h" +#include "display/displaystate.h" + +#define ENTITY_RENDERABLE_SPRITEBATCH_SPRITES_MAX 64 +#define ENTITY_RENDERABLE_MESHES_MAX 8 + +typedef enum { + ENTITY_RENDERABLE_TYPE_CUSTOM = 0, + ENTITY_RENDERABLE_TYPE_SPRITEBATCH, + ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL, +} entityrenderabletype_t; + +typedef struct { + spritebatchsprite_t sprites[ENTITY_RENDERABLE_SPRITEBATCH_SPRITES_MAX]; + uint32_t spriteCount; + texture_t *texture; +} entityrenderablespritebatch_t; + +typedef struct { + mesh_t *meshes[ENTITY_RENDERABLE_MESHES_MAX]; + int32_t meshOffsets[ENTITY_RENDERABLE_MESHES_MAX]; + int32_t meshCounts[ENTITY_RENDERABLE_MESHES_MAX]; + uint8_t meshCount; + shaderlistshader_t shaderType; + shadermaterial_t material; + displaystate_t state; +} entityrenderablematerial_t; + +typedef struct { + errorret_t (*draw)( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user + ); + void *drawUser; +} entityrenderablecustom_t; + +typedef union entityrenderabledata_u { + entityrenderablespritebatch_t spritebatch; + entityrenderablematerial_t material; + entityrenderablecustom_t custom; +} entityrenderabledata_t; + +typedef struct { + entityrenderabletype_t type; + entityrenderabledata_t data; + + /** + * Render priority. 0 = auto (derived from type/flags). Higher values + * render later (on top of lower values). Range: [-128..127] with 0 auto. + */ + int8_t priority; +} entityrenderable_t; + +/** + * Initializes the entity renderable component. Defaults to + * ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL with the unlit shader, a white + * cube, and depth-test enabled. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to initialize the component for. + * @param componentId The renderable component of the entity. + */ +void entityRenderableInit( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Disposes the entity renderable component. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to dispose the component for. + * @param componentId The renderable component of the entity. + */ +void entityRenderableDispose( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Sets the rendering type for the renderable component. Resets + * type-specific data to zero. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to configure. + * @param componentId The renderable component. + * @param type The rendering type to use. + */ +void entityRenderableSetType( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityrenderabletype_t type +); + +/** + * Sets the render priority. 0 = auto (derived from type/flags). Higher + * values render later (on top). Use non-zero to force ordering. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to configure. + * @param componentId The renderable component. + * @param priority The priority value, or 0 for auto. + */ +void entityRenderableSetPriority( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const int8_t priority +); + +/** + * Sets the draw callback, switching the type to + * ENTITY_RENDERABLE_TYPE_CUSTOM. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to configure. + * @param componentId The renderable component of the entity. + * @param draw The draw callback to assign. + * @param user Userdata passed to the callback. + */ +void entityRenderableSetDraw( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + errorret_t (*draw)( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user + ), + void *user +); + +/** + * Draws the entity using its renderable component data. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to draw. + * @param componentId The renderable component of the entity. + * @return Any error state that happened. + */ +errorret_t entityRenderableDraw( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId +); + +/** + * Internal. Draws a spritebatch-type renderable. + * + * @param sb The spritebatch data to draw. + * @return Error state. + */ +errorret_t entityRenderableDrawSpritebatch( + const entityrenderablespritebatch_t *sb +); + +/** + * Internal. Draws a shader-material-type renderable. + * + * @param m The material data to draw. + * @return Error state. + */ +errorret_t entityRenderableDrawMaterial(const entityrenderablematerial_t *m); + +/** + * Internal. Invokes a custom-type renderable's draw callback. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity being drawn. + * @param componentId The renderable component of the entity. + * @param custom The custom draw data. + * @return Error state. + */ +errorret_t entityRenderableDrawCustom( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + const entityrenderablecustom_t *custom +); diff --git a/src/dusk/entity/componentlist.h b/src/dusk/entity/componentlist.h new file mode 100644 index 00000000..4a38433e --- /dev/null +++ b/src/dusk/entity/componentlist.h @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entity/component/display/entityposition.h" +#include "entity/component/display/entitycamera.h" +#include "entity/component/display/entityrenderable.h" + +// Name (Uppercase) +// Structure +// Field name (lowercase) +// Init function (optional) +// Dispose function (optional) +// Render function (optional) + +X(POSITION, entityposition_t, position, entityPositionInit, NULL, NULL) +X(CAMERA, entitycamera_t, camera, entityCameraInit, NULL, NULL) +X(RENDERABLE, entityrenderable_t, renderable, + entityRenderableInit, entityRenderableDispose, NULL) diff --git a/src/dusk/entity/entity.c b/src/dusk/entity/entity.c new file mode 100644 index 00000000..392a5e3d --- /dev/null +++ b/src/dusk/entity/entity.c @@ -0,0 +1,186 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entitymanager.h" +#include "component/display/entityposition.h" +#include "util/memory.h" +#include "assert/assert.h" + +void entityInit(entitymanager_t *mgr, const entityid_t entityId) { + entity_t *ent = &mgr->entities[entityId]; + + memoryZero(ent, sizeof(entity_t)); + + // Mark all component types not using this entity. + for( + componenttype_t compType = 0; + compType < COMPONENT_TYPE_COUNT; + compType++ + ) { + mgr->entitiesWithComponent[ + compType * ENTITY_COUNT_MAX + entityId + ] = COMPONENT_ID_INVALID; + } + + ent->state |= ENTITY_STATE_ACTIVE; +} + +componentid_t entityAddComponent( + entitymanager_t *mgr, + const entityid_t entityId, + const componenttype_t type +) { + componentindex_t compInd; + + for(componentid_t i = 0; i < ENTITY_COMPONENT_COUNT_MAX; i++) { + compInd = componentGetIndex(entityId, i); + if(mgr->components[compInd].type != COMPONENT_TYPE_NULL) { + assertTrue( + mgr->components[compInd].type != type, + "Entity already has component of this type" + ); + continue; + } + componentInit(mgr, entityId, i, type); + mgr->entitiesWithComponent[ + type * ENTITY_COUNT_MAX + entityId + ] = i; + return i; + } + + assertUnreachable("Entity has no more component slots available"); + return COMPONENT_ID_INVALID; +} + +componentid_t entityGetComponent( + entitymanager_t *mgr, + const entityid_t entityId, + const componenttype_t type +) { + componentid_t compId = mgr->entitiesWithComponent[ + type * ENTITY_COUNT_MAX + entityId + ]; + if(compId == COMPONENT_ID_INVALID) return compId; + assertTrue( + mgr->components[componentGetIndex(entityId, compId)].type == type, + "Component type mismatch" + ); + return compId; +} + +void entityDisposeDeep(entitymanager_t *mgr, const entityid_t entityId) { + componentid_t posComp = entityGetComponent( + mgr, entityId, COMPONENT_TYPE_POSITION + ); + if(posComp != COMPONENT_ID_INVALID) { + entityPositionDisposeDeep(mgr, entityId, posComp); + } else { + entityDispose(mgr, entityId); + } +} + +void entityUpdate(entitymanager_t *mgr, const entityid_t entityId) { + entity_t *ent = &mgr->entities[entityId]; + for(uint8_t i = 0; i < ent->updateCount; i++) { + ent->onUpdate[i](entityId, ent->updateComponentId[i], ent->updateUser[i]); + } +} + +void entityDispose(entitymanager_t *mgr, const entityid_t entityId) { + componentindex_t compInd; + entity_t *ent = &mgr->entities[entityId]; + + for(uint8_t i = 0; i < ent->disposeCount; i++) { + ent->onDispose[i]( + entityId, ent->disposeComponentId[i], ent->disposeUser[i] + ); + } + + for(componentid_t i = 0; i < ENTITY_COMPONENT_COUNT_MAX; i++) { + compInd = componentGetIndex(entityId, i); + componenttype_t type = mgr->components[compInd].type; + if(type == COMPONENT_TYPE_NULL) continue; + mgr->entitiesWithComponent[ + type * ENTITY_COUNT_MAX + entityId + ] = COMPONENT_ID_INVALID; + componentDispose(mgr, entityId, i); + } + + ent->state = 0; +} + +void entityUpdateAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback, + const componentid_t componentId, + void *user +) { + entity_t *ent = &mgr->entities[entityId]; + assertTrue( + ent->updateCount < ENTITY_UPDATE_CALLBACK_COUNT_MAX, + "Entity update callback slots full" + ); + ent->onUpdate[ent->updateCount] = callback; + ent->updateComponentId[ent->updateCount] = componentId; + ent->updateUser[ent->updateCount] = user; + ent->updateCount++; +} + +void entityUpdateRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback +) { + entity_t *ent = &mgr->entities[entityId]; + for(uint8_t i = 0; i < ent->updateCount; i++) { + if(ent->onUpdate[i] != callback) continue; + ent->updateCount--; + for(uint8_t j = i; j < ent->updateCount; j++) { + ent->onUpdate[j] = ent->onUpdate[j + 1]; + ent->updateComponentId[j] = ent->updateComponentId[j + 1]; + ent->updateUser[j] = ent->updateUser[j + 1]; + } + return; + } +} + +void entityDisposeAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback, + const componentid_t componentId, + void *user +) { + entity_t *ent = &mgr->entities[entityId]; + assertTrue( + ent->disposeCount < ENTITY_DISPOSE_CALLBACK_COUNT_MAX, + "Entity dispose callback slots full" + ); + ent->onDispose[ent->disposeCount] = callback; + ent->disposeComponentId[ent->disposeCount] = componentId; + ent->disposeUser[ent->disposeCount] = user; + ent->disposeCount++; +} + +void entityDisposeRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback +) { + entity_t *ent = &mgr->entities[entityId]; + for(uint8_t i = 0; i < ent->disposeCount; i++) { + if(ent->onDispose[i] != callback) continue; + ent->disposeCount--; + for(uint8_t j = i; j < ent->disposeCount; j++) { + ent->onDispose[j] = ent->onDispose[j + 1]; + ent->disposeComponentId[j] = ent->disposeComponentId[j + 1]; + ent->disposeUser[j] = ent->disposeUser[j + 1]; + } + return; + } +} diff --git a/src/dusk/entity/entity.h b/src/dusk/entity/entity.h new file mode 100644 index 00000000..a1b02f82 --- /dev/null +++ b/src/dusk/entity/entity.h @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "component.h" + +#define ENTITY_STATE_ACTIVE (1 << 0) + +#define ENTITY_UPDATE_CALLBACK_COUNT_MAX 5 +#define ENTITY_DISPOSE_CALLBACK_COUNT_MAX 5 + +typedef void (*entitycallback_t)( + const entityid_t entityId, + const componentid_t componentId, + void *user +); + +typedef struct { + uint8_t state; + uint8_t updateCount; + uint8_t disposeCount; + entitycallback_t onUpdate[ENTITY_UPDATE_CALLBACK_COUNT_MAX]; + componentid_t updateComponentId[ENTITY_UPDATE_CALLBACK_COUNT_MAX]; + void *updateUser[ENTITY_UPDATE_CALLBACK_COUNT_MAX]; + entitycallback_t onDispose[ENTITY_DISPOSE_CALLBACK_COUNT_MAX]; + componentid_t disposeComponentId[ENTITY_DISPOSE_CALLBACK_COUNT_MAX]; + void *disposeUser[ENTITY_DISPOSE_CALLBACK_COUNT_MAX]; +} entity_t; + +/** + * Initializes an entity with the given ID. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The ID of the entity to initialize. + */ +void entityInit(entitymanager_t *mgr, const entityid_t entityId); + +/** + * Adds a component of the given type to the entity with the given ID. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The ID of the entity to add the component to. + * @param type The type of the component to add. + * @return The ID of the entity with component. + */ +componentid_t entityAddComponent( + entitymanager_t *mgr, + const entityid_t entityId, + const componenttype_t type +); + +/** + * Gets the ID of the component of the given type on the entity with the + * given ID, or COMPONENT_ID_INVALID if the entity lacks the component. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The ID of the entity to get the component from. + * @param type The type of the component to get. + * @return The ID of the component. + */ +componentid_t entityGetComponent( + entitymanager_t *mgr, + const entityid_t entityId, + const componenttype_t type +); + +/** + * Runs all registered update callbacks for the entity. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The ID of the entity to update. + */ +void entityUpdate(entitymanager_t *mgr, const entityid_t entityId); + +/** + * Disposes of an entity with the given ID. Fires all dispose callbacks + * before cleaning up components and state. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The ID of the entity to dispose of. + */ +void entityDispose(entitymanager_t *mgr, const entityid_t entityId); + +/** + * Disposes of an entity and all of its position-component descendants + * recursively. If the entity has no position component, behaves like + * entityDispose. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The root entity ID. + */ +void entityDisposeDeep(entitymanager_t *mgr, const entityid_t entityId); + +/** + * Registers an update callback, invoked each time entityUpdate is called. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to register on. + * @param callback The function to call. + * @param componentId The component ID passed back to the callback. + * @param user Userdata passed back to the callback. + */ +void entityUpdateAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback, + const componentid_t componentId, + void *user +); + +/** + * Removes a previously registered update callback. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to remove from. + * @param callback The function to remove. + */ +void entityUpdateRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback +); + +/** + * Registers a dispose callback, invoked at the start of entityDispose + * before any component or state cleanup. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to register on. + * @param callback The function to call. + * @param componentId The component ID passed back to the callback. + * @param user Userdata passed back to the callback. + */ +void entityDisposeAdd( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback, + const componentid_t componentId, + void *user +); + +/** + * Removes a previously registered dispose callback. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The entity to remove from. + * @param callback The function to remove. + */ +void entityDisposeRemove( + entitymanager_t *mgr, + const entityid_t entityId, + const entitycallback_t callback +); diff --git a/src/dusk/entity/entitybase.h b/src/dusk/entity/entitybase.h new file mode 100644 index 00000000..649c647c --- /dev/null +++ b/src/dusk/entity/entitybase.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 "dusk.h" + +#define ENTITY_COUNT_MAX 64 +#define ENTITY_COMPONENT_COUNT_MAX 16 + +#define ENTITY_ID_INVALID 0xFF +#define COMPONENT_ID_INVALID 0xFF + +typedef uint8_t entityid_t; +typedef uint8_t componentid_t; +typedef uint16_t componentindex_t; + +// Forward declared here (rather than in component.h) so every entity/ +// component header can reference entitymanager_t* regardless of include +// order, without needing a full entitymanager.h include. +typedef struct entitymanager_t entitymanager_t; diff --git a/src/dusk/entity/entitymanager.c b/src/dusk/entity/entitymanager.c new file mode 100644 index 00000000..c661d338 --- /dev/null +++ b/src/dusk/entity/entitymanager.c @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "entitymanager.h" +#include "assert/assert.h" +#include "util/memory.h" +#include "console/console.h" + +void entityManagerInit(entitymanager_t *mgr) { + assertNotNull(mgr, "Entity manager cannot be null"); + memoryZero(mgr, sizeof(entitymanager_t)); + memorySet( + mgr->entitiesWithComponent, COMPONENT_ID_INVALID, + sizeof(componentid_t) * COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX + ); + + consolePrint( + "Entity manager size: %zu bytes (%.2f KB)", + sizeof(entitymanager_t), + sizeof(entitymanager_t) / 1024.0f + ); +} + +entityid_t entityManagerAdd(entitymanager_t *mgr) { + assertNotNull(mgr, "Entity manager cannot be null"); + for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) { + if((mgr->entities[i].state & ENTITY_STATE_ACTIVE) != 0) continue; + entityInit(mgr, i); + return i; + } + assertUnreachable("No more entity IDs available"); + return ENTITY_ID_INVALID; +} + +void entityManagerUpdate(entitymanager_t *mgr) { + assertNotNull(mgr, "Entity manager cannot be null"); + entityid_t i = 0; + while(i < ENTITY_COUNT_MAX) { + if((mgr->entities[i].state & ENTITY_STATE_ACTIVE) != 0) { + entityUpdate(mgr, i); + } + i++; + } +} + +void entityManagerDispose(entitymanager_t *mgr) { + assertNotNull(mgr, "Entity manager cannot be null"); + for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) { + if((mgr->entities[i].state & ENTITY_STATE_ACTIVE) == 0) continue; + entityDispose(mgr, i); + } +} diff --git a/src/dusk/entity/entitymanager.h b/src/dusk/entity/entitymanager.h new file mode 100644 index 00000000..7872a0b9 --- /dev/null +++ b/src/dusk/entity/entitymanager.h @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "entity.h" + +typedef struct entitymanager_t { + entity_t entities[ENTITY_COUNT_MAX]; + component_t components[ENTITY_COUNT_MAX * ENTITY_COMPONENT_COUNT_MAX]; + componentid_t entitiesWithComponent[COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX]; +} entitymanager_t; + +/** + * Initializes an entity manager, marking all entities and components as + * unused. + * + * @param mgr The entity manager to initialize. + */ +void entityManagerInit(entitymanager_t *mgr); + +/** + * Adds / Reserves a new entity ID in the given manager. + * + * @param mgr The entity manager to allocate the entity in. + * @return The new entity ID. + */ +entityid_t entityManagerAdd(entitymanager_t *mgr); + +/** + * Updates all active entities in the given manager. + * + * @param mgr The entity manager to update. + */ +void entityManagerUpdate(entitymanager_t *mgr); + +/** + * Disposes of the entity manager, in turn freeing all entities and + * components. + * + * @param mgr The entity manager to dispose. + */ +void entityManagerDispose(entitymanager_t *mgr); diff --git a/src/dusk/scene/scene.c b/src/dusk/scene/scene.c index 24d02416..2f59e7f8 100644 --- a/src/dusk/scene/scene.c +++ b/src/dusk/scene/scene.c @@ -10,26 +10,165 @@ #include "display/screen/screen.h" #include "display/shader/shaderunlit.h" #include "display/display.h" +#include "entity/component/display/entitycamera.h" +#include "entity/component/display/entityposition.h" +#include "entity/component/display/entityrenderable.h" #include "ui/ui.h" -#include "asset/asset.h" -#include "asset/loader/assetloader.h" #include "console/console.h" -scene_t SCENE; +scenemanager_t SCENE_MANAGER; errorret_t sceneInit(void) { - memoryZero(&SCENE, sizeof(scene_t)); + memoryZero(&SCENE_MANAGER, sizeof(scenemanager_t)); + SCENE_MANAGER.active = SCENE_ID_INVALID; errorOk(); } +sceneid_t sceneCreate(void) { + for(sceneid_t i = 0; i < SCENE_COUNT_MAX; i++) { + if(SCENE_MANAGER.scenes[i].used) continue; + scene_t *scene = &SCENE_MANAGER.scenes[i]; + entityManagerInit(&scene->entities); + scene->used = true; + return i; + } + assertUnreachable("No more scene IDs available"); + return SCENE_ID_INVALID; +} + +void sceneDestroy(const sceneid_t id) { + assertTrue(id < SCENE_COUNT_MAX, "Scene ID OOB"); + scene_t *scene = &SCENE_MANAGER.scenes[id]; + if(!scene->used) return; + + entityManagerDispose(&scene->entities); + scene->used = false; + + if(SCENE_MANAGER.active == id) SCENE_MANAGER.active = SCENE_ID_INVALID; +} + +void sceneSetActive(const sceneid_t id) { + assertTrue( + id == SCENE_ID_INVALID || id < SCENE_COUNT_MAX, "Scene ID OOB" + ); + SCENE_MANAGER.active = id; +} + +sceneid_t sceneGetActive(void) { + return SCENE_MANAGER.active; +} + +entitymanager_t *sceneGetEntities(const sceneid_t id) { + assertTrue(id < SCENE_COUNT_MAX, "Scene ID OOB"); + assertTrue(SCENE_MANAGER.scenes[id].used, "Scene is not in use"); + return &SCENE_MANAGER.scenes[id].entities; +} + errorret_t sceneUpdate(void) { + if(SCENE_MANAGER.active == SCENE_ID_INVALID) errorOk(); + + #if DUSK_TIME_DYNAMIC + if(!TIME.dynamicUpdate) errorOk(); + #endif + + entityManagerUpdate(sceneGetEntities(SCENE_MANAGER.active)); errorOk(); } errorret_t sceneRender(void) { + if(SCENE_MANAGER.active != SCENE_ID_INVALID) { + entitymanager_t *mgr = sceneGetEntities(SCENE_MANAGER.active); + entityid_t camEntity = entityCameraGetCurrent(mgr); + + if(camEntity != ENTITY_ID_INVALID) { + componentid_t camComp = entityGetComponent( + mgr, camEntity, COMPONENT_TYPE_CAMERA + ); + componentid_t camPosComp = entityGetComponent( + mgr, camEntity, COMPONENT_TYPE_POSITION + ); + + mat4 proj; + entityCameraGetProjection(mgr, camEntity, camComp, proj); + + mat4 view; + glm_mat4_identity(view); + if(camPosComp != COMPONENT_ID_INVALID) { + mat4 camTransform; + entityPositionGetTransform(mgr, camEntity, camPosComp, camTransform); + // Position components always store world-space placement (eye -> + // world), regardless of how they were set (SetPosition/Rotation or + // LookAt) -- invert to get the view matrix (world -> eye). + glm_mat4_inv(camTransform, view); + } + + errorChain(shaderBind(&SHADER_UNLIT)); + errorChain(shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_PROJECTION, proj)); + errorChain(shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_VIEW, view)); + + entityid_t renderEntities[ENTITY_COUNT_MAX]; + componentid_t renderComponents[ENTITY_COUNT_MAX]; + entityid_t renderCount = componentGetEntitiesWithComponent( + mgr, COMPONENT_TYPE_RENDERABLE, renderEntities, renderComponents + ); + + for(entityid_t i = 0; i < renderCount; i++) { + mat4 model; + componentid_t posComp = entityGetComponent( + mgr, renderEntities[i], COMPONENT_TYPE_POSITION + ); + if(posComp == COMPONENT_ID_INVALID) { + glm_mat4_identity(model); + } else { + entityPositionGetTransform(mgr, renderEntities[i], posComp, model); + } + errorChain( + shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_MODEL, model) + ); + errorChain(entityRenderableDraw( + mgr, renderEntities[i], renderComponents[i] + )); + } + } + } + + // Screen-space matrices for UI rendering. + mat4 screenIdentity; + mat4 screenProj; + mat4 screenView; + + glm_mat4_identity(screenIdentity); + + glm_ortho( + 0.0f, (float_t)(SCREEN.width / SCREEN.scaleUi), + (float_t)(SCREEN.height / SCREEN.scaleUi), 0.0f, + 0.1f, 100.0f, + screenProj + ); + + glm_lookat( + (vec3){ 0.0f, 0.0f, 1.0f }, + (vec3){ 0.0f, 0.0f, 0.0f }, + (vec3){ 0.0f, 1.0f, 0.0f }, + screenView + ); + + errorChain(shaderBind(&SHADER_UNLIT)); + errorChain(shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_MODEL, screenIdentity)); + errorChain(shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_PROJECTION, screenProj)); + errorChain(shaderSetMatrix(&SHADER_UNLIT, SHADER_UNLIT_VIEW, screenView)); + + errorChain(displaySetState((displaystate_t){ + .flags = DISPLAY_STATE_FLAG_BLEND + })); + errorChain(uiRender()); + errorOk(); } errorret_t sceneDispose(void) { + for(sceneid_t i = 0; i < SCENE_COUNT_MAX; i++) { + sceneDestroy(i); + } errorOk(); } diff --git a/src/dusk/scene/scene.h b/src/dusk/scene/scene.h index 8673bed4..9cfc833a 100644 --- a/src/dusk/scene/scene.h +++ b/src/dusk/scene/scene.h @@ -6,46 +6,88 @@ */ #pragma once -#include "error/error.h" +#include "scenebase.h" +#include "entity/entitymanager.h" typedef struct { - void *nothing; + bool_t used; + entitymanager_t entities; } scene_t; -extern scene_t SCENE; +typedef struct { + scene_t scenes[SCENE_COUNT_MAX]; + sceneid_t active; +} scenemanager_t; + +extern scenemanager_t SCENE_MANAGER; /** - * Initialises the scene manager. - * + * Initialises the scene manager. No scenes are created or active until + * sceneCreate()/sceneSetActive() are called. + * * @return An error if the init failed, or errorOk() if it succeeded. */ errorret_t sceneInit(void); /** - * Ticks the scene manager. Processes any pending scene transition, then - * calls scriptSceneUpdate on the active scene. - * + * Creates a new, empty scene with its own isolated entity/component pool. + * The new scene is not made active automatically. + * + * @return The ID of the new scene. + */ +sceneid_t sceneCreate(void); + +/** + * Destroys a scene, disposing all of its entities/components. If the scene + * was the active scene, no scene is active afterwards. + * + * @param id The ID of the scene to destroy. + */ +void sceneDestroy(const sceneid_t id); + +/** + * Sets which scene is active. Only the active scene is ticked and rendered + * by sceneUpdate()/sceneRender(). Previously active scenes are left running + * in the background (paused, not disposed) until sceneDestroy() is called. + * + * @param id The ID of the scene to make active, or SCENE_ID_INVALID to make + * no scene active. + */ +void sceneSetActive(const sceneid_t id); + +/** + * Gets the ID of the currently active scene. + * + * @return The active scene ID, or SCENE_ID_INVALID if none is active. + */ +sceneid_t sceneGetActive(void); + +/** + * Gets the entity manager owned by a given scene, for spawning/querying + * entities scoped to that scene. + * + * @param id The ID of the scene. + * @return Pointer to the scene's entity manager. + */ +entitymanager_t *sceneGetEntities(const sceneid_t id); + +/** + * Ticks the active scene's entities. + * * @return An error if the update failed, or errorOk() if it succeeded. */ errorret_t sceneUpdate(void); /** - * Renders the current scene (entities, render pipeline, UI). - * + * Renders the active scene (entities, render pipeline, UI). + * * @return An error if the render failed, or errorOk() if it succeeded. */ errorret_t sceneRender(void); /** - * Requests the next frame to change to this scene. - * - * @param type The scene type to change to. - */ -// void sceneSet(const scenetype_t type); - -/** - * Disposes the active scene immediately. - * + * Destroys every created scene. + * * @return An error if the dispose failed, or errorOk() if it succeeded. */ errorret_t sceneDispose(void); diff --git a/src/dusk/scene/scenebase.h b/src/dusk/scene/scenebase.h new file mode 100644 index 00000000..802d3f5c --- /dev/null +++ b/src/dusk/scene/scenebase.h @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "dusk.h" + +#define SCENE_COUNT_MAX 4 +#define SCENE_ID_INVALID 0xFF + +typedef uint8_t sceneid_t; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d49c71cc..c631f99a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -9,7 +9,8 @@ add_subdirectory(error) add_subdirectory(network) add_subdirectory(thread) add_subdirectory(display) -add_subdirectory(rpg) +add_subdirectory(entity) +add_subdirectory(scene) # add_subdirectory(item) add_subdirectory(time) add_subdirectory(util) \ No newline at end of file diff --git a/test/rpg/overworld/CMakeLists.txt b/test/entity/CMakeLists.txt similarity index 71% rename from test/rpg/overworld/CMakeLists.txt rename to test/entity/CMakeLists.txt index 5449d2e4..3389c384 100644 --- a/test/rpg/overworld/CMakeLists.txt +++ b/test/entity/CMakeLists.txt @@ -6,7 +6,5 @@ include(dusktest) # Tests -dusktest(test_maparea.c) -dusktest(test_tileshape.c) - -# Subdirs \ No newline at end of file +dusktest(test_entitymanager.c) +dusktest(test_entityposition.c) diff --git a/test/entity/test_entitymanager.c b/test/entity/test_entitymanager.c new file mode 100644 index 00000000..3317ea40 --- /dev/null +++ b/test/entity/test_entitymanager.c @@ -0,0 +1,97 @@ +/** + * 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" + +static void test_entityManagerAddIsolatesEntities(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t a = entityManagerAdd(&mgr); + entityid_t b = entityManagerAdd(&mgr); + + assert_true(a != ENTITY_ID_INVALID); + assert_true(b != ENTITY_ID_INVALID); + assert_true(a != b); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityManagerFullAsserts(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) { + assert_true(entityManagerAdd(&mgr) != ENTITY_ID_INVALID); + } + + expect_assert_failure(entityManagerAdd(&mgr)); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityAddGetDisposeComponent(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + assert_int_equal( + entityGetComponent(&mgr, entity, COMPONENT_TYPE_POSITION), + COMPONENT_ID_INVALID + ); + + componentid_t posComp = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_POSITION + ); + assert_true(posComp != COMPONENT_ID_INVALID); + assert_int_equal( + entityGetComponent(&mgr, entity, COMPONENT_TYPE_POSITION), posComp + ); + + entityposition_t *pos = entityPositionGet(&mgr, entity, posComp); + assert_non_null(pos); + + entityDispose(&mgr, entity); + assert_int_equal( + entityGetComponent(&mgr, entity, COMPONENT_TYPE_POSITION), + COMPONENT_ID_INVALID + ); + assert_true((mgr.entities[entity].state & ENTITY_STATE_ACTIVE) == 0); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityAddDuplicateComponentAsserts(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + entityAddComponent(&mgr, entity, COMPONENT_TYPE_POSITION); + expect_assert_failure( + entityAddComponent(&mgr, entity, COMPONENT_TYPE_POSITION) + ); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_entityManagerAddIsolatesEntities), + cmocka_unit_test(test_entityManagerFullAsserts), + cmocka_unit_test(test_entityAddGetDisposeComponent), + cmocka_unit_test(test_entityAddDuplicateComponentAsserts), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/entity/test_entityposition.c b/test/entity/test_entityposition.c new file mode 100644 index 00000000..598b0784 --- /dev/null +++ b/test/entity/test_entityposition.c @@ -0,0 +1,130 @@ +/** + * 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" + +static void test_entityPositionLocalGetSet(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t entity = entityManagerAdd(&mgr); + componentid_t comp = entityAddComponent( + &mgr, entity, COMPONENT_TYPE_POSITION + ); + + vec3 setPos = { 1.0f, 2.0f, 3.0f }; + entityPositionSetLocalPosition(&mgr, entity, comp, setPos); + + vec3 getPos; + entityPositionGetLocalPosition(&mgr, entity, comp, getPos); + assert_float_equal(getPos[0], 1.0f, 0.0001f); + assert_float_equal(getPos[1], 2.0f, 0.0001f); + assert_float_equal(getPos[2], 3.0f, 0.0001f); + + // World position of a parentless entity matches local position. + vec3 worldPos; + entityPositionGetWorldPosition(&mgr, entity, comp, worldPos); + assert_float_equal(worldPos[0], 1.0f, 0.0001f); + assert_float_equal(worldPos[1], 2.0f, 0.0001f); + assert_float_equal(worldPos[2], 3.0f, 0.0001f); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityPositionParentChildWorldTransform(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t parent = entityManagerAdd(&mgr); + componentid_t parentComp = entityAddComponent( + &mgr, parent, COMPONENT_TYPE_POSITION + ); + + entityid_t child = entityManagerAdd(&mgr); + componentid_t childComp = entityAddComponent( + &mgr, child, COMPONENT_TYPE_POSITION + ); + + vec3 parentPos = { 10.0f, 0.0f, 0.0f }; + entityPositionSetLocalPosition(&mgr, parent, parentComp, parentPos); + + vec3 childLocalPos = { 1.0f, 0.0f, 0.0f }; + entityPositionSetLocalPosition(&mgr, child, childComp, childLocalPos); + + entityPositionSetParent(&mgr, child, childComp, parent, parentComp); + + vec3 childWorldPos; + entityPositionGetWorldPosition(&mgr, child, childComp, childWorldPos); + assert_float_equal(childWorldPos[0], 11.0f, 0.0001f); + assert_float_equal(childWorldPos[1], 0.0f, 0.0001f); + assert_float_equal(childWorldPos[2], 0.0f, 0.0001f); + + // Moving the parent should lazily update the child's world position too. + vec3 parentPosMoved = { 20.0f, 5.0f, 0.0f }; + entityPositionSetLocalPosition(&mgr, parent, parentComp, parentPosMoved); + entityPositionGetWorldPosition(&mgr, child, childComp, childWorldPos); + assert_float_equal(childWorldPos[0], 21.0f, 0.0001f); + assert_float_equal(childWorldPos[1], 5.0f, 0.0001f); + assert_float_equal(childWorldPos[2], 0.0f, 0.0001f); + + // Detaching does not preserve world position -- the child's local + // position (never touched since entityPositionSetLocalPosition above) is + // now its world position too, since it no longer has a parent. + entityPositionSetParent( + &mgr, child, childComp, ENTITY_ID_INVALID, COMPONENT_ID_INVALID + ); + vec3 parentPosMovedAgain = { 100.0f, 100.0f, 100.0f }; + entityPositionSetLocalPosition( + &mgr, parent, parentComp, parentPosMovedAgain + ); + entityPositionGetWorldPosition(&mgr, child, childComp, childWorldPos); + assert_float_equal(childWorldPos[0], childLocalPos[0], 0.0001f); + assert_float_equal(childWorldPos[1], childLocalPos[1], 0.0001f); + assert_float_equal(childWorldPos[2], childLocalPos[2], 0.0001f); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_entityPositionDisposeDeep(void **state) { + entitymanager_t mgr; + entityManagerInit(&mgr); + + entityid_t parent = entityManagerAdd(&mgr); + componentid_t parentComp = entityAddComponent( + &mgr, parent, COMPONENT_TYPE_POSITION + ); + + entityid_t child = entityManagerAdd(&mgr); + componentid_t childComp = entityAddComponent( + &mgr, child, COMPONENT_TYPE_POSITION + ); + + entityPositionSetParent(&mgr, child, childComp, parent, parentComp); + + entityPositionDisposeDeep(&mgr, parent, parentComp); + + assert_true((mgr.entities[parent].state & ENTITY_STATE_ACTIVE) == 0); + assert_true((mgr.entities[child].state & ENTITY_STATE_ACTIVE) == 0); + + entityManagerDispose(&mgr); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_entityPositionLocalGetSet), + cmocka_unit_test(test_entityPositionParentChildWorldTransform), + cmocka_unit_test(test_entityPositionDisposeDeep), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/rpg/CMakeLists.txt b/test/rpg/CMakeLists.txt deleted file mode 100644 index d4d1190c..00000000 --- a/test/rpg/CMakeLists.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) 2026 Dominic Masters -# -# This software is released under the MIT License. -# https://opensource.org/licenses/MIT - -include(dusktest) - -# Tests -dusktest(test_rpg.c) - -# Subdirs -add_subdirectory(overworld) -add_subdirectory(physics) -add_subdirectory(entity) \ No newline at end of file diff --git a/test/rpg/entity/test_entity.c b/test/rpg/entity/test_entity.c deleted file mode 100644 index 65482220..00000000 --- a/test/rpg/entity/test_entity.c +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 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 "rpg/entity/entity.h" - -static void testEntitiesReset(void) { - memoryZero(ENTITIES, sizeof(ENTITIES)); -} - -static void testEntityPlace( - const uint8_t index, - const entitytype_t type, - const vec3 position, - const entitydir_t direction -) { - entityInit(&ENTITIES[index], type); - ENTITIES[index].direction = direction; - const vec3 extents = ENTITY_PHYSICS_EXTENTS_DEFAULT; - physicsBodyInit(&ENTITIES[index].body, position, extents); -} - -static void test_entityGetFacingFindsEntityAhead(void **state) { - testEntitiesReset(); - testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, - ENTITY_DIR_EAST); - testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ 1.0f, 0.0f, 0.0f }, - ENTITY_DIR_NORTH); - - entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); - assert_ptr_equal(result, &ENTITIES[1]); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_entityGetFacingIgnoresEntityBehind(void **state) { - testEntitiesReset(); - testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, - ENTITY_DIR_EAST); - testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ -2.0f, 0.0f, 0.0f }, - ENTITY_DIR_NORTH); - - entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); - assert_null(result); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_entityGetFacingIgnoresEntityToSide(void **state) { - testEntitiesReset(); - testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, - ENTITY_DIR_EAST); - testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ 0.0f, 3.0f, 0.0f }, - ENTITY_DIR_NORTH); - - entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); - assert_null(result); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_entityGetFacingReturnsNullWhenNothingInRange(void **state) { - testEntitiesReset(); - testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, - ENTITY_DIR_EAST); - - entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); - assert_null(result); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_entityGetFacingPicksNearest(void **state) { - testEntitiesReset(); - testEntityPlace(0, ENTITY_TYPE_PLAYER, (vec3){ 0.0f, 0.0f, 0.0f }, - ENTITY_DIR_EAST); - // Farther candidate placed at the lower array index, to prove the - // result is chosen by distance, not array/insertion order. - testEntityPlace(1, ENTITY_TYPE_NPC, (vec3){ 1.5f, 0.0f, 0.0f }, - ENTITY_DIR_NORTH); - testEntityPlace(2, ENTITY_TYPE_NPC, (vec3){ 1.0f, 0.0f, 0.0f }, - ENTITY_DIR_NORTH); - - entity_t *result = entityGetFacing(&ENTITIES[0], ENTITY_INTERACT_RANGE); - assert_ptr_equal(result, &ENTITIES[2]); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -int main(void) { - const struct CMUnitTest tests[] = { - cmocka_unit_test(test_entityGetFacingFindsEntityAhead), - cmocka_unit_test(test_entityGetFacingIgnoresEntityBehind), - cmocka_unit_test(test_entityGetFacingIgnoresEntityToSide), - cmocka_unit_test(test_entityGetFacingReturnsNullWhenNothingInRange), - cmocka_unit_test(test_entityGetFacingPicksNearest), - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} diff --git a/test/rpg/entity/test_entitydir.c b/test/rpg/entity/test_entitydir.c deleted file mode 100644 index 3e2dba88..00000000 --- a/test/rpg/entity/test_entitydir.c +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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 "rpg/entity/entitydir.h" - -static void test_entityDirToVec2(void **state) { - vec2 out; - - entityDirToVec2(ENTITY_DIR_NORTH, out); - assert_float_equal(out[0], 0.0f, 0.0001f); - assert_float_equal(out[1], 1.0f, 0.0001f); - - entityDirToVec2(ENTITY_DIR_EAST, out); - assert_float_equal(out[0], 1.0f, 0.0001f); - assert_float_equal(out[1], 0.0f, 0.0001f); - - entityDirToVec2(ENTITY_DIR_SOUTH, out); - assert_float_equal(out[0], 0.0f, 0.0001f); - assert_float_equal(out[1], -1.0f, 0.0001f); - - entityDirToVec2(ENTITY_DIR_WEST, out); - assert_float_equal(out[0], -1.0f, 0.0001f); - assert_float_equal(out[1], 0.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_entityDirFromVec2Cardinal(void **state) { - assert_int_equal( - entityDirFromVec2((vec2){ 0.0f, 1.0f }), ENTITY_DIR_NORTH - ); - assert_int_equal( - entityDirFromVec2((vec2){ 1.0f, 0.0f }), ENTITY_DIR_EAST - ); - assert_int_equal( - entityDirFromVec2((vec2){ 0.0f, -1.0f }), ENTITY_DIR_SOUTH - ); - assert_int_equal( - entityDirFromVec2((vec2){ -1.0f, 0.0f }), ENTITY_DIR_WEST - ); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_entityDirFromVec2DominantAxis(void **state) { - // |x| > |y| picks east/west regardless of y's sign. - assert_int_equal( - entityDirFromVec2((vec2){ 2.0f, 1.0f }), ENTITY_DIR_EAST - ); - assert_int_equal( - entityDirFromVec2((vec2){ -2.0f, -1.0f }), ENTITY_DIR_WEST - ); - - // |y| >= |x| picks north/south. - assert_int_equal( - entityDirFromVec2((vec2){ 1.0f, 2.0f }), ENTITY_DIR_NORTH - ); - assert_int_equal( - entityDirFromVec2((vec2){ -1.0f, -3.0f }), ENTITY_DIR_SOUTH - ); - - // Exact diagonal ties break toward north/south. - assert_int_equal( - entityDirFromVec2((vec2){ 0.7f, 0.7f }), ENTITY_DIR_NORTH - ); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -int main(void) { - const struct CMUnitTest tests[] = { - cmocka_unit_test(test_entityDirToVec2), - cmocka_unit_test(test_entityDirFromVec2Cardinal), - cmocka_unit_test(test_entityDirFromVec2DominantAxis), - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} diff --git a/test/rpg/overworld/test_maparea.c b/test/rpg/overworld/test_maparea.c deleted file mode 100644 index 4e22f963..00000000 --- a/test/rpg/overworld/test_maparea.c +++ /dev/null @@ -1,126 +0,0 @@ -/** - * 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 "rpg/overworld/maparea.h" - -static uint32_t TEST_ENTER_COUNT; -static uint32_t TEST_STEP_COUNT; -static uint32_t TEST_EXIT_COUNT; - -static void testAreaCallback(entity_t *entity, const uint8_t trigger) { - if(trigger == MAP_TRIGGER_ENTER) TEST_ENTER_COUNT++; - else if(trigger == MAP_TRIGGER_STEP) TEST_STEP_COUNT++; - else if(trigger == MAP_TRIGGER_EXIT) TEST_EXIT_COUNT++; -} - -static void testMapAreaReset(void) { - memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); - TEST_ENTER_COUNT = 0; - TEST_STEP_COUNT = 0; - TEST_EXIT_COUNT = 0; -} - -static entity_t testEntityAt(const entitytype_t type, const worldpos_t pos) { - entity_t entity; - memoryZero(&entity, sizeof(entity)); - entity.id = 0; - entity.type = type; - entity.position = pos; - return entity; -} - -static void test_mapAreaCheckEntityFiresEnterAndExit(void **state) { - testMapAreaReset(); - mapAreaAdd( - (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 }, - testAreaCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL - ); - - entity_t entity = testEntityAt(ENTITY_TYPE_PLAYER, (worldpos_t){ 10, 10, 0 }); - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_ENTER_COUNT, 0); - - entity.position = (worldpos_t){ 2, 2, 0 }; - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_ENTER_COUNT, 1); - assert_int_equal(TEST_STEP_COUNT, 0); - - entity.position = (worldpos_t){ 10, 10, 0 }; - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_EXIT_COUNT, 1); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_mapAreaCheckEntityStepFiresOncePerNewTile(void **state) { - testMapAreaReset(); - mapAreaAdd( - (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 }, - testAreaCallback, MAP_AREA_NOTIFY_ALL, MAP_TRIGGER_ALL - ); - - entity_t entity = testEntityAt(ENTITY_TYPE_PLAYER, (worldpos_t){ 1, 1, 0 }); - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_ENTER_COUNT, 1); - assert_int_equal(TEST_STEP_COUNT, 0); - - // Same tile, checked repeatedly (simulating multiple frames without the - // entity moving) - STEP must not fire. - mapAreaCheckEntity(&entity); - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_STEP_COUNT, 0); - - // Moves to a new tile, still inside - STEP fires once. - entity.position = (worldpos_t){ 2, 1, 0 }; - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_STEP_COUNT, 1); - - // Same new tile again over multiple calls - no additional STEP. - mapAreaCheckEntity(&entity); - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_STEP_COUNT, 1); - - // Another new tile - STEP fires again. - entity.position = (worldpos_t){ 3, 1, 0 }; - mapAreaCheckEntity(&entity); - assert_int_equal(TEST_STEP_COUNT, 2); - - assert_int_equal(TEST_ENTER_COUNT, 1); - assert_int_equal(TEST_EXIT_COUNT, 0); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_mapAreaCheckEntityNotifyFlagsRespected(void **state) { - testMapAreaReset(); - mapAreaAdd( - (worldpos_t){ 0, 0, 0 }, (worldpos_t){ 5, 5, 0 }, - testAreaCallback, MAP_AREA_NOTIFY_PLAYER, MAP_TRIGGER_ALL - ); - - entity_t npc = testEntityAt(ENTITY_TYPE_NPC, (worldpos_t){ 1, 1, 0 }); - mapAreaCheckEntity(&npc); - assert_int_equal(TEST_ENTER_COUNT, 0); - - entity_t player = testEntityAt(ENTITY_TYPE_PLAYER, (worldpos_t){ 1, 1, 0 }); - mapAreaCheckEntity(&player); - assert_int_equal(TEST_ENTER_COUNT, 1); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -int main(void) { - const struct CMUnitTest tests[] = { - cmocka_unit_test(test_mapAreaCheckEntityFiresEnterAndExit), - cmocka_unit_test(test_mapAreaCheckEntityStepFiresOncePerNewTile), - cmocka_unit_test(test_mapAreaCheckEntityNotifyFlagsRespected), - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} diff --git a/test/rpg/overworld/test_tileshape.c b/test/rpg/overworld/test_tileshape.c deleted file mode 100644 index 8eb92967..00000000 --- a/test/rpg/overworld/test_tileshape.c +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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 "rpg/overworld/tileshape.h" - -static void test_tileShapeGetRampHeightGroundIsAlwaysFlat(void **state) { - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_GROUND, 0.0f, 0.0f), 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_GROUND, 1.0f, 1.0f), 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_GROUND, 0.5f, 0.5f), 0.0f, 0.0001f - ); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_tileShapeGetRampHeightCardinalRamps(void **state) { - // RAMP_NORTH: rises going north (+y) - height equals localY. - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTH, 0.5f, 0.0f), 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTH, 0.5f, 1.0f), 1.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTH, 0.0f, 0.5f), 0.5f, 0.0001f - ); - - // RAMP_SOUTH: rises going south (-y) - height equals 1 - localY. - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTH, 0.5f, 0.0f), 1.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTH, 0.5f, 1.0f), 0.0f, 0.0001f - ); - - // RAMP_EAST: rises going east (+x) - height equals localX. - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_EAST, 0.0f, 0.5f), 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_EAST, 1.0f, 0.5f), 1.0f, 0.0001f - ); - - // RAMP_WEST: rises going west (-x) - height equals 1 - localX. - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_WEST, 0.0f, 0.5f), 1.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_WEST, 1.0f, 0.5f), 0.0f, 0.0001f - ); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_tileShapeGetRampHeightOuterCornerRamps(void **state) { - // RAMP_NORTHEAST: only the NE corner raised - a hip shape, height - // equal to min(localX, localY) since the mesh is split along the - // SW-NE diagonal into two flat triangles. - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 0.0f, 0.0f), - 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 1.0f, 1.0f), - 1.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 0.3f, 0.7f), - 0.3f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST, 0.7f, 0.3f), - 0.3f, 0.0001f - ); - - // RAMP_SOUTHWEST: only the SW corner raised - height equal to - // min(1 - localX, 1 - localY). - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTHWEST, 0.0f, 0.0f), - 1.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTHWEST, 1.0f, 1.0f), - 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_SOUTHWEST, 1.0f, 0.0f), - 0.0f, 0.0001f - ); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_tileShapeGetRampHeightInnerCornerRamp(void **state) { - // RAMP_NORTHEAST_INNER: only the opposite (SW) corner lowered, rest of - // the tile raised - height equal to max(localX, localY). - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 0.0f, 0.0f), - 0.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 1.0f, 1.0f), - 1.0f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 0.3f, 0.7f), - 0.7f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 0.7f, 0.3f), - 0.7f, 0.0001f - ); - assert_float_equal( - tileShapeGetRampHeight(TILE_SHAPE_RAMP_NORTHEAST_INNER, 1.0f, 0.0f), - 1.0f, 0.0001f - ); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -int main(void) { - const struct CMUnitTest tests[] = { - cmocka_unit_test(test_tileShapeGetRampHeightGroundIsAlwaysFlat), - cmocka_unit_test(test_tileShapeGetRampHeightCardinalRamps), - cmocka_unit_test(test_tileShapeGetRampHeightOuterCornerRamps), - cmocka_unit_test(test_tileShapeGetRampHeightInnerCornerRamp), - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} diff --git a/test/rpg/physics/test_physicsbody.c b/test/rpg/physics/test_physicsbody.c deleted file mode 100644 index eecd6694..00000000 --- a/test/rpg/physics/test_physicsbody.c +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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 "rpg/physics/physicsbody.h" - -static void test_physicsBodyInit(void **state) { - physicsbody_t body; - const vec3 position = { 1.0f, 2.0f, 3.0f }; - const vec3 extents = { 1.0f, 2.0f, 3.0f }; - physicsBodyInit(&body, position, extents); - - assert_float_equal(body.position[0], 1.0f, 0.0001f); - assert_float_equal(body.position[1], 2.0f, 0.0001f); - assert_float_equal(body.position[2], 3.0f, 0.0001f); - assert_float_equal(body.extents[0], 1.0f, 0.0001f); - assert_float_equal(body.extents[1], 2.0f, 0.0001f); - assert_float_equal(body.extents[2], 3.0f, 0.0001f); - assert_float_equal(body.velocity[0], 0.0f, 0.0001f); - assert_float_equal(body.velocity[1], 0.0f, 0.0001f); - assert_float_equal(body.velocity[2], 0.0f, 0.0001f); - assert_false(body.grounded); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsBodyGetBounds(void **state) { - physicsbody_t body; - const vec3 position = { 2.0f, 3.0f, 4.0f }; - const vec3 extents = { 1.0f, 2.0f, 0.5f }; - physicsBodyInit(&body, position, extents); - - vec3 min, max; - physicsBodyGetBounds(&body, min, max); - - assert_float_equal(min[0], 2.0f, 0.0001f); - assert_float_equal(min[1], 3.0f, 0.0001f); - assert_float_equal(min[2], 4.0f, 0.0001f); - assert_float_equal(max[0], 3.0f, 0.0001f); - assert_float_equal(max[1], 5.0f, 0.0001f); - assert_float_equal(max[2], 4.5f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsBodyGetBoundsNegativeCoordinates(void **state) { - physicsbody_t body; - const vec3 position = { -5.0f, -1.5f, -2.0f }; - const vec3 extents = { 2.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - - vec3 min, max; - physicsBodyGetBounds(&body, min, max); - - assert_float_equal(min[0], -5.0f, 0.0001f); - assert_float_equal(min[1], -1.5f, 0.0001f); - assert_float_equal(min[2], -2.0f, 0.0001f); - assert_float_equal(max[0], -3.0f, 0.0001f); - assert_float_equal(max[1], -0.5f, 0.0001f); - assert_float_equal(max[2], -1.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -int main(void) { - const struct CMUnitTest tests[] = { - cmocka_unit_test(test_physicsBodyInit), - cmocka_unit_test(test_physicsBodyGetBounds), - cmocka_unit_test(test_physicsBodyGetBoundsNegativeCoordinates), - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} diff --git a/test/rpg/physics/test_physicsworld.c b/test/rpg/physics/test_physicsworld.c deleted file mode 100644 index 33134c95..00000000 --- a/test/rpg/physics/test_physicsworld.c +++ /dev/null @@ -1,432 +0,0 @@ -/** - * 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 "time/time.h" -#include "rpg/physics/physicsbody.h" -#include "rpg/physics/physicsworld.h" -#include "rpg/overworld/map.h" -#include "rpg/overworld/tile.h" -#include "rpg/overworld/tileshape.h" -#include "rpg/overworld/worldpos.h" - -static void testMapReset(void) { - memoryZero(&MAP, sizeof(map_t)); - MAP.loaded = true; - MAP.chunkPosition = (chunkpos_t){ 0, 0, 0 }; - - // Push every chunk but the first out of the loaded window, so only - // MAP.chunks[0] (positioned at the origin below) resolves through - // mapRebuildChunkOrder - otherwise every chunk would default to - // position (0,0,0) too and collide on the same chunk order slot. - for(chunkindex_t i = 1; i < MAP_CHUNK_COUNT; i++) { - MAP.chunks[i].position = (chunkpos_t){ 100, 100, 100 }; - } - MAP.chunks[0].position = (chunkpos_t){ 0, 0, 0 }; - - mapRebuildChunkOrder(); -} - -static void testMapSetTile( - const worldunit_t x, - const worldunit_t y, - const worldunit_t z, - const tileshape_t shape -) { - const worldpos_t pos = { x, y, z }; - const chunktileindex_t index = worldPosToChunkTileIndex(&pos); - const uint8_t localZ = worldPosToChunkLocalZ(&pos); - MAP.chunks[0].tiles[index] = (tile_t){ .shape = shape, .z = localZ }; -} - -static void test_physicsWorldStepStraightLineNoObstacles(void **state) { - testMapReset(); - for(worldunit_t x = 0; x < 10; x++) { - testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); - } - - physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 0.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[0] = 1.0f; - - const uint32_t steps = 10; - for(uint32_t i = 0; i < steps; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - } - - assert_float_equal(body.position[0], 1.0f * steps * DUSK_TIME_STEP, 0.001f); - assert_float_equal(body.position[1], 0.0f, 0.0001f); - assert_float_equal(body.position[2], 0.0f, 0.0001f); - assert_float_equal(body.velocity[0], 1.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepBlockedHorizontally(void **state) { - testMapReset(); - testMapSetTile(0, 0, 0, TILE_SHAPE_GROUND); - testMapSetTile(1, 0, 0, TILE_SHAPE_GROUND); - testMapSetTile(2, 0, 0, TILE_SHAPE_GROUND); - // x = 3 left as TILE_SHAPE_NULL, acting as a wall. - - physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 0.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[0] = 5.0f; - - for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - } - - assert_float_equal(body.position[0], 2.0f, 0.0001f); - assert_float_equal(body.velocity[0], 0.0f, 0.0001f); - - // Further steps must not push it past the wall. - for(uint32_t i = 0; i < 5; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - assert_float_equal(body.position[0], 2.0f, 0.0001f); - } - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepGravitySettlesOnFloor(void **state) { - testMapReset(); - testMapSetTile(0, 0, 0, TILE_SHAPE_GROUND); - - physicsworld_t world; - physicsWorldInit(&world, 20.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 3.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - - for(uint32_t i = 0; i < 200; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - assert_true(body.position[2] >= -0.0001f); - } - - assert_float_equal(body.position[2], 0.0f, 0.0001f); - assert_float_equal(body.velocity[2], 0.0f, 0.0001f); - assert_true(body.grounded); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepFallsThroughHole(void **state) { - testMapReset(); - // No tiles set anywhere - every column is a hole. - - physicsworld_t world; - physicsWorldInit(&world, 20.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 5.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - - for(uint32_t i = 0; i < 50; i++) { - const float_t previousZ = body.position[2]; - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - assert_true(body.position[2] < previousZ); - assert_false(body.grounded); - } - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepTerminalVelocityClamp(void **state) { - testMapReset(); - // No tiles set anywhere - every column is a hole. - - physicsworld_t world; - physicsWorldInit(&world, 20.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 5.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - - for(uint32_t i = 0; i < 300; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - assert_true(fabsf(body.velocity[2]) <= world.terminalVelocity + 0.0001f); - } - - assert_float_equal(fabsf(body.velocity[2]), world.terminalVelocity, 0.01f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepBlockedByCeiling(void **state) { - testMapReset(); - testMapSetTile(0, 0, 2, TILE_SHAPE_GROUND); - - physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 0.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[2] = 1.0f; - - for(uint32_t i = 0; i < 200; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - } - - assert_float_equal(body.position[2], 1.0f, 0.0001f); - assert_float_equal(body.velocity[2], 0.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepMultiColumnFootprint(void **state) { - testMapReset(); - for(worldunit_t x = 0; x < 6; x++) { - testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); - } - testMapSetTile(0, 1, 0, TILE_SHAPE_GROUND); - testMapSetTile(1, 1, 0, TILE_SHAPE_GROUND); - // x = 2, y = 1 left as TILE_SHAPE_NULL, blocking only the second row - // spanned by the body's 2-unit-deep footprint. - - physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 0.0f }; - const vec3 extents = { 1.0f, 2.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[0] = 5.0f; - - for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - } - - assert_float_equal(body.position[0], 1.0f, 0.0001f); - assert_float_equal(body.velocity[0], 0.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepClimbsRampGoingUp(void **state) { - testMapReset(); - testMapSetTile(0, 0, 0, TILE_SHAPE_GROUND); - testMapSetTile(1, 0, 0, TILE_SHAPE_RAMP_EAST); - for(worldunit_t x = 2; x < 6; x++) { - testMapSetTile(x, 0, 1, TILE_SHAPE_GROUND); - } - - physicsworld_t world; - physicsWorldInit(&world, 20.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 0.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[0] = 1.0f; - - for(uint32_t i = 0; i < 400; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - - // Ground height is driven by the body's center (not its corner - // position), so a 1-wide body barely touching a taller neighbouring - // column isn't yanked onto that column's full height - see - // physicsWorldResolveAxisZ. While the center is over the ramp tile, - // height should track how far across it smoothly, not snap flat. - const float_t centerX = body.position[0] + 0.5f; - if(centerX >= 1.0f && centerX < 2.0f) { - const float_t expected = centerX - 1.0f; - assert_float_equal(body.position[2], expected, 0.005f); - } - } - - // Fully across, resting on the elevated ground one layer up. - assert_float_equal(body.position[0], 5.0f, 0.001f); - assert_float_equal(body.position[2], 1.0f, 0.005f); - assert_true(body.grounded); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepDescendsRampGoingDown(void **state) { - testMapReset(); - testMapSetTile(0, 0, 1, TILE_SHAPE_GROUND); - testMapSetTile(1, 0, 0, TILE_SHAPE_RAMP_WEST); - for(worldunit_t x = 2; x < 6; x++) { - testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); - } - - physicsworld_t world; - physicsWorldInit(&world, 20.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 1.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[0] = 1.0f; - - for(uint32_t i = 0; i < 400; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - - // Never falls through as a hole while crossing the ramp - descent - // may lag slightly behind the ideal slope (gravity closes the gap - // each step, see physicsWorldStep's documented limitation) but must - // stay close to it, never dropping toward the void below. - assert_true(body.position[2] >= -0.2f); - } - - // Fully across, resting on the lower ground. - assert_float_equal(body.position[0], 5.0f, 0.001f); - assert_float_equal(body.position[2], 0.0f, 0.005f); - assert_true(body.grounded); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepFlatGroundUnaffectedByRampLogic( - void **state -) { - testMapReset(); - for(worldunit_t x = 0; x < 6; x++) { - testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); - } - - physicsworld_t world; - physicsWorldInit(&world, 20.0f, 40.0f); - - physicsbody_t body; - const vec3 position = { 0.0f, 0.0f, 0.0f }; - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsBodyInit(&body, position, extents); - body.velocity[0] = 5.0f; - - for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, NULL, 0); - assert_float_equal(body.position[2], 0.0f, 0.0001f); - } - - assert_true(body.position[0] > 1.5f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepBlockedByOtherBody(void **state) { - testMapReset(); - for(worldunit_t x = 0; x < 10; x++) { - testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); - } - - physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); - - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - - physicsbody_t other; - physicsBodyInit(&other, (vec3){ 5.0f, 0.0f, 0.0f }, extents); - - physicsbody_t body; - physicsBodyInit(&body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); - body.velocity[0] = 5.0f; - - physicsbody_t *others[] = { &other }; - - for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, others, 1); - } - - // Other occupies [5,6) - body (1 unit wide) should stop exactly - // touching it, at x=4. - assert_float_equal(body.position[0], 4.0f, 0.0001f); - assert_float_equal(body.velocity[0], 0.0f, 0.0001f); - assert_float_equal(other.position[0], 5.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldStepBlockedByNearestOfMultipleBodies( - void **state -) { - testMapReset(); - for(worldunit_t x = 0; x < 20; x++) { - testMapSetTile(x, 0, 0, TILE_SHAPE_GROUND); - } - - physicsworld_t world; - physicsWorldInit(&world, 0.0f, 40.0f); - - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - - physicsbody_t nearOther; - physicsBodyInit(&nearOther, (vec3){ 5.0f, 0.0f, 0.0f }, extents); - - physicsbody_t farOther; - physicsBodyInit(&farOther, (vec3){ 15.0f, 0.0f, 0.0f }, extents); - - physicsbody_t body; - physicsBodyInit(&body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); - body.velocity[0] = 5.0f; - - physicsbody_t *others[] = { &farOther, &nearOther }; - - for(uint32_t i = 0; i < 60; i++) { - physicsWorldStep(&world, &body, DUSK_TIME_STEP, others, 2); - } - - assert_float_equal(body.position[0], 4.0f, 0.0001f); - assert_float_equal(body.velocity[0], 0.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -static void test_physicsWorldResolveBodyOverlapIgnoresSelfAndNull( - void **state -) { - const vec3 extents = { 1.0f, 1.0f, 1.0f }; - physicsbody_t body; - physicsBodyInit(&body, (vec3){ 0.0f, 0.0f, 0.0f }, extents); - body.velocity[0] = 1.0f; - - physicsbody_t *others[] = { &body, NULL }; - physicsWorldResolveBodyOverlap(&body, 0, others, 2); - - assert_float_equal(body.position[0], 0.0f, 0.0001f); - assert_float_equal(body.velocity[0], 1.0f, 0.0001f); - - assert_int_equal(memoryGetAllocatedCount(), 0); -} - -int main(void) { - const struct CMUnitTest tests[] = { - cmocka_unit_test(test_physicsWorldStepStraightLineNoObstacles), - cmocka_unit_test(test_physicsWorldStepBlockedHorizontally), - cmocka_unit_test(test_physicsWorldStepGravitySettlesOnFloor), - cmocka_unit_test(test_physicsWorldStepFallsThroughHole), - cmocka_unit_test(test_physicsWorldStepTerminalVelocityClamp), - cmocka_unit_test(test_physicsWorldStepBlockedByCeiling), - cmocka_unit_test(test_physicsWorldStepMultiColumnFootprint), - cmocka_unit_test(test_physicsWorldStepClimbsRampGoingUp), - cmocka_unit_test(test_physicsWorldStepDescendsRampGoingDown), - cmocka_unit_test(test_physicsWorldStepFlatGroundUnaffectedByRampLogic), - cmocka_unit_test(test_physicsWorldStepBlockedByOtherBody), - cmocka_unit_test(test_physicsWorldStepBlockedByNearestOfMultipleBodies), - cmocka_unit_test(test_physicsWorldResolveBodyOverlapIgnoresSelfAndNull), - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} diff --git a/test/rpg/test_rpg.c b/test/rpg/test_rpg.c deleted file mode 100644 index 7a5e36d7..00000000 --- a/test/rpg/test_rpg.c +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2026 Dominic Masters - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -#include "dusktest.h" - -int main(int argc, char** argv) { - const struct CMUnitTest tests[] = { - // Add RPG tests here in the future - }; - - return cmocka_run_group_tests(tests, NULL, NULL); -} \ No newline at end of file diff --git a/test/rpg/entity/CMakeLists.txt b/test/scene/CMakeLists.txt similarity index 71% rename from test/rpg/entity/CMakeLists.txt rename to test/scene/CMakeLists.txt index e1c5be20..a51d5a78 100644 --- a/test/rpg/entity/CMakeLists.txt +++ b/test/scene/CMakeLists.txt @@ -6,7 +6,4 @@ include(dusktest) # Tests -dusktest(test_entitydir.c) -dusktest(test_entity.c) - -# Subdirs +dusktest(test_scene.c) diff --git a/test/scene/test_scene.c b/test/scene/test_scene.c new file mode 100644 index 00000000..879f13da --- /dev/null +++ b/test/scene/test_scene.c @@ -0,0 +1,125 @@ +/** + * 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 "time/time.h" +#include "scene/scene.h" +#include "entity/entitymanager.h" + +static void countingUpdateCallback( + const entityid_t entityId, + const componentid_t componentId, + void *user +) { + uint32_t *counter = (uint32_t *)user; + (*counter)++; +} + +static void test_sceneCreateDestroy(void **state) { + sceneInit(); + + sceneid_t id = sceneCreate(); + assert_true(id != SCENE_ID_INVALID); + assert_non_null(sceneGetEntities(id)); + + sceneDestroy(id); + assert_int_equal(sceneGetActive(), SCENE_ID_INVALID); + + sceneDispose(); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_sceneEntitiesAreIsolatedPerScene(void **state) { + sceneInit(); + + sceneid_t sceneA = sceneCreate(); + sceneid_t sceneB = sceneCreate(); + assert_true(sceneA != sceneB); + + entitymanager_t *entitiesA = sceneGetEntities(sceneA); + entitymanager_t *entitiesB = sceneGetEntities(sceneB); + assert_true(entitiesA != entitiesB); + + // Fill scene A completely; scene B must be entirely unaffected. + for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) { + assert_true(entityManagerAdd(entitiesA) != ENTITY_ID_INVALID); + } + expect_assert_failure(entityManagerAdd(entitiesA)); + + entityid_t entityB = entityManagerAdd(entitiesB); + assert_true(entityB != ENTITY_ID_INVALID); + + sceneDispose(); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_sceneUpdateOnlyTicksActiveScene(void **state) { + sceneInit(); + timeInit(); + TIME.dynamicUpdate = true; + + sceneid_t sceneA = sceneCreate(); + sceneid_t sceneB = sceneCreate(); + + uint32_t counterA = 0; + uint32_t counterB = 0; + + entitymanager_t *entitiesA = sceneGetEntities(sceneA); + entityid_t entityA = entityManagerAdd(entitiesA); + entityUpdateAdd( + entitiesA, entityA, countingUpdateCallback, COMPONENT_ID_INVALID, + &counterA + ); + + entitymanager_t *entitiesB = sceneGetEntities(sceneB); + entityid_t entityB = entityManagerAdd(entitiesB); + entityUpdateAdd( + entitiesB, entityB, countingUpdateCallback, COMPONENT_ID_INVALID, + &counterB + ); + + sceneSetActive(sceneA); + sceneUpdate(); + + assert_int_equal(counterA, 1); + assert_int_equal(counterB, 0); + + sceneSetActive(sceneB); + sceneUpdate(); + + assert_int_equal(counterA, 1); + assert_int_equal(counterB, 1); + + sceneDispose(); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_sceneDestroyClearsActive(void **state) { + sceneInit(); + + sceneid_t id = sceneCreate(); + sceneSetActive(id); + assert_int_equal(sceneGetActive(), id); + + sceneDestroy(id); + assert_int_equal(sceneGetActive(), SCENE_ID_INVALID); + + sceneDispose(); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_sceneCreateDestroy), + cmocka_unit_test(test_sceneEntitiesAreIsolatedPerScene), + cmocka_unit_test(test_sceneUpdateOnlyTicksActiveScene), + cmocka_unit_test(test_sceneDestroyClearsActive), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}