/** * 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); }