Fix three arcade physics defects the unit tests could not see

tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.

The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.

Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.

Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.

Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.

Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.

26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
This commit is contained in:
2026-08-01 12:10:53 -04:00
parent d55778e625
commit 147ab7dc78
9 changed files with 931 additions and 55 deletions

View File

@@ -559,6 +559,50 @@ CTest recorded a pass. That is not hypothetical: `tests/character.c` aborted at
its second of four tests on a bad renderer and was green until 0.5.0. The trap
in `tests/testutil.h` collapses any status a byte cannot carry onto 1. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward.
### Physics simulations
`tests/physics_sim.c` is not a unit suite. `tests/physics.c` checks that
`akgl_physics_simulate` does the arithmetic it says it does; this one runs the
arcade backend for a second or two the way a game does and asks whether the
result is what a player would expect. It carries a Mario-esque jump and fall,
Zelda-style top-down walking, and a run reversed at full speed, because those
are the three shapes most 2D games are made of.
It found three defects that every existing unit test passed straight over, so
the distinction is worth keeping:
- **The first step was however long the level took to load.** `gravity_time`
was never initialized and `dt` was unbounded, so a 250 ms load produced a
250 ms step -- 100 px of fall where a 60 Hz frame is 0.44 px. Both
initializers seed the clock now, and `akgl_physics_simulate` bounds `dt` to
`max_timestep` (`physics.max_timestep`, default 0.05 s).
- **Releasing a vertical key cancelled gravity.** The `_off` handlers zeroed
`ay`, `ey`, `ty` and `vy` together, and `ey` is where the arcade backend
accumulates gravity, so tapping down mid-jump stopped the character in the
air. The handlers clear `ax`/`tx` (or `ay`/`ty`) and nothing else. Velocity
was never theirs to clear either -- `simulate` recomputes `v` as `e + t`
every step.
- **Diagonal movement was 41% too fast.** Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the `sx`/`sy`/`sz` ellipse now.
Two rules for working on this suite:
- **Drive the clock, do not sleep on it.** `sim_step()` sets `gravity_time` to
`now - dt` and calls `simulate` once, so a simulated second is 60 steps and
takes no wall-clock time. Only `test_sim_first_step_is_not_a_leap` uses a real
`SDL_Delay`, because a real stall is the thing it is measuring.
- **`main` must `SDL_Init`.** Without it, SDL sets its clock epoch on first use,
`SDL_GetTicksNS()` returns something around 130 ns, and every dt-dependent
assertion passes for a reason that has nothing to do with the code. The
first-step test passed exactly that way before the `SDL_Init` call was added,
and only started failing -- correctly -- once it was there.
Each simulation prints what it measured whether it passes or fails, and `main`
exits with the number that failed. Read the numbers when changing the backend;
a change that keeps every assertion green while moving the apex of a jump by
30 px is a change worth noticing.
### Performance suites
`tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that

View File

@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
# The single source of truth for the library version. It drives the generated
# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
# Version field in akgl.pc. Bump it here and nowhere else.
project(akgl VERSION 0.5.0 LANGUAGES C)
project(akgl VERSION 0.6.0 LANGUAGES C)
# Memory checking reuses the suites that already exist -- `ctest -T memcheck`
# runs every registered test under valgrind -- rather than adding programs of its
@@ -314,6 +314,7 @@ set(AKGL_TEST_SUITES
heap
json_helpers
physics
physics_sim
registry
renderer
sprite

63
TODO.md
View File

@@ -1884,6 +1884,69 @@ currently promises the opposite. `aksl_strncpy` already reports exactly that
status when the bytes do not fit, so the change is to stop capping `n` at
`size - 1` and let it raise.
## Arcade physics feel
Found by building `tests/physics_sim.c`, which runs the arcade backend as a game
would -- a Mario-esque jump and fall, Zelda-style top-down movement, and a
fast run reversed at speed -- and prints what the actor actually did. Three
defects it found are fixed in 0.6.0 (an unbounded first step, release handlers
zeroing the gravity accumulator, and per-axis thrust caps composing to a 141%
diagonal). These are what it found and did **not** fix.
### No terminal velocity
`akgl_physics_arcade_gravity` adds `gravity_y * dt` to `ey` every step and
nothing bounds it. `sy` caps *thrust*, deliberately -- it is the character's own
top speed, not a speed limit on the world -- so a fall accelerates without limit
for as long as it lasts. The jump simulation reaches 560 px/s in 0.7 s and would
keep going.
Drag is the mechanism that exists for this: `physics.drag.y` makes `ey`
approach `gravity_y / drag_y` instead of diverging. So the behaviour is
reachable, it just is not the default and is not documented as the way to get a
terminal velocity. Either document it that way or add an explicit
`physics.terminal_velocity`. Touches `src/physics.c` and
`include/akgl/physics.h`; no ABI change if it is a property rather than a field.
### Releasing a direction stops the actor dead
`akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character running at
full speed stops within one frame of the key coming up -- the top-down
simulation measures 0.0 px of drift in the second after release. That is correct
for Zelda and wrong for Mario, who slides. There is no deceleration or ground
friction anywhere in the arcade backend; `ax` is the only rate, and it applies
only while a direction is held.
The shape that fits the existing model is a per-character deceleration that
decays `tx` toward zero over several frames instead of assigning zero, with the
current behaviour as the value that means "instant". That is a new
`akgl_Character` field and an ABI break, so it wants to land with other
character changes rather than alone.
### Integration is explicit Euler, so trajectories are frame-rate dependent
Velocity is applied to position using the velocity computed *this* step, and
thrust is accumulated before the cap, so the same jump traces a slightly
different arc at 30 Hz than at 144 Hz. The reversal simulation measures 0.500 s
to turn around where the closed form predicts 0.489 s -- about 2% at 60 Hz, and
it grows with the step.
`max_timestep` bounds how bad it gets (that is half of why it exists), and 2% is
not a bug a player can see. It is recorded because the fix -- a fixed-step
accumulator, integrating in whole `max_timestep` slices and carrying the
remainder -- would also remove the slow-motion trade that bounding the step
currently makes, and the two are the same piece of work.
### A large drag coefficient can invert velocity
`actor->ex -= actor->ex * self->drag_x * dt` is a first-order decay, so it only
decays for `drag * dt < 1`. Past that it overshoots zero, and past `2` it
diverges. With `dt` bounded to the default 0.05 s that needs a drag coefficient
above 20, which is not a plausible setting, so this is a sharp edge rather than
a live defect -- but nothing rejects it, and `max_timestep` is caller-settable.
`src/physics.c`, in the three drag blocks. The exact fix is
`expf(-drag * dt)` instead of `1 - drag * dt`, which is unconditionally stable.
## Carried over
1. **Make character-to-sprite state bindings release their references symmetrically.**

