Roadmap and physics

This commit is contained in:
2026-07-17 13:09:58 -05:00
parent 2432443ea6
commit 0bc80d5df3
12 changed files with 868 additions and 3 deletions
+55
View File
@@ -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.
+2 -1
View File
@@ -14,4 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
add_subdirectory(cutscene)
add_subdirectory(entity)
add_subdirectory(overworld)
add_subdirectory(item)
add_subdirectory(item)
add_subdirectory(physics)
+10
View File
@@ -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
)
+36
View File
@@ -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);
}
+55
View File
@@ -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
);
+247
View File
@@ -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;
}
}
}
+102
View File
@@ -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
);
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -9,4 +9,5 @@ include(dusktest)
dusktest(test_rpg.c)
# Subdirs
add_subdirectory(overworld)
add_subdirectory(overworld)
add_subdirectory(physics)
+12
View File
@@ -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
+78
View File
@@ -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);
}
+268
View File
@@ -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);
}