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
+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
);