Files
libakgl/tests/physics_sim.c
Andrew Kesterson 5e213061af 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>
2026-08-02 07:05:09 -04:00

854 lines
30 KiB
C

/**
* @file physics_sim.c
* @brief Whole-motion simulations of the arcade backend, in the shapes games actually use.
*
* `tests/physics.c` checks the pieces: that gravity accelerates, that drag
* sheds, that the cap caps. Every one of those passes while the motion a player
* would feel is still wrong, because feel lives in how the pieces compose over
* a few hundred frames.
*
* Three shapes, chosen because they are the three a 2D game almost always
* needs:
*
* 1. **Side-on jump and fall.** Gravity down, an upward impulse, land. The
* question is whether the arc is an arc.
* 2. **Top-down walk.** No gravity, four-way input, stop on release. The
* question is whether stopping and diagonals behave.
* 3. **Sudden reversal.** Full speed one way, then the other way. The question
* is whether the turn takes a believable amount of time.
*
* Every case runs at a fixed 60 Hz step and records the trajectory, so a
* failure says *what the motion did*, not merely that a number was wrong.
*
* @note akgl_physics_simulate reads SDL_GetTicksNS() itself, so it cannot be
* handed a dt. It can be *bounded* to one, though: sim_step sets
* `max_timestep` to the step it wants and `gravity_time` to zero, so the
* measured interval is always longer than the bound and every step is
* exactly `max_timestep`. That is deterministic under any load, which
* matters -- the first version placed `gravity_time` at `now - dt` and
* let the real clock supply the step, and a machine busy enough to
* deschedule the process between that store and the SDL_GetTicksNS()
* inside simulate produced a longer step and a red suite. It failed
* exactly once, under a parallel ctest, which is the worst way to find
* out. That the engine cannot be stepped exactly is itself a finding;
* see TODO.md.
*/
#include <SDL3/SDL.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/actor.h>
#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>
#include "testutil.h"
/** @brief One simulation step, in seconds. 60 Hz, the rate the budgets assume. */
#define SIM_DT (1.0f / 60.0f)
/** @brief How many steps a case may run before it is called a hang. */
#define SIM_MAX_STEPS 1200
/**
* @brief How long a host is assumed to spend loading before its first frame.
*
* Short enough to keep the suite quick, long enough to be far outside a frame.
* A real level load is longer, which only makes the effect larger.
*/
#define SIM_LOAD_TIME_MS 250
/** @brief The backend under test. Reconfigured per case rather than shared. */
static akgl_PhysicsBackend sim_physics;
/** @brief The character the simulated actor borrows its speed and acceleration from. */
static akgl_Character sim_character;
/**
* @brief Advance the simulation by exactly one step of @p dt seconds.
*
* akgl_physics_simulate measures dt as `SDL_GetTicksNS() - self->gravity_time`
* and then bounds it by `max_timestep`. Zeroing `gravity_time` makes the
* measured interval the whole uptime of the process, which is always past the
* bound, so the step the simulation takes is `max_timestep` and nothing else.
* The scheduler cannot get in the way of that, which the earlier `now - dt`
* version could not say.
*/
static akerr_ErrorContext *sim_step(float32_t dt)
{
PREPARE_ERROR(errctx);
sim_physics.max_timestep = (float64_t)dt;
sim_physics.gravity_time = 0;
PASS(errctx, sim_physics.simulate(&sim_physics, NULL));
SUCCEED_RETURN(errctx);
}
/** @brief Build a fresh actor bound to a character with the given accel and top speed. */
static akerr_ErrorContext *sim_actor(akgl_Actor **dest, char *name,
float32_t accel, float32_t topspeed)
{
PREPARE_ERROR(errctx);
memset(&sim_character, 0x00, sizeof(akgl_Character));
sim_character.ax = accel;
sim_character.ay = accel;
sim_character.sx = topspeed;
sim_character.sy = topspeed;
PASS(errctx, akgl_heap_next_actor(dest));
PASS(errctx, akgl_actor_initialize(*dest, name));
(*dest)->basechar = &sim_character;
(*dest)->x = 0.0f;
(*dest)->y = 0.0f;
SUCCEED_RETURN(errctx);
}
/** @brief Zero the backend and point it at the arcade implementation. */
static void sim_arcade(float64_t gravity_y, float64_t drag_x, float64_t drag_y)
{
memset(&sim_physics, 0x00, sizeof(akgl_PhysicsBackend));
sim_physics.simulate = &akgl_physics_simulate;
sim_physics.gravity = &akgl_physics_arcade_gravity;
sim_physics.collide = &akgl_physics_arcade_collide;
sim_physics.move = &akgl_physics_arcade_move;
sim_physics.gravity_y = gravity_y;
sim_physics.drag_x = drag_x;
sim_physics.drag_y = drag_y;
}
/** @brief A synthetic key-up, for driving the release handlers the way input does. */
static void sim_keyevent(SDL_Event *event, bool pressed)
{
memset(event, 0x00, sizeof(SDL_Event));
event->type = pressed ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
}
/**
* @brief Mario-esque: gravity, an upward impulse, and a landing.
*
* A side-on platformer jump is the arc of one impulse against constant
* acceleration. `libakgl` has no jump entry point, so a game does what this
* does: drive the environmental accumulator directly and let
* akgl_physics_arcade_gravity pull it back.
*
* The assertions are the ones that hold for *any* believable jump, not for one
* particular tuning: it must go up, it must come back, the apex must be in the
* middle rather than at an end, and rise and fall must take about the same time
* under constant gravity.
*/
akerr_ErrorContext *test_sim_jump_and_fall(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
float32_t apex_y = 0.0f;
int apex_step = 0;
int landed_step = 0;
int i = 0;
ATTEMPT {
// 1600 px/s^2 down and a 600 px/s launch: about a 0.75 s hop, which is
// roughly what a platformer of this era feels like.
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "jumper", 900.0f, 220.0f));
// The impulse. y grows downward, so up is negative.
actor->ey = -600.0f;
for ( i = 0; i < SIM_MAX_STEPS; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
if ( actor->y < apex_y ) {
apex_y = actor->y;
apex_step = i;
}
// Back to the ground it started on.
if ( (i > 0) && (actor->y >= 0.0f) ) {
landed_step = i;
break;
}
}
TEST_ASSERT(errctx, landed_step > 0,
"the jumper never came back down in %d steps (y is %f, vy %f)",
SIM_MAX_STEPS, actor->y, actor->vy);
TEST_ASSERT(errctx, apex_y < -1.0f,
"the jump did not leave the ground: apex was y=%f", apex_y);
// A constant-gravity arc is symmetric: the apex sits at the midpoint of
// the airtime, within a step or two of rounding.
TEST_ASSERT(errctx,
(apex_step > ((landed_step / 2) - 3)) && (apex_step < ((landed_step / 2) + 3)),
"apex at step %d of %d airborne steps; a constant-gravity arc peaks at the middle",
apex_step, landed_step);
printf(" jump: apex %.1f px at step %d, landed at step %d (%.2f s airborne)\n",
-apex_y, apex_step, landed_step, (float)landed_step * SIM_DT);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Mario-esque, part two: steering in mid-air must not cancel the fall.
*
* Every platformer lets you steer while airborne, which means pressing and
* releasing a horizontal key during a jump. Releasing must not disturb the
* vertical arc: horizontal input and gravity are different axes.
*/
akerr_ErrorContext *test_sim_air_control_preserves_the_arc(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *plain = NULL;
akgl_Actor *steered = NULL;
SDL_Event event;
float32_t plain_y = 0.0f;
float32_t steered_y = 0.0f;
int i = 0;
ATTEMPT {
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&plain, "plain", 900.0f, 220.0f));
// sim_actor rewrites the shared character; both actors share it, which
// is what a game does with two of the same enemy.
CATCH(errctx, sim_actor(&steered, "steered", 900.0f, 220.0f));
plain->basechar = &sim_character;
plain->ey = -600.0f;
steered->ey = -600.0f;
// Both jump. The steered one taps right for ten frames, then lets go,
// while both are still in the air.
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(steered, &event));
for ( i = 0; i < 10; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
sim_keyevent(&event, false);
CATCH(errctx, akgl_actor_cmhf_right_off(steered, &event));
for ( i = 0; i < 10; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
plain_y = plain->y;
steered_y = steered->y;
printf(" air control: plain y=%.1f vy=%.1f | steered y=%.1f vy=%.1f x=%.1f\n",
plain_y, plain->vy, steered_y, steered->vy, steered->x);
TEST_ASSERT(errctx, steered->x > 1.0f,
"steering right in mid-air moved the actor %f px", steered->x);
// The one that steered must be on the same vertical arc as the one that
// did not. Anything else means a horizontal key changed the fall.
TEST_ASSERT_FEQ(errctx, steered_y, plain_y,
"steering in mid-air moved the vertical arc: y=%f against %f, "
"a difference of %f px",
steered_y, plain_y, (steered_y - plain_y));
} CLEANUP {
if ( steered != NULL ) {
IGNORE(akgl_heap_release_actor(steered));
}
if ( plain != NULL ) {
IGNORE(akgl_heap_release_actor(plain));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Zelda-style: top-down, no gravity, four-way walk that stops.
*
* The whole feel of a top-down game is in accelerate, hold, release, stop. The
* assertions here are that the walk reaches its stated top speed and no more,
* and that letting go actually stops the character rather than leaving them
* drifting.
*/
akerr_ErrorContext *test_sim_topdown_walk(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
SDL_Event event;
float32_t speed_at_hold = 0.0f;
float32_t x_at_release = 0.0f;
float32_t drift = 0.0f;
int i = 0;
ATTEMPT {
// No gravity anywhere: this is a floor seen from above.
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "link", 900.0f, 220.0f));
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
speed_at_hold = actor->vx;
TEST_ASSERT(errctx, speed_at_hold > 0.0f,
"a held right key produced vx=%f", speed_at_hold);
// The cap is on thrust, and with no environmental component in a
// top-down world vx is thrust, so it must not exceed the character's
// stated top speed.
TEST_ASSERT(errctx, speed_at_hold <= (sim_character.sx + 1.0f),
"walking reached vx=%f, above the character's top speed of %f",
speed_at_hold, sim_character.sx);
x_at_release = actor->x;
sim_keyevent(&event, false);
CATCH(errctx, akgl_actor_cmhf_right_off(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
drift = actor->x - x_at_release;
printf(" top-down: held vx=%.1f, drifted %.1f px in the second after release\n",
speed_at_hold, drift);
// A second after letting go, the character is stopped.
TEST_ASSERT_FEQ(errctx, actor->vx, 0.0f,
"a second after release vx is still %f", actor->vx);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Zelda-style, part two: diagonal movement must not be faster.
*
* Holding two directions at once composes two axes that are each capped
* separately, so the resulting speed is the diagonal of the two caps rather
* than the cap. A character who walks 41% faster on the diagonal is the oldest
* bug in top-down movement.
*/
akerr_ErrorContext *test_sim_topdown_diagonal_speed(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
SDL_Event event;
float32_t straight = 0.0f;
float32_t diagonal = 0.0f;
int i = 0;
ATTEMPT {
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "link_straight", 900.0f, 220.0f));
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
straight = SDL_sqrtf((actor->vx * actor->vx) + (actor->vy * actor->vy));
IGNORE(akgl_heap_release_actor(actor));
actor = NULL;
CATCH(errctx, sim_actor(&actor, "link_diagonal", 900.0f, 220.0f));
sim_keyevent(&event, true);
// Right and down together. cmhf_right_on clears MOVING_ALL, so the
// down bit is set after it rather than before.
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN);
actor->ay = sim_character.ay;
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
// Holding down means the down bit stays set; the movement logic
// rewrites ay from the character each step.
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN);
}
diagonal = SDL_sqrtf((actor->vx * actor->vx) + (actor->vy * actor->vy));
printf(" diagonal: straight %.1f px/s, diagonal %.1f px/s (%.0f%% of straight)\n",
straight, diagonal, (double)((diagonal / straight) * 100.0f));
TEST_ASSERT(errctx, straight > 0.0f, "the straight walk did not move");
// Within a few percent of the same speed in both directions.
TEST_ASSERT(errctx, (diagonal <= (straight * 1.05f)),
"walking diagonally is %.0f%% of the straight speed (%f against %f); "
"two independently capped axes compose to the diagonal of the caps",
(double)((diagonal / straight) * 100.0f), diagonal, straight);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Full speed one way, then the other: the turn has to take a moment.
*
* A character at top speed who reverses instantly reads as weightless, and one
* who takes a second reads as stuck in treacle. With an acceleration of `a` and
* a top speed of `s`, a turn should take about `2s/a` seconds -- the time to
* shed the old speed plus the time to build the new one.
*/
akerr_ErrorContext *test_sim_sudden_reversal(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
SDL_Event event;
float32_t topspeed = 0.0f;
float32_t expected_turn = 0.0f;
float32_t actual_turn = 0.0f;
int steps_to_reverse = 0;
int i = 0;
ATTEMPT {
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "runner", 900.0f, 220.0f));
// Up to speed going right.
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
topspeed = actor->vx;
TEST_ASSERT(errctx, topspeed > 0.0f, "the runner never got up to speed (vx=%f)", topspeed);
// Now the other way, without letting go first -- which is what a player
// does, and which skips the release handler that zeroes everything.
CATCH(errctx, akgl_actor_cmhf_left_on(actor, &event));
for ( i = 0; i < SIM_MAX_STEPS; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
if ( actor->vx <= -topspeed ) {
steps_to_reverse = i + 1;
break;
}
}
TEST_ASSERT(errctx, steps_to_reverse > 0,
"the runner never reached full speed the other way (vx=%f after %d steps)",
actor->vx, SIM_MAX_STEPS);
actual_turn = (float32_t)steps_to_reverse * SIM_DT;
expected_turn = (2.0f * topspeed) / sim_character.ax;
printf(" reversal: %.3f s to turn around at %.0f px/s (2s/a predicts %.3f s)\n",
actual_turn, topspeed, expected_turn);
// A turn that takes no time at all means the velocity was assigned
// rather than accelerated.
TEST_ASSERT(errctx, steps_to_reverse > 1,
"the runner reversed from +%f to -%f in %d step(s)",
topspeed, topspeed, steps_to_reverse);
// And it should match the acceleration the character actually declares,
// within a couple of frames of rounding.
TEST_ASSERT(errctx,
(actual_turn > (expected_turn - (3.0f * SIM_DT))) &&
(actual_turn < (expected_turn + (3.0f * SIM_DT))),
"turning took %.3f s; the character's acceleration predicts %.3f s",
actual_turn, expected_turn);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief The first step must not launch the world.
*
* `gravity_time` is the timestamp the step's dt is measured from, and nothing
* initialises it -- akgl_physics_init_arcade sets every other field and leaves
* this one at whatever the backend's storage held, which for the default
* backend is zero. The first dt is then the whole time since SDL started.
*
* A game that spends a second loading before its first frame gets a one-second
* step, and with gravity that is a fall of several hundred pixels between the
* first frame and the second.
*/
akerr_ErrorContext *test_sim_first_step_is_not_a_leap(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
float32_t worstcase = 0.0f;
ATTEMPT {
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "faller", 900.0f, 220.0f));
// Exactly what a host does: initialize the backend, then start calling
// simulate. Nothing here sets gravity_time, because there is no
// documented way for a caller to.
CATCH(errctx, akgl_physics_init_arcade(&sim_physics));
sim_physics.gravity_y = 1600.0;
// A host does not call simulate() microseconds after configuring the
// physics; it loads a level first, and *then* starts its frame loop. dt
// is measured from gravity_time, so that load lands in the first step
// however gravity_time was initialized.
SDL_Delay(SIM_LOAD_TIME_MS);
CATCH(errctx, sim_physics.simulate(&sim_physics, NULL));
printf(" first step: y=%.1f vy=%.1f after one simulate() from a fresh backend\n",
actor->y, actor->vy);
// One 60 Hz step of 1600 px/s^2 is 0.44 px. Anything past a few pixels
// means dt was not a frame -- and the same happens on any hitch a
// running game takes: a level load, a breakpoint, a dragged window, an
// alt-tab. Initializing gravity_time is necessary but does not cover
// this on its own; the step has to be bounded.
//
// The bound this asserts is the worst case the backend still permits,
// derived rather than written down: one step of at most max_timestep
// accelerates to g*dt and then travels that for dt, so g*dt^2 -- 4 px
// under this gravity at the default 0.05 s. Not 100 px, which is what a
// 250 ms load bought before the step was bounded.
worstcase = (float32_t)(1600.0 * AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP * AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP);
TEST_ASSERT(errctx, (actor->y <= worstcase) && (actor->y > -worstcase),
"one step from a freshly initialized backend moved the actor %f px "
"after a %d ms load; a 60 Hz frame under this gravity is 0.44 px "
"and a step bounded to %.2f s cannot exceed %f px",
actor->y, SIM_LOAD_TIME_MS, AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP, worstcase);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Releasing a vertical key mid-air must not cancel gravity.
*
* akgl_actor_cmhf_up_off and _down_off zero `ay`, `ey`, `ty` and `vy`. `ey` is
* the environmental accumulator -- the field gravity has been building up all
* fall -- and it belongs to the world, not to the key.
*
* A platformer that maps down to crouch or fast-fall, or up to look up or climb,
* releases those keys in mid-air constantly. Every release parks the actor in
* the air with zero vertical velocity and starts the fall again from nothing.
*/
akerr_ErrorContext *test_sim_vertical_key_release_keeps_gravity(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *plain = NULL;
akgl_Actor *crouched = NULL;
SDL_Event event;
int i = 0;
ATTEMPT {
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&plain, "faller_plain", 900.0f, 220.0f));
CATCH(errctx, sim_actor(&crouched, "faller_crouch", 900.0f, 220.0f));
plain->basechar = &sim_character;
// Both fall from rest for a third of a second.
for ( i = 0; i < 20; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
TEST_ASSERT(errctx, plain->vy > 100.0f,
"the plain faller is only doing %f px/s after 20 steps of 1600 px/s^2",
plain->vy);
// One of them taps down and lets go, the way a fast-fall or a crouch
// does. The fall is the world's, not the key's.
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_down_on(crouched, &event));
CATCH(errctx, sim_step(SIM_DT));
sim_keyevent(&event, false);
CATCH(errctx, akgl_actor_cmhf_down_off(crouched, &event));
printf(" vertical release: plain vy=%.1f | after a down tap vy=%.1f\n",
plain->vy, crouched->vy);
TEST_ASSERT(errctx, crouched->vy > (plain->vy * 0.5f),
"releasing the down key left vy at %f while the untouched faller is at "
"%f; the release handler zeroed the gravity accumulator",
crouched->vy, plain->vy);
} CLEANUP {
if ( crouched != NULL ) {
IGNORE(akgl_heap_release_actor(crouched));
}
if ( plain != NULL ) {
IGNORE(akgl_heap_release_actor(plain));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Run one simulation, report it, and return 1 if it failed.
*
* The context is released here rather than propagated, so one bad case does not
* stop the rest from running.
*/
static int sim_run(const char *name, akerr_ErrorContext *(*simulation)(void))
{
akerr_ErrorContext *result = NULL;
printf("%s:\n", name);
result = simulation();
if ( result == NULL ) {
return 0;
}
result->handled = true;
result = akerr_release_error(result);
printf(" ^^ FAILED\n");
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");
ATTEMPT {
CATCH(errctx, akgl_error_init());
// SDL_GetTicksNS() counts from SDL's own initialization, and
// akgl_physics_simulate measures dt against it. Without this the clock
// epoch would be established by the first call inside the first
// simulation, every reading would be a handful of nanoseconds, and the
// first-step case could not reproduce what a real host sees.
if ( !SDL_Init(SDL_INIT_VIDEO) ) {
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
}
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
// Every case runs even when an earlier one fails. This suite exists to
// describe the motion, and stopping at the first bad number would hide
// the rest of the picture -- which is the whole picture.
failures += sim_run("first step is a frame", &test_sim_first_step_is_not_a_leap);
failures += sim_run("jump and fall", &test_sim_jump_and_fall);
failures += sim_run("air control keeps the arc", &test_sim_air_control_preserves_the_arc);
failures += sim_run("vertical release keeps gravity", &test_sim_vertical_key_release_keeps_gravity);
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;
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 {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}