Files
dusk/test/rpg/physics/test_physicsworld.c
T

433 lines
13 KiB
C

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