Call collide from the simulation, after the move rather than before it
The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,9 @@
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
@@ -606,10 +608,205 @@ static int sim_run(const char *name, akerr_ErrorContext *(*simulation)(void))
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A floor across the bottom, with a two-tile hole in it.
|
||||
*
|
||||
* Static, not local: sizeof(akgl_Tilemap) is about 26 MB and a local one
|
||||
* overflows the stack before the first assertion runs.
|
||||
*/
|
||||
static akgl_Tilemap sim_map;
|
||||
static akgl_CollisionWorld sim_world;
|
||||
|
||||
static akerr_ErrorContext *sim_collision_world(void)
|
||||
{
|
||||
int x = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
memset(&sim_map, 0x00, sizeof(akgl_Tilemap));
|
||||
sim_map.tilewidth = 16;
|
||||
sim_map.tileheight = 16;
|
||||
sim_map.width = 40;
|
||||
sim_map.height = 20;
|
||||
sim_map.numlayers = 1;
|
||||
sim_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
|
||||
for ( x = 0; x < sim_map.width; x++ ) {
|
||||
if ( (x == 20) || (x == 21) ) {
|
||||
continue; // a hole to fall through
|
||||
}
|
||||
sim_map.layers[0].data[(19 * sim_map.width) + x] = 1;
|
||||
}
|
||||
sim_map.collidablelayers = 1u;
|
||||
|
||||
PASS(errctx, akgl_collision_world_init(&sim_world, NULL, 16.0f, 16.0f));
|
||||
PASS(errctx, akgl_collision_bind_tilemap(&sim_world, &sim_map));
|
||||
sim_physics.collision = &sim_world;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief An actor dropped on a floor lands on it and stays there.
|
||||
*
|
||||
* The number that matters is the drift after it has settled. A resolver that
|
||||
* zeroes the environmental term rather than accounting for the gravity the next
|
||||
* step is about to add commits `gravity * dt^2` of penetration per step -- a
|
||||
* quarter of a pixel at 60 Hz, invisible, and cumulative.
|
||||
*/
|
||||
static akerr_ErrorContext *test_sim_lands_and_rests(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_CollisionShape shape;
|
||||
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
||||
float32_t settled = 0.0f;
|
||||
float32_t drift = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
sim_arcade(900.0, 0.0, 0.0);
|
||||
CATCH(errctx, sim_collision_world());
|
||||
CATCH(errctx, sim_actor(&actor, "faller", 600.0f, 200.0f));
|
||||
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
||||
actor->shape = shape;
|
||||
actor->shape_override = true;
|
||||
actor->x = 32.0f;
|
||||
actor->y = 100.0f;
|
||||
|
||||
for ( i = 0; i < 120; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
settled = actor->y;
|
||||
|
||||
for ( i = 0; i < 120; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
drift = actor->y - settled;
|
||||
printf(" landed at y=%.3f, drift over 120 further steps %.4f px\n", settled, drift);
|
||||
|
||||
TEST_ASSERT(errctx, (settled < 304.0f),
|
||||
"the actor came to rest at y=%f; the floor is at 304 and it is inside it",
|
||||
settled);
|
||||
TEST_ASSERT(errctx, (fabsf(drift) < 1.0f),
|
||||
"a resting actor drifted %f px over 120 steps; it is sinking through the floor",
|
||||
drift);
|
||||
} CLEANUP {
|
||||
sim_physics.collision = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief And it can still walk once it has landed.
|
||||
*
|
||||
* The consequence a player sees. A quarter pixel of sink is invisible until the
|
||||
* horizontal test starts reporting blocked wherever the actor stands, and then
|
||||
* the character jerks backwards every time it tries to move.
|
||||
*/
|
||||
static akerr_ErrorContext *test_sim_walks_after_landing(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_CollisionShape shape;
|
||||
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
||||
float32_t startx = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
sim_arcade(900.0, 0.0, 0.0);
|
||||
CATCH(errctx, sim_collision_world());
|
||||
CATCH(errctx, sim_actor(&actor, "walker", 600.0f, 120.0f));
|
||||
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
||||
actor->shape = shape;
|
||||
actor->shape_override = true;
|
||||
actor->x = 32.0f;
|
||||
actor->y = 100.0f;
|
||||
|
||||
for ( i = 0; i < 120; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
startx = actor->x;
|
||||
|
||||
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
for ( i = 0; i < 60; i++ ) {
|
||||
CATCH(errctx, sim_step(SIM_DT));
|
||||
}
|
||||
printf(" walked from x=%.2f to x=%.2f in one second\n", startx, actor->x);
|
||||
|
||||
TEST_ASSERT(errctx, (actor->x > (startx + 30.0f)),
|
||||
"an actor resting on the floor walked from %f to %f in a second; it is "
|
||||
"caught on the ground it is standing on", startx, actor->x);
|
||||
} CLEANUP {
|
||||
sim_physics.collision = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A fast fall does not pass through a one-tile floor.
|
||||
*
|
||||
* At 900 px/s^2 and the default 0.05 s bound a single step covers about 30
|
||||
* pixels -- nearly two 16-pixel tiles. Without sub-stepping the actor is above
|
||||
* the floor at one sample and below it at the next, and no test between them
|
||||
* ever sees an overlap.
|
||||
*/
|
||||
static akerr_ErrorContext *test_sim_fast_fall_does_not_tunnel(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_CollisionShape shape;
|
||||
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
/*
|
||||
* Zero gravity and a constant 1200 px/s, so every step is exactly 60
|
||||
* pixels and the arithmetic is the same every time. With gravity on, the
|
||||
* step size grows and the actor lands *inside* the floor by luck rather
|
||||
* than passing through it -- which made the first version of this test
|
||||
* pass with sub-stepping disabled and prove nothing.
|
||||
*
|
||||
* 60 pixels a step against a 16-pixel floor and a 16-pixel actor: the
|
||||
* window in which an overlap exists is 32 pixels wide, so from y=100 the
|
||||
* samples land at 160, 220, 280 and 340 and step clean over it. Sub-
|
||||
* stepping cuts that to 7.5 pixels, which cannot miss.
|
||||
*/
|
||||
sim_arcade(0.0, 0.0, 0.0);
|
||||
CATCH(errctx, sim_collision_world());
|
||||
CATCH(errctx, sim_actor(&actor, "bullet", 600.0f, 200.0f));
|
||||
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
||||
actor->shape = shape;
|
||||
actor->shape_override = true;
|
||||
actor->x = 32.0f;
|
||||
actor->y = 100.0f;
|
||||
actor->ey = 1200.0f;
|
||||
|
||||
for ( i = 0; i < 20; i++ ) {
|
||||
CATCH(errctx, sim_step(0.05f));
|
||||
}
|
||||
printf(" 60 px per step at a 16 px floor: ended at y=%.2f\n", actor->y);
|
||||
|
||||
TEST_ASSERT(errctx, (actor->y < 320.0f),
|
||||
"an actor moving 60 px a step ended at y=%f, past the floor at 304; it "
|
||||
"stepped straight over the 32 px window in which an overlap exists",
|
||||
actor->y);
|
||||
} CLEANUP {
|
||||
sim_physics.collision = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int failures = 0;
|
||||
int total = 0;
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
@@ -637,8 +834,16 @@ int main(void)
|
||||
failures += sim_run("top-down walk", &test_sim_topdown_walk);
|
||||
failures += sim_run("top-down diagonal speed", &test_sim_topdown_diagonal_speed);
|
||||
failures += sim_run("sudden reversal", &test_sim_sudden_reversal);
|
||||
total = 7;
|
||||
|
||||
printf("\n%d of 7 simulations failed\n", failures);
|
||||
failures += sim_run("lands and rests", &test_sim_lands_and_rests);
|
||||
failures += sim_run("walks after landing", &test_sim_walks_after_landing);
|
||||
failures += sim_run("fast fall does not tunnel", &test_sim_fast_fall_does_not_tunnel);
|
||||
total += 3;
|
||||
|
||||
// Derived, not written out. The literal that used to be here went stale
|
||||
// the first time a simulation was added, which is what a literal does.
|
||||
printf("\n%d of %d simulations failed\n", failures, total);
|
||||
FAIL_NONZERO_BREAK(errctx, failures, AKGL_ERR_BEHAVIOR,
|
||||
"%d physics simulations do not behave as a game needs", failures);
|
||||
} CLEANUP {
|
||||
|
||||
Reference in New Issue
Block a user