Roadmap and physics
This commit is contained in:
+55
@@ -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.
|
||||
@@ -15,3 +15,4 @@ add_subdirectory(cutscene)
|
||||
add_subdirectory(entity)
|
||||
add_subdirectory(overworld)
|
||||
add_subdirectory(item)
|
||||
add_subdirectory(physics)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -10,3 +10,4 @@ dusktest(test_rpg.c)
|
||||
|
||||
# Subdirs
|
||||
add_subdirectory(overworld)
|
||||
add_subdirectory(physics)
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user