640 lines
23 KiB
C
640 lines
23 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
|
||
|
|
* 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;
|
||
|
|
}
|