From 0bc80d5df3cd3adffbeac55d4ec7245e137a6f94 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Fri, 17 Jul 2026 13:09:58 -0500 Subject: [PATCH] Roadmap and physics --- ROADMAP.md | 55 ++++++ src/dusk/rpg/CMakeLists.txt | 3 +- src/dusk/rpg/physics/CMakeLists.txt | 10 + src/dusk/rpg/physics/physicsbody.c | 36 ++++ src/dusk/rpg/physics/physicsbody.h | 55 ++++++ src/dusk/rpg/physics/physicsworld.c | 247 ++++++++++++++++++++++++ src/dusk/rpg/physics/physicsworld.h | 102 ++++++++++ test/CMakeLists.txt | 2 +- test/rpg/CMakeLists.txt | 3 +- test/rpg/physics/CMakeLists.txt | 12 ++ test/rpg/physics/test_physicsbody.c | 78 ++++++++ test/rpg/physics/test_physicsworld.c | 268 +++++++++++++++++++++++++++ 12 files changed, 868 insertions(+), 3 deletions(-) create mode 100644 ROADMAP.md create mode 100644 src/dusk/rpg/physics/CMakeLists.txt create mode 100644 src/dusk/rpg/physics/physicsbody.c create mode 100644 src/dusk/rpg/physics/physicsbody.h create mode 100644 src/dusk/rpg/physics/physicsworld.c create mode 100644 src/dusk/rpg/physics/physicsworld.h create mode 100644 test/rpg/physics/CMakeLists.txt create mode 100644 test/rpg/physics/test_physicsbody.c create mode 100644 test/rpg/physics/test_physicsworld.c diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..b9dd89fe --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,55 @@ +# Dusk Roadmap + +Tracking upcoming milestones for the engine. + +## Upcoming milestones + +1. Add a very basic physics engine, moving away from the current + tile-based movement. +2. Give entities full freedom of movement (no longer locked to tile + grid positions). +3. Update entity interaction, triggers, chunk management, and other + systems that currently assume tile-based positioning so they work + with the new 3D positioning/movement code. +4. Investigate and fix poor UI rendering performance. Rendering the + console alone tanks framerate despite the existing mesh + optimizations, so there is likely more headroom to find in the + vertex/text rendering path. +5. Create UI elements for displaying status indicators, e.g. network + connection state and save-in-progress. +6. Fully test saving end-to-end on all supported platforms. +7. Remove the tile system from chunks in favor of meshes, with + dynamic hitboxes per chunk loaded in from the chunk file data. +8. Create UI elements for network status: a connecting modal, an + error state, and a connected flag. Retire the test HTTP request + once these are in place. +9. Build the socket server and client implementation, including + handlers for the different packet types. +10. Add a dedicated multiplayer entity type, `clientplayer`, alongside + the existing `npc` and `player` types. Limit to 8 (defined + constant) for now. +11. Send and receive `clientplayer` position over the network. +12. Create a UI menu for creating a server and joining a server. For + now, join IPs are hard-coded (testing against a fixed IP of + 10.0.0.94). +13. Create "handshake" packets. For now, just send the username, + enforced to be under 10 characters long. +14. Server tracks all players' positions and broadcasts them to all + connected clients. +15. Server sends disconnect packets for users who leave. +16. Server assigns each client a UUID; all clients know every other + client's UUID (used to reference them across position updates, + disconnect packets, etc). +17. Server notifies all clients (by UUID) when a user joins, leaves, + or is disconnected, so clients can spawn or remove the + corresponding `clientplayer` entity in the world. + +## Principles + +- Never trust the network implicitly. Neither side (server or client) + should assume the other's packets are well-formed or benign -- + validate all incoming packet data defensively, since either side + may send garbage or malicious data. Use `errorret_t` / + `errorThrow()` for these runtime checks, not assert macros -- + asserts are debug-only and won't guard release builds against + malformed or malicious packet data. diff --git a/src/dusk/rpg/CMakeLists.txt b/src/dusk/rpg/CMakeLists.txt index 519e13eb..e17955c4 100644 --- a/src/dusk/rpg/CMakeLists.txt +++ b/src/dusk/rpg/CMakeLists.txt @@ -14,4 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} add_subdirectory(cutscene) add_subdirectory(entity) add_subdirectory(overworld) -add_subdirectory(item) \ No newline at end of file +add_subdirectory(item) +add_subdirectory(physics) \ No newline at end of file diff --git a/src/dusk/rpg/physics/CMakeLists.txt b/src/dusk/rpg/physics/CMakeLists.txt new file mode 100644 index 00000000..5f81ee6e --- /dev/null +++ b/src/dusk/rpg/physics/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + physicsbody.c + physicsworld.c +) diff --git a/src/dusk/rpg/physics/physicsbody.c b/src/dusk/rpg/physics/physicsbody.c new file mode 100644 index 00000000..01d84649 --- /dev/null +++ b/src/dusk/rpg/physics/physicsbody.c @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "physicsbody.h" +#include "assert/assert.h" + +void physicsBodyInit( + physicsbody_t *body, const vec3 position, const vec3 extents +) { + assertNotNull(body, "body must not be null"); + assertNotNull(position, "position must not be null"); + assertNotNull(extents, "extents must not be null"); + assertTrue(extents[0] > 0.0f, "extents.x must be greater than 0"); + assertTrue(extents[1] > 0.0f, "extents.y must be greater than 0"); + assertTrue(extents[2] > 0.0f, "extents.z must be greater than 0"); + + glm_vec3_copy((float_t *)position, body->position); + glm_vec3_copy((float_t *)extents, body->extents); + glm_vec3_zero(body->velocity); + body->grounded = false; +} + +void physicsBodyGetBounds( + const physicsbody_t *body, vec3 outMin, vec3 outMax +) { + assertNotNull(body, "body must not be null"); + assertNotNull(outMin, "outMin must not be null"); + assertNotNull(outMax, "outMax must not be null"); + + glm_vec3_copy((float_t *)body->position, outMin); + glm_vec3_add((float_t *)body->position, (float_t *)body->extents, outMax); +} diff --git a/src/dusk/rpg/physics/physicsbody.h b/src/dusk/rpg/physics/physicsbody.h new file mode 100644 index 00000000..d0837ad9 --- /dev/null +++ b/src/dusk/rpg/physics/physicsbody.h @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "util/math.h" + +typedef struct physicsbody_s { + // Base/foot position, in raw grid units (same convention as worldpos_t - + // 1.0 equals one tile in X/Y, one Z-layer in Z). Not pre-scaled by + // WORLD_LAYER_HEIGHT, which is a cosmetic render-space value only. + vec3 position; + + // Velocity, in grid units per second, per axis. + vec3 velocity; + + // Full box size, in grid units. The box is anchored at position on all + // three axes and extends in the positive direction - i.e. it occupies + // [position, position + extents) - matching the tile grid's own + // convention that tile n occupies [n, n + 1). + vec3 extents; + + // True if the last physicsWorldStep clamped a downward Z velocity + // against a walkable tile beneath the body. + bool_t grounded; +} physicsbody_t; + +/** + * Initializes a physics body at the given position with the given extents. + * Velocity is zeroed and grounded is set to false. + * + * @param body Pointer to the physics body to initialize. + * @param position The initial position of the body. + * @param extents The size of the body's collision box. + */ +void physicsBodyInit( + physicsbody_t *body, const vec3 position, const vec3 extents +); + +/** + * Computes the world-space min/max bounds of a physics body's collision + * box, from its position and extents. The box is anchored at position + * and extends in the positive direction on every axis, so outMin equals + * position and outMax equals position + extents. + * + * @param body Pointer to the physics body. + * @param outMin Output, set to the box's minimum corner. + * @param outMax Output, set to the box's maximum corner. + */ +void physicsBodyGetBounds( + const physicsbody_t *body, vec3 outMin, vec3 outMax +); diff --git a/src/dusk/rpg/physics/physicsworld.c b/src/dusk/rpg/physics/physicsworld.c new file mode 100644 index 00000000..1aba803c --- /dev/null +++ b/src/dusk/rpg/physics/physicsworld.c @@ -0,0 +1,247 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "physicsworld.h" +#include "assert/assert.h" +#include "rpg/overworld/map.h" +#include "rpg/overworld/tile.h" +#include "rpg/overworld/tileshape.h" +#include "rpg/overworld/worldpos.h" + +// Tolerance used when converting a float boundary coordinate into a tile +// column/layer index, so that a coordinate sitting exactly on a tile +// boundary is treated as belonging to the tile it is entering/leaving, +// not the neighbour on the far side of the boundary. +#define PHYSICS_EPSILON 0.0001f + +void physicsWorldInit( + physicsworld_t *world, + const float_t gravity, + const float_t terminalVelocity +) { + assertNotNull(world, "world must not be null"); + assertTrue(terminalVelocity > 0.0f, "terminalVelocity must be positive"); + + world->gravity = gravity; + world->terminalVelocity = terminalVelocity; +} + +void physicsWorldStep( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +) { + assertNotNull(world, "world must not be null"); + assertNotNull(body, "body must not be null"); + + body->velocity[2] -= world->gravity * dt; + body->velocity[2] = mathClamp( + body->velocity[2], -world->terminalVelocity, world->terminalVelocity + ); + + physicsWorldResolveAxisX(world, body, dt); + physicsWorldResolveAxisY(world, body, dt); + physicsWorldResolveAxisZ(world, body, dt); +} + +void physicsWorldResolveAxisX( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +) { + assertNotNull(world, "world must not be null"); + assertNotNull(body, "body must not be null"); + + const float_t vx = body->velocity[0]; + if(vx == 0.0f) return; + + vec3 min, max; + physicsBodyGetBounds(body, min, max); + + const worldunit_t yStart = (worldunit_t)floorf(min[1] + PHYSICS_EPSILON); + const worldunit_t yEnd = (worldunit_t)floorf(max[1] - PHYSICS_EPSILON); + const worldunit_t layerZ = + (worldunit_t)floorf(body->position[2] + PHYSICS_EPSILON); + + if(vx > 0.0f) { + const worldunit_t oldColMax = + (worldunit_t)floorf(max[0] - PHYSICS_EPSILON); + const float_t newMax = max[0] + vx * dt; + const worldunit_t newColMax = + (worldunit_t)floorf(newMax - PHYSICS_EPSILON); + + for(worldunit_t col = oldColMax + 1; col <= newColMax; col++) { + bool_t blocked = false; + for(worldunit_t y = yStart; y <= yEnd; y++) { + const worldpos_t pos = { col, y, layerZ }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + blocked = true; + break; + } + } + + if(blocked) { + body->position[0] = (float_t)col - body->extents[0]; + body->velocity[0] = 0.0f; + return; + } + } + + body->position[0] += vx * dt; + } else { + const worldunit_t oldColMin = + (worldunit_t)floorf(min[0] + PHYSICS_EPSILON); + const float_t newMin = min[0] + vx * dt; + const worldunit_t newColMin = + (worldunit_t)floorf(newMin + PHYSICS_EPSILON); + + for(worldunit_t col = oldColMin - 1; col >= newColMin; col--) { + bool_t blocked = false; + for(worldunit_t y = yStart; y <= yEnd; y++) { + const worldpos_t pos = { col, y, layerZ }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + blocked = true; + break; + } + } + + if(blocked) { + body->position[0] = (float_t)(col + 1); + body->velocity[0] = 0.0f; + return; + } + } + + body->position[0] += vx * dt; + } +} + +void physicsWorldResolveAxisY( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +) { + assertNotNull(world, "world must not be null"); + assertNotNull(body, "body must not be null"); + + const float_t vy = body->velocity[1]; + if(vy == 0.0f) return; + + vec3 min, max; + physicsBodyGetBounds(body, min, max); + + const worldunit_t xStart = (worldunit_t)floorf(min[0] + PHYSICS_EPSILON); + const worldunit_t xEnd = (worldunit_t)floorf(max[0] - PHYSICS_EPSILON); + const worldunit_t layerZ = + (worldunit_t)floorf(body->position[2] + PHYSICS_EPSILON); + + if(vy > 0.0f) { + const worldunit_t oldRowMax = + (worldunit_t)floorf(max[1] - PHYSICS_EPSILON); + const float_t newMax = max[1] + vy * dt; + const worldunit_t newRowMax = + (worldunit_t)floorf(newMax - PHYSICS_EPSILON); + + for(worldunit_t row = oldRowMax + 1; row <= newRowMax; row++) { + bool_t blocked = false; + for(worldunit_t x = xStart; x <= xEnd; x++) { + const worldpos_t pos = { x, row, layerZ }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + blocked = true; + break; + } + } + + if(blocked) { + body->position[1] = (float_t)row - body->extents[1]; + body->velocity[1] = 0.0f; + return; + } + } + + body->position[1] += vy * dt; + } else { + const worldunit_t oldRowMin = + (worldunit_t)floorf(min[1] + PHYSICS_EPSILON); + const float_t newMin = min[1] + vy * dt; + const worldunit_t newRowMin = + (worldunit_t)floorf(newMin + PHYSICS_EPSILON); + + for(worldunit_t row = oldRowMin - 1; row >= newRowMin; row--) { + bool_t blocked = false; + for(worldunit_t x = xStart; x <= xEnd; x++) { + const worldpos_t pos = { x, row, layerZ }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) { + blocked = true; + break; + } + } + + if(blocked) { + body->position[1] = (float_t)(row + 1); + body->velocity[1] = 0.0f; + return; + } + } + + body->position[1] += vy * dt; + } +} + +void physicsWorldResolveAxisZ( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +) { + assertNotNull(world, "world must not be null"); + assertNotNull(body, "body must not be null"); + + const float_t vz = body->velocity[2]; + if(vz == 0.0f) return; + + vec3 min, max; + physicsBodyGetBounds(body, min, max); + + const worldunit_t xStart = (worldunit_t)floorf(min[0] + PHYSICS_EPSILON); + const worldunit_t xEnd = (worldunit_t)floorf(max[0] - PHYSICS_EPSILON); + const worldunit_t yStart = (worldunit_t)floorf(min[1] + PHYSICS_EPSILON); + const worldunit_t yEnd = (worldunit_t)floorf(max[1] - PHYSICS_EPSILON); + + if(vz < 0.0f) { + const worldunit_t layer = + (worldunit_t)floorf(body->position[2] + PHYSICS_EPSILON); + + bool_t solid = true; + for(worldunit_t x = xStart; x <= xEnd && solid; x++) { + for(worldunit_t y = yStart; y <= yEnd && solid; y++) { + const worldpos_t pos = { x, y, layer }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) solid = false; + } + } + + const float_t newZ = body->position[2] + vz * dt; + if(solid && newZ < (float_t)layer) { + body->position[2] = (float_t)layer; + body->velocity[2] = 0.0f; + body->grounded = true; + } else { + body->position[2] = newZ; + body->grounded = false; + } + } else { + const float_t head = max[2]; + const worldunit_t layer = (worldunit_t)ceilf(head - PHYSICS_EPSILON); + + bool_t solid = true; + for(worldunit_t x = xStart; x <= xEnd && solid; x++) { + for(worldunit_t y = yStart; y <= yEnd && solid; y++) { + const worldpos_t pos = { x, y, layer }; + if(!tileShapeIsWalkable(mapGetTile(pos).shape)) solid = false; + } + } + + const float_t newHead = head + vz * dt; + if(solid && newHead > (float_t)layer) { + body->position[2] = (float_t)layer - body->extents[2]; + body->velocity[2] = 0.0f; + } else { + body->position[2] += vz * dt; + } + } +} diff --git a/src/dusk/rpg/physics/physicsworld.h b/src/dusk/rpg/physics/physicsworld.h new file mode 100644 index 00000000..2683bea1 --- /dev/null +++ b/src/dusk/rpg/physics/physicsworld.h @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "physicsbody.h" + +#define PHYSICS_WORLD_GRAVITY_DEFAULT 20.0f +#define PHYSICS_WORLD_TERMINAL_VELOCITY_DEFAULT 40.0f + +typedef struct physicsworld_s { + // Downward acceleration applied to velocity.z every step, in grid units + // per second squared. + float_t gravity; + + // Maximum magnitude velocity.z may reach while falling, in grid units + // per second. A safety clamp bounding how far a single step can move, + // to limit (not eliminate) the tunneling limitation documented on + // physicsWorldStep. + float_t terminalVelocity; +} physicsworld_t; + +/** + * Initializes a physics world with the given gravity and terminal + * velocity. + * + * @param world Pointer to the physics world to initialize. + * @param gravity Downward acceleration applied every step. + * @param terminalVelocity Maximum falling speed. + */ +void physicsWorldInit( + physicsworld_t *world, + const float_t gravity, + const float_t terminalVelocity +); + +/** + * Advances a body by one timestep: applies gravity, integrates velocity + * into position (semi-implicit Euler), and resolves collisions against + * the tile map one axis at a time (X, then Y, then Z), clamping position + * and zeroing velocity on any axis that hits a tile boundary. + * + * Known limitations, deliberately out of scope for this very basic pass: + * - No ramp/slope support - ramp tiles are treated as flat walkable + * ground at whatever Z layer they are queried at. + * - No wall/hole distinction - horizontal movement treats any + * non-walkable (or unloaded) column at the body's current Z layer as + * a solid wall, rather than an edge to fall from. + * - A single step can tunnel through an intervening solid Z layer if + * velocity.z * dt exceeds one grid unit; terminalVelocity bounds this + * but does not eliminate it for very small/thin floors. + * + * @param world Physics world configuration. + * @param body The body to step. Its position/velocity/grounded fields are + * updated in place. + * @param dt Timestep, in seconds (use DUSK_TIME_STEP for the fixed step). + */ +void physicsWorldStep( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +); + +/** + * Resolves the body's movement along the X axis for this step, clamping + * position and zeroing velocity.x if a non-walkable column blocks the + * move. Declared publicly as an internal step helper, not a stable public + * API on its own. + * + * @param world Physics world configuration. + * @param body The body to resolve. + * @param dt Timestep, in seconds. + */ +void physicsWorldResolveAxisX( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +); + +/** + * Resolves the body's movement along the Y axis for this step. See + * physicsWorldResolveAxisX. + * + * @param world Physics world configuration. + * @param body The body to resolve. + * @param dt Timestep, in seconds. + */ +void physicsWorldResolveAxisY( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +); + +/** + * Resolves the body's movement along the Z axis for this step, clamping + * against walkable tile planes above and below the body and updating + * grounded. See physicsWorldResolveAxisX. + * + * @param world Physics world configuration. + * @param body The body to resolve. + * @param dt Timestep, in seconds. + */ +void physicsWorldResolveAxisZ( + const physicsworld_t *world, physicsbody_t *body, const float_t dt +); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1eedb721..d49c71cc 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -9,7 +9,7 @@ add_subdirectory(error) add_subdirectory(network) add_subdirectory(thread) add_subdirectory(display) -# add_subdirectory(rpg) +add_subdirectory(rpg) # add_subdirectory(item) add_subdirectory(time) add_subdirectory(util) \ No newline at end of file diff --git a/test/rpg/CMakeLists.txt b/test/rpg/CMakeLists.txt index 5a1133cd..f80ff20e 100644 --- a/test/rpg/CMakeLists.txt +++ b/test/rpg/CMakeLists.txt @@ -9,4 +9,5 @@ include(dusktest) dusktest(test_rpg.c) # Subdirs -add_subdirectory(overworld) \ No newline at end of file +add_subdirectory(overworld) +add_subdirectory(physics) \ No newline at end of file diff --git a/test/rpg/physics/CMakeLists.txt b/test/rpg/physics/CMakeLists.txt new file mode 100644 index 00000000..d115da20 --- /dev/null +++ b/test/rpg/physics/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Dominic Masters +# +# 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 diff --git a/test/rpg/physics/test_physicsbody.c b/test/rpg/physics/test_physicsbody.c new file mode 100644 index 00000000..eecd6694 --- /dev/null +++ b/test/rpg/physics/test_physicsbody.c @@ -0,0 +1,78 @@ +/** + * 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 new file mode 100644 index 00000000..453e1e58 --- /dev/null +++ b/test/rpg/physics/test_physicsworld.c @@ -0,0 +1,268 @@ +/** + * 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); + } + + 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); + } + + 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); + 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); + 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); + 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); + 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); + } + + 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); + } + + 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_physicsWorldStepRampTreatedAsFlatGround(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, 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] = 5.0f; + + for(uint32_t i = 0; i < 60; i++) { + physicsWorldStep(&world, &body, DUSK_TIME_STEP); + assert_float_equal(body.position[2], 0.0f, 0.0001f); + } + + assert_float_equal(body.velocity[0], 5.0f, 0.0001f); + assert_true(body.position[0] > 1.5f); + + 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_physicsWorldStepRampTreatedAsFlatGround), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}