View File

@@ -16,15 +16,29 @@
* | thrust | `tx, ty, tz` | the actor's own acceleration while it is moving |
* | environmental | `ex, ey, ez` | gravity, less drag |
* | velocity | `vx, vy, vz` | thrust + environmental |
* | max speed | `sx, sy, sz` | the character; caps thrust, not velocity |
* | max speed | `sx, sy, sz` | the character; caps the thrust vector, not velocity |
* | acceleration | `ax, ay, az` | the character |
*
* Each step: movement logic, then gravity, then drag, then velocity, then move.
* Because the cap is applied to thrust rather than to velocity, gravity can
* carry an actor faster than its stated top speed -- which is the point, for a
* falling one.
*
* The cap is on the thrust **vector**, not on each axis separately. Capping the
* axes independently lets the corner of the box through: an actor holding two
* directions at once got both caps at once and travelled their diagonal, 41%
* faster than either alone. Scaling to the ellipse instead keeps a character
* whose horizontal and vertical speeds differ moving at the ratio it asked for.
*/
/**
* @brief Default bound on one simulation step, in seconds.
*
* A twentieth of a second: three frames at 60 Hz, so an ordinary dropped frame
* or two passes through untouched, and a load or a breakpoint does not.
*/
#define AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP 0.05
#ifndef _AKGL_PHYSICS_H_
#define _AKGL_PHYSICS_H_
@@ -47,8 +61,8 @@ typedef struct akgl_PhysicsBackend {
float64_t gravity_x; /**< Acceleration along x, world units per second squared. Applied toward screen left. From `physics.gravity.x`. */
float64_t gravity_y; /**< Acceleration along y. Applied down the screen, since y grows downward. From `physics.gravity.y`. */
float64_t gravity_z; /**< Acceleration along z. Applied away from the camera. From `physics.gravity.z`. */
SDL_Time gravity_time; /**< Timestamp of the previous step, in nanoseconds. The simulation's `dt` is measured from this. */
SDL_Time timer_gravity; /**< Unused. Nothing in the library reads or writes it. */
SDL_Time gravity_time; /**< Timestamp of the previous step, in nanoseconds. Stamped by akgl_physics_simulate and seeded by the initializers; the simulation's `dt` is measured from this. */
float64_t max_timestep; /**< Longest step the simulation will take, in seconds. A hitch longer than this advances the world by this much instead. 0 disables the bound. From `physics.max_timestep`, default #AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP. */
} akgl_PhysicsBackend;
/**
@@ -203,16 +217,26 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_factory(akgl_PhysicsBackend *sel
* offset, and skipped entirely -- children do not simulate;
* - an actor with no character is skipped, since the speed and acceleration
* constants live there;
* - thrust accumulates along an axis the actor is moving on, then is clamped to
* the character's maximum speed;
* - thrust accumulates along an axis the actor is moving on, then the thrust
* *vector* is scaled to the ellipse the character's per-axis maximum speeds
* describe;
* - the backend's `gravity` runs, drag is applied to the environmental term,
* velocity becomes environmental plus thrust, and the backend's `move`
* commits it.
*
* `dt` is measured from `self->gravity_time`, which is stamped at the end. The
* first call after initialization therefore measures from 0 and produces an
* enormous `dt` -- initialize `gravity_time` from `SDL_GetTicksNS()` before the
* first step if that matters.
* `dt` is measured from `self->gravity_time`, which is stamped at the end and
* seeded by akgl_physics_init_arcade and akgl_physics_init_null. It is then
* bounded by `self->max_timestep`: a step longer than that advances the world
* by `max_timestep` instead of by the whole elapsed time.
*
* That bound is not a nicety. Anything that stalls a frame -- a level load
* between initialization and the first step, a breakpoint, a dragged window, an
* alt-tab -- otherwise arrives as one enormous `dt`, and one enormous `dt`
* moves every actor by however far its velocity carries it in that whole
* interval. A quarter-second load under ordinary platformer gravity is a
* hundred pixels of fall between the first frame and the second, straight
* through whatever was underneath. Advancing the world in slow motion during a
* hitch is the trade every game engine makes here.
*
* @param self The backend. Required, along with its `move` pointer.
* @param opflags Iterator flags. Optional -- `NULL` means "simulate everything".

View File

@@ -354,9 +354,7 @@ akerr_ErrorContext *akgl_actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event)
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
//SDL_Log("event %d (button %d / key %d) stops moving actor left", event->type, event->gbutton.which, event->key.key);
obj->ax = 0;
obj->ex = 0;
obj->tx = 0;
obj->vx = 0;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
//SDL_Log("new target actor state: %b", obj->state);
SUCCEED_RETURN(errctx);
@@ -383,9 +381,7 @@ akerr_ErrorContext *akgl_actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event)
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
//SDL_Log("event %d (button %d / key %d) stops moving actor right", event->type, event->gbutton.which, event->key.key);
obj->ax = 0;
obj->ex = 0;
obj->tx = 0;
obj->vx = 0;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
//SDL_Log("new target actor state: %b", obj->state);
SUCCEED_RETURN(errctx);
@@ -412,9 +408,7 @@ akerr_ErrorContext *akgl_actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event)
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
//SDL_Log("event %d (button %d / key %d) stops moving actor up", event->type, event->gbutton.which, event->key.key);
obj->ay = 0;
obj->ey = 0;
obj->ty = 0;
obj->vy = 0;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
//SDL_Log("new target actor state: %b", obj->state);
SUCCEED_RETURN(errctx);
@@ -440,10 +434,8 @@ akerr_ErrorContext *akgl_actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event)
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
//SDL_Log("event %d (button %d / key %d) stops moving actor down", event->type, event->gbutton.which, event->key.key);
obj->ty = 0;
obj->ey = 0;
obj->ay = 0;
obj->vy = 0;
obj->ty = 0;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_DOWN);
//SDL_Log("new target actor state: %b", obj->state);
SUCCEED_RETURN(errctx);

View File

@@ -47,6 +47,12 @@ akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
self->move = akgl_physics_null_move;
self->simulate = akgl_physics_simulate;
// Set for the same reason as the arcade backend: the null backend still
// goes through akgl_physics_simulate, which still computes a dt and still
// commits velocity to position through akgl_physics_null_move.
self->gravity_time = SDL_GetTicksNS();
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
SUCCEED_RETURN(errctx);
}
@@ -104,6 +110,14 @@ akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self)
self->move = akgl_physics_arcade_move;
self->simulate = akgl_physics_simulate;
// The epoch the first step's dt is measured from. Nothing set it, so it
// stayed at whatever the backend's storage held -- zero, for the default
// backend in BSS -- and the first akgl_physics_simulate() measured dt as
// the entire time since SDL started. A host that spends a quarter of a
// second loading before its first frame got a quarter-second step: 101
// pixels of fall where a 60 Hz frame is 0.44.
self->gravity_time = SDL_GetTicksNS();
ATTEMPT {
CATCH(errctx, akgl_get_property("physics.gravity.x", &tmp, "0.0"));
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_x));
@@ -117,6 +131,8 @@ akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self)
CATCH(errctx, aksl_atof(tmp->data, &self->drag_y));
CATCH(errctx, akgl_get_property("physics.drag.z", &tmp, "0.0"));
CATCH(errctx, aksl_atof(tmp->data, &self->drag_z));
CATCH(errctx, akgl_get_property("physics.max_timestep", &tmp, "0.05"));
CATCH(errctx, aksl_atof(tmp->data, &self->max_timestep));
} CLEANUP {
IGNORE(akgl_heap_release_string(tmp));
} PROCESS(errctx) {
@@ -134,6 +150,8 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
};
SDL_Time curtime = 0;
float32_t dt = 0;
float32_t overshoot = 0.0f;
float32_t thrustscale = 0.0f;
akgl_Actor *actor = NULL;
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
@@ -144,6 +162,23 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
curtime = SDL_GetTicksNS();
dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS;
// Bound the step. Everything below multiplies by dt, so one enormous dt
// moves every actor by however far its velocity carries it over the whole
// stall -- a quarter-second load is a hundred pixels of fall under
// platformer gravity, straight through whatever was underneath. Advancing
// the world in slow motion through a hitch is the trade to make here.
//
// A zero or negative max_timestep disables the bound, for a caller who
// would rather have the real elapsed time and handle it themselves.
if ( (self->max_timestep > 0.0) && (dt > (float32_t)self->max_timestep) ) {
dt = (float32_t)self->max_timestep;
}
// A clock that went backwards is not a step. SDL_GetTicksNS is monotonic,
// but gravity_time is a public field and a caller can put anything in it.
if ( dt < 0.0f ) {
dt = 0.0f;
}
if ( opflags == NULL ) {
opflags = &defflags;
}
@@ -179,27 +214,40 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
actor->ty += actor->ay * dt;
}
// velocity equals thrust unless thrust exceeds max speed
if ( fabsf(actor->tx) > fabsf(actor->sx) ) {
if ( actor->tx < 0 ) {
actor->tx = -actor->sx;
} else {
actor->tx = actor->sx;
}
// Cap the thrust *vector* against the ellipse the character's per-axis
// top speeds describe, rather than each axis against its own cap.
//
// Capping the axes independently lets the corner of the box through: an
// actor holding two directions at once got both caps at once and
// travelled their diagonal, which measured 41% faster than either alone.
// Scaling to the ellipse keeps a character whose horizontal and vertical
// speeds differ moving at the ratio it asked for, and makes them equal
// where the speeds are.
//
// An axis with a top speed of zero cannot be thrust along at all, which
// is what the old per-axis clamp did with it, and it stays out of the
// magnitude entirely -- dividing by it would not end well.
overshoot = 0.0f;
if ( actor->sx != 0.0f ) {
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
} else {
actor->tx = 0.0f;
}
if ( fabsf(actor->ty) > fabsf(actor->sy) ) {
if ( actor->ty < 0 ) {
actor->ty = -actor->sy;
} else {
actor->ty = actor->sy;
}
if ( actor->sy != 0.0f ) {
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
} else {
actor->ty = 0.0f;
}
if ( fabsf(actor->tz) > fabsf(actor->sz) ) {
if ( actor->tz < 0 ) {
actor->tz = -actor->sz;
} else {
actor->tz = actor->sz;
}
if ( actor->sz != 0.0f ) {
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
} else {
actor->tz = 0.0f;
}
if ( overshoot > 1.0f ) {
thrustscale = 1.0f / sqrtf(overshoot);
actor->tx *= thrustscale;
actor->ty *= thrustscale;
actor->tz *= thrustscale;
}
ATTEMPT {
CATCH(errctx, actor->movementlogicfunc(actor, dt));

View File

@@ -444,8 +444,17 @@ akerr_ErrorContext *test_actor_control_handlers_off(void)
basechar.ax = 7.0f;
basechar.ay = 11.0f;
// Releasing a direction zeroes that axis outright: acceleration, thrust,
// environmental force, and velocity all go to zero together.
// Releasing a direction stops the actor pushing: acceleration and thrust
// go to zero. It does *not* touch the environmental force on that axis.
//
// It used to zero `e` and `v` as well, and that is a real bug rather
// than a style difference: `ey` is where the arcade backend accumulates
// gravity, so tapping down while falling zeroed the fall and the
// character stopped dead in mid-air. `tests/physics_sim.c` measures it
// (`vertical release keeps gravity`). Velocity is not the actor's to
// clear either -- akgl_physics_simulate recomputes `vx` as `ex + tx`
// every step, so whatever a handler writes there is overwritten before
// anything can read it.
memset(&actor, 0x00, sizeof(akgl_Actor));
actor.basechar = &basechar;
actor.ax = 5; actor.ex = 5; actor.tx = 5; actor.vx = 5;
@@ -453,22 +462,25 @@ akerr_ErrorContext *test_actor_control_handlers_off(void)
actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_LEFT);
TEST_EXPECT_OK(e, akgl_actor_cmhf_left_off(&actor, &event), "left off");
TEST_ASSERT_FEQ(e, actor.ax, 0.0f, "left off left ax at %f", actor.ax);
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "left off left ex at %f", actor.ex);
TEST_ASSERT_FEQ(e, actor.tx, 0.0f, "left off left tx at %f", actor.tx);
TEST_ASSERT_FEQ(e, actor.vx, 0.0f, "left off left vx at %f", actor.vx);
TEST_ASSERT_FEQ(e, actor.ex, 5.0f, "left off cleared ex (%f), which is the world's, not the actor's", actor.ex);
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT),
"left off did not clear MOVING_LEFT (state %d)", actor.state);
// Facing is deliberately sticky, so an idle actor keeps looking where it was.
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT),
"left off cleared FACE_LEFT, which should persist (state %d)", actor.state);
// The Y axis is untouched by a horizontal release.
TEST_ASSERT_FEQ(e, actor.vy, 5.0f, "left off disturbed vy (%f)", actor.vy);
TEST_ASSERT_FEQ(e, actor.ay, 5.0f, "left off disturbed ay (%f)", actor.ay);
TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "left off disturbed ey (%f)", actor.ey);
TEST_ASSERT_FEQ(e, actor.ty, 5.0f, "left off disturbed ty (%f)", actor.ty);
memset(&actor, 0x00, sizeof(akgl_Actor));
actor.ax = 5; actor.ex = 5; actor.tx = 5; actor.vx = 5;
actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT;
TEST_EXPECT_OK(e, akgl_actor_cmhf_right_off(&actor, &event), "right off");
TEST_ASSERT_FEQ(e, actor.vx, 0.0f, "right off left vx at %f", actor.vx);
TEST_ASSERT_FEQ(e, actor.ax, 0.0f, "right off left ax at %f", actor.ax);
TEST_ASSERT_FEQ(e, actor.tx, 0.0f, "right off left tx at %f", actor.tx);
TEST_ASSERT_FEQ(e, actor.ex, 5.0f, "right off cleared ex (%f)", actor.ex);
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT),
"right off did not clear MOVING_RIGHT (state %d)", actor.state);
@@ -476,7 +488,9 @@ akerr_ErrorContext *test_actor_control_handlers_off(void)
actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5;
actor.state = AKGL_ACTOR_STATE_MOVING_UP;
TEST_EXPECT_OK(e, akgl_actor_cmhf_up_off(&actor, &event), "up off");
TEST_ASSERT_FEQ(e, actor.vy, 0.0f, "up off left vy at %f", actor.vy);
TEST_ASSERT_FEQ(e, actor.ay, 0.0f, "up off left ay at %f", actor.ay);
TEST_ASSERT_FEQ(e, actor.ty, 0.0f, "up off left ty at %f", actor.ty);
TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "up off cleared ey (%f), which is where gravity accumulates", actor.ey);
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_UP),
"up off did not clear MOVING_UP (state %d)", actor.state);
@@ -484,7 +498,9 @@ akerr_ErrorContext *test_actor_control_handlers_off(void)
actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5;
actor.state = AKGL_ACTOR_STATE_MOVING_DOWN;
TEST_EXPECT_OK(e, akgl_actor_cmhf_down_off(&actor, &event), "down off");
TEST_ASSERT_FEQ(e, actor.vy, 0.0f, "down off left vy at %f", actor.vy);
TEST_ASSERT_FEQ(e, actor.ay, 0.0f, "down off left ay at %f", actor.ay);
TEST_ASSERT_FEQ(e, actor.ty, 0.0f, "down off left ty at %f", actor.ty);
TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "down off cleared ey (%f), which is where gravity accumulates", actor.ey);
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_DOWN),
"down off did not clear MOVING_DOWN (state %d)", actor.state);
} CLEANUP {

View File

@@ -531,20 +531,69 @@ akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void)
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&actor, &basechar, "thruster"));
// Thrust beyond the actor's max speed clamps to +sx, preserving sign.
// Over-thrust on one axis alone clamps to that axis's max speed,
// preserving sign. This is the case the old per-axis clamp got right,
// and it has to keep working.
actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f;
actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with positive over-thrust");
actor->tx = 500.0f; actor->ty = 0.0f; actor->tz = 0.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with single-axis over-thrust");
TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, 40.0f, "ty clamped to %f, expected 40", actor->ty);
actor->tx = -500.0f; actor->ty = 0.0f; actor->tz = 0.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative single-axis over-thrust");
TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);
actor->tx = 0.0f; actor->ty = -400.0f; actor->tz = 0.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative y over-thrust");
TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);
actor->tx = 0.0f; actor->ty = 0.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with z over-thrust");
TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz);
// Negative over-thrust clamps to -sx.
// Over-thrust on several axes at once scales the *vector* back to the
// ellipse the three max speeds describe, rather than clamping each axis
// against its own. Clamping the axes independently let the corner of the
// box through: an actor holding two directions travelled their diagonal,
// 41% faster than either alone, which is the classic diagonal-run bug.
// tests/physics_sim.c measures it (`top-down diagonal speed`).
//
// The two things that makes true are asserted here rather than the three
// numbers that fall out of them: the direction asked for is preserved,
// so every axis is scaled by the same factor, and the result lands
// exactly on the ellipse.
actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with over-thrust on every axis");
TEST_ASSERT_FEQ(e, actor->tx / actor->sx, actor->ty / actor->sy,
"x and y were scaled differently (%f vs %f), so the direction moved",
actor->tx / actor->sx, actor->ty / actor->sy);
TEST_ASSERT_FEQ(e, actor->ty / actor->sy, actor->tz / actor->sz,
"y and z were scaled differently (%f vs %f), so the direction moved",
actor->ty / actor->sy, actor->tz / actor->sz);
TEST_ASSERT_FEQ(e,
((actor->tx / actor->sx) * (actor->tx / actor->sx))
+ ((actor->ty / actor->sy) * (actor->ty / actor->sy))
+ ((actor->tz / actor->sz) * (actor->tz / actor->sz)),
1.0f,
"capped thrust (%f, %f, %f) is not on the max-speed ellipse",
actor->tx, actor->ty, actor->tz);
// Signs survive the scaling.
actor->tx = -500.0f; actor->ty = -400.0f; actor->tz = -300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust");
TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);
TEST_ASSERT_FEQ(e, actor->tz, -30.0f, "tz clamped to %f, expected -30", actor->tz);
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust on every axis");
TEST_ASSERT(e, (actor->tx < 0.0f) && (actor->ty < 0.0f) && (actor->tz < 0.0f),
"capping negative thrust changed a sign: (%f, %f, %f)",
actor->tx, actor->ty, actor->tz);
// An axis whose max speed is zero cannot be thrust along at all, and it
// stays out of the magnitude -- dividing by it would not end well.
actor->sz = 0.0f;
actor->tx = 10.0f; actor->ty = 0.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a zero max speed");
TEST_ASSERT_FEQ(e, actor->tz, 0.0f, "thrust survived a zero max speed as %f", actor->tz);
TEST_ASSERT_FEQ(e, actor->tx, 10.0f,
"a zero max speed on z disturbed in-range x thrust (%f)", actor->tx);
actor->sz = 30.0f;
// Thrust inside the limit is left alone.
actor->tx = 10.0f; actor->ty = -10.0f; actor->tz = 5.0f;

639
tests/physics_sim.c Normal file
View File

@@ -0,0 +1,639 @@
/**
* @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
* stepped with a caller-supplied dt. These tests set `gravity_time` to
* `now - dt` immediately before each call, which lands within a few
* microseconds of the intended step -- about 0.02% of a 60 Hz frame.
* Assertions carry tolerances to match. 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/physics.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`,
* so the only way to control it from outside is to place `gravity_time` the
* right distance behind the clock immediately before the call.
*/
static akerr_ErrorContext *sim_step(float32_t dt)
{
PREPARE_ERROR(errctx);
sim_physics.gravity_time = SDL_GetTicksNS() - (SDL_Time)(dt * (float32_t)AKGL_TIME_ONESEC_NS);
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;
}
int main(void)
{
PREPARE_ERROR(errctx);
int failures = 0;
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
// 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);
printf("\n%d of 7 simulations failed\n", failures);
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;
}