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
2026-08-01 12:10:53 -04:00
|
|
|
/**
|
|
|
|
|
* @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
|
Report the failures libakstdlib's wrappers were already catching
Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather
than new code, so the interesting part is what libakgl was not yet
calling: it links libakstdlib and uses ten of its wrappers while calling
the raw libc function at a good many more sites. Four of those were
carrying a real defect.
akgl_game_save checked every write and threw away the close. Every write
goes through aksl_fwrite, and for a record this size all of them land in
stdio's buffer -- nothing reaches the device until the close flushes it,
so a full disk, an exceeded quota or an NFS server going away is
reported only there. The function returned success over a savegame that
was never written. aksl_fclose surfaces it. tests/game.c reaches the
path through /dev/full, which accepts writes and fails at the flush;
against the unfixed code the test reports "expected a failure, got
success" with ENOSPC.
The close has to drop the FILE * before the status is examined, because
fclose(3) disassociates the stream either way and CLEANUP would close it
again. Written the other way round it aborted in glibc with "double free
detected in tcache" the first time a close actually failed, which is how
that ordering was found.
akgl_game_load's end-of-file check used fgetc(3), which spells "end of
file" and "the read failed" with the same EOF -- a disk error there read
as a clean end and passed the check it was meant to fail. aksl_fgetc
tells them apart. Extracted to require_at_eof, because inverting the
sense of a call inline needs a nested ATTEMPT whose break binds to the
wrong switch.
Two snprintf sites were truncating under a silenced
-Wformat-truncation. The compiler was right about both. The tileset one
handed the shortened path to IMG_LoadTexture, so a length error was
reported as a missing file, with SDL naming a path the caller never
wrote. Both use aksl_snprintf now and the suppression is gone; nothing
in the tree uses those macros any more. tests/tilemap.c covers the join,
and against the unfixed code it gets AKGL_ERR_SDL where it wants
AKERR_OUTOFBOUNDS.
The two src/game.c strncpy sites the fixed-width copy sweep missed --
the savegame name field and the libversion stamp -- use aksl_strncpy and
sizeof rather than a repeated 32.
Also makes tests/physics_sim.c deterministic. It placed gravity_time at
"now - dt" and let the real clock supply the step, so a machine busy
enough to deschedule the process between that store and the
SDL_GetTicksNS() inside simulate got a longer step. It went red once,
under a parallel ctest. It now sets max_timestep to the step it wants
and gravity_time to zero, so the bound supplies the step and the
scheduler cannot reach it.
TODO.md records what is deliberately not adopted: the pure-arithmetic
wrappers, which can only fail on NULL and which the tree already spells
two ways, and the collections, which the registries do not want because
SDL owns the property-set lifetime. AGENTS.md has the rule and the
fclose ordering trap.
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
2026-08-01 12:36:36 -04:00
|
|
|
* 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.
|
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
2026-08-01 12:10:53 -04:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#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.
|
|
|
|
|
*
|
Report the failures libakstdlib's wrappers were already catching
Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather
than new code, so the interesting part is what libakgl was not yet
calling: it links libakstdlib and uses ten of its wrappers while calling
the raw libc function at a good many more sites. Four of those were
carrying a real defect.
akgl_game_save checked every write and threw away the close. Every write
goes through aksl_fwrite, and for a record this size all of them land in
stdio's buffer -- nothing reaches the device until the close flushes it,
so a full disk, an exceeded quota or an NFS server going away is
reported only there. The function returned success over a savegame that
was never written. aksl_fclose surfaces it. tests/game.c reaches the
path through /dev/full, which accepts writes and fails at the flush;
against the unfixed code the test reports "expected a failure, got
success" with ENOSPC.
The close has to drop the FILE * before the status is examined, because
fclose(3) disassociates the stream either way and CLEANUP would close it
again. Written the other way round it aborted in glibc with "double free
detected in tcache" the first time a close actually failed, which is how
that ordering was found.
akgl_game_load's end-of-file check used fgetc(3), which spells "end of
file" and "the read failed" with the same EOF -- a disk error there read
as a clean end and passed the check it was meant to fail. aksl_fgetc
tells them apart. Extracted to require_at_eof, because inverting the
sense of a call inline needs a nested ATTEMPT whose break binds to the
wrong switch.
Two snprintf sites were truncating under a silenced
-Wformat-truncation. The compiler was right about both. The tileset one
handed the shortened path to IMG_LoadTexture, so a length error was
reported as a missing file, with SDL naming a path the caller never
wrote. Both use aksl_snprintf now and the suppression is gone; nothing
in the tree uses those macros any more. tests/tilemap.c covers the join,
and against the unfixed code it gets AKGL_ERR_SDL where it wants
AKERR_OUTOFBOUNDS.
The two src/game.c strncpy sites the fixed-width copy sweep missed --
the savegame name field and the libversion stamp -- use aksl_strncpy and
sizeof rather than a repeated 32.
Also makes tests/physics_sim.c deterministic. It placed gravity_time at
"now - dt" and let the real clock supply the step, so a machine busy
enough to deschedule the process between that store and the
SDL_GetTicksNS() inside simulate got a longer step. It went red once,
under a parallel ctest. It now sets max_timestep to the step it wants
and gravity_time to zero, so the bound supplies the step and the
scheduler cannot reach it.
TODO.md records what is deliberately not adopted: the pure-arithmetic
wrappers, which can only fail on NULL and which the tree already spells
two ways, and the collections, which the registries do not want because
SDL owns the property-set lifetime. AGENTS.md has the rule and the
fclose ordering trap.
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
2026-08-01 12:36:36 -04:00
|
|
|
* 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.
|
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
2026-08-01 12:10:53 -04:00
|
|
|
*/
|
|
|
|
|
static akerr_ErrorContext *sim_step(float32_t dt)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
Report the failures libakstdlib's wrappers were already catching
Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather
than new code, so the interesting part is what libakgl was not yet
calling: it links libakstdlib and uses ten of its wrappers while calling
the raw libc function at a good many more sites. Four of those were
carrying a real defect.
akgl_game_save checked every write and threw away the close. Every write
goes through aksl_fwrite, and for a record this size all of them land in
stdio's buffer -- nothing reaches the device until the close flushes it,
so a full disk, an exceeded quota or an NFS server going away is
reported only there. The function returned success over a savegame that
was never written. aksl_fclose surfaces it. tests/game.c reaches the
path through /dev/full, which accepts writes and fails at the flush;
against the unfixed code the test reports "expected a failure, got
success" with ENOSPC.
The close has to drop the FILE * before the status is examined, because
fclose(3) disassociates the stream either way and CLEANUP would close it
again. Written the other way round it aborted in glibc with "double free
detected in tcache" the first time a close actually failed, which is how
that ordering was found.
akgl_game_load's end-of-file check used fgetc(3), which spells "end of
file" and "the read failed" with the same EOF -- a disk error there read
as a clean end and passed the check it was meant to fail. aksl_fgetc
tells them apart. Extracted to require_at_eof, because inverting the
sense of a call inline needs a nested ATTEMPT whose break binds to the
wrong switch.
Two snprintf sites were truncating under a silenced
-Wformat-truncation. The compiler was right about both. The tileset one
handed the shortened path to IMG_LoadTexture, so a length error was
reported as a missing file, with SDL naming a path the caller never
wrote. Both use aksl_snprintf now and the suppression is gone; nothing
in the tree uses those macros any more. tests/tilemap.c covers the join,
and against the unfixed code it gets AKGL_ERR_SDL where it wants
AKERR_OUTOFBOUNDS.
The two src/game.c strncpy sites the fixed-width copy sweep missed --
the savegame name field and the libversion stamp -- use aksl_strncpy and
sizeof rather than a repeated 32.
Also makes tests/physics_sim.c deterministic. It placed gravity_time at
"now - dt" and let the real clock supply the step, so a machine busy
enough to deschedule the process between that store and the
SDL_GetTicksNS() inside simulate got a longer step. It went red once,
under a parallel ctest. It now sets max_timestep to the step it wants
and gravity_time to zero, so the bound supplies the step and the
scheduler cannot reach it.
TODO.md records what is deliberately not adopted: the pure-arithmetic
wrappers, which can only fail on NULL and which the tree already spells
two ways, and the collections, which the registries do not want because
SDL owns the property-set lifetime. AGENTS.md has the rule and the
fclose ordering trap.
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
2026-08-01 12:36:36 -04:00
|
|
|
sim_physics.max_timestep = (float64_t)dt;
|
|
|
|
|
sim_physics.gravity_time = 0;
|
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
2026-08-01 12:10:53 -04:00
|
|
|
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());
|
|
|
|
|
// 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;
|
|
|
|
|
}
|