Files
libakgl/examples/sidescroller/collision.c
Andrew Kesterson 88aaa184e4 Add two tutorial games that build, run in CI, and cannot drift
examples/sidescroller and examples/jrpg are complete programs, built with the
library and exercised headless by ctest. The chapters quote them with
`c excerpt=examples/...` blocks rather than restating the code, so a chapter
cannot drift from a program that compiles -- the excerpt check fails the moment
the source moves. 34 excerpts in one chapter, 21 in the other.

The two are complementary. The sidescroller is the physics tutorial: gravity, a
jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map,
NPCs spawned from map objects, four-way per-facing animation, a text box, a
follower.

Both smoke tests drive real SDL_Events through akgl_controller_handle_event and
step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run
is deterministic and finishes in under five seconds.

Writing them is what turned up most of the defects recorded in the next commit,
because a game exercises paths a unit test does not. Each workaround says in the
chapter which library gap forced it:

- collision is written in a custom movementlogicfunc, because
  akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never
  calls collide at all;
- the sidescroller cancels the step's own gravity when it blocks downward,
  because otherwise a quarter-pixel of penetration makes the *horizontal* sweep
  report blocked and the character walks backwards a tile at a time;
- both clear movement_controls_face on every map-spawned actor, because the
  default facefunc leaves a stopped actor with no facing bit, no sprite, and no
  draw;
- the JRPG's follower gets a renderfunc that nulls obj->parent for the duration
  of the draw, because a child's offset is counted twice.

Assets are CC0 from three Kenney packs, vendored with per-pack licence text,
per-file provenance, and the geometry contract in
docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a
reader who copies a tutorial into their own game inherits no obligation.
scripts/fetch_tutorial_assets.sh refreshes them in the shape
mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status,
refuses a pack page that does not say CC0, verifies the archive and the staged
dimensions, and leaves the tracked bytes untouched on any failure. Both failure
paths were tested, and a no-op refresh is byte-identical.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00

384 lines
13 KiB
C

/**
* @file collision.c
* @brief The collision libakgl does not have.
*
* This file exists because of a gap, and the gap is worth stating exactly:
*
* - `akgl_physics_arcade_collide` raises `AKERR_API` with the message "Not
* implemented".
* - `akgl_physics_simulate` never calls `collide` at all -- not for the arcade
* backend, not for the null one. The vtable slot exists and the simulation
* does not use it.
* - `akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.
* It does not clamp to the map, consult the tilemap, or test anything.
*
* So an actor walks through a wall and off the edge of the world, and the only
* hook that runs inside the physics step is the actor's own `movementlogicfunc`.
* That is where this game's collision lives, and everything here is called from
* there.
*
* The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
* drag, the velocity recomputation and `move`, so it cannot look at where the
* actor ended up -- the step has not happened yet. ss_collide_predict therefore
* repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
* resolves against that predicted position. Get the prediction wrong and the
* actor is resolved against a step it never takes.
*/
#include <math.h>
#include <akstdlib.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include "sidescroller.h"
/**
* @brief Half a pixel-thousandth, taken off the far edge of a box before it is
* turned into tile indices.
*
* A box whose right edge sits exactly on a tile boundary does not overlap the
* tile on the far side of it. Without this, an actor standing flush against a
* wall reads as inside it, and the sweep snaps it back a tile every frame.
*/
#define SS_COLLIDE_EPSILON 0.001f
/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
#define SS_COLLIDE_SETTLE_TILES 4
static akgl_TilemapLayer *ss_terrain = NULL;
/**
* @brief Is this map cell solid?
*
* Off the left or right edge of the map is solid, so the level has walls at its
* ends. Off the top or the bottom is not: the sky is open and the pit in the
* middle of level1.tmj has to be fallable-into, which is the whole point of it.
*/
static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
*dest = true;
} else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
*dest = false;
} else {
/* A tile layer's data is global tile ids in row-major order, and 0 is
* the empty cell. Any tile at all on the terrain layer is solid: the
* level says what is solid by which layer the tile is drawn on. */
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
{
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
ss_terrain = NULL;
for ( i = 0; i < map->numlayers; i++ ) {
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
ss_terrain = &map->layers[i];
}
}
FAIL_ZERO_RETURN(
errctx,
ss_terrain,
AKERR_KEY,
"Map has no tile layer with id %d to collide against",
SS_TERRAIN_LAYER_ID
);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
{
int x0 = 0;
int x1 = 0;
int y0 = 0;
int y1 = 0;
int tilex = 0;
int tiley = 0;
bool solid = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_NONZERO_RETURN(
errctx,
((box->w <= 0.0f) || (box->h <= 0.0f)),
AKERR_VALUE,
"Collision box is %fx%f; both sides must be positive",
box->w,
box->h
);
*dest = false;
x0 = (int)floorf(box->x / SS_TILE_SIZE);
x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
y0 = (int)floorf(box->y / SS_TILE_SIZE);
y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
for ( tiley = y0; tiley <= y1; tiley++ ) {
for ( tilex = x0; tilex <= x1; tilex++ ) {
PASS(errctx, solid_tile(tilex, tiley, &solid));
if ( solid == true ) {
*dest = true;
SUCCEED_RETURN(errctx);
}
}
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
{
float32_t ex = 0.0f;
float32_t ey = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
/*
* Everything below is akgl_physics_simulate's own arithmetic, in its own
* order: gravity onto the environmental term, then drag off it, then
* velocity as environmental plus thrust, then position plus velocity times
* dt. The `!= 0` guards are the library's too -- it skips each axis whose
* constant is zero rather than multiplying by it -- and are repeated here
* because dropping them would make a zero-gravity axis pick up drag.
*
* This mirrors the *arcade* backend. Against the null backend gravity does
* nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
* right.
*/
ex = obj->ex;
ey = obj->ey;
if ( akgl_physics->gravity_x != 0 ) {
ex -= (float32_t)akgl_physics->gravity_x * dt;
}
if ( akgl_physics->gravity_y != 0 ) {
ey += (float32_t)akgl_physics->gravity_y * dt;
}
if ( akgl_physics->drag_x != 0 ) {
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
}
if ( akgl_physics->drag_y != 0 ) {
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
}
*dx = (ex + obj->tx) * dt;
*dy = (ey + obj->ty) * dt;
SUCCEED_RETURN(errctx);
}
/**
* @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
*
* Each axis is tested on its own, which is what lets an actor slide along a wall
* instead of sticking to it, and the sub-step is capped at half a tile so a
* fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
* the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
* step, which is nearly two tiles -- so this is not a theoretical concern.
*
* On a blocked axis the box is snapped to the tile boundary it was about to
* cross rather than simply not moved, so an actor lands flush on a floor at
* whatever speed it arrives.
*/
static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
{
SDL_FRect trial;
float32_t span = 0.0f;
float32_t stepx = 0.0f;
float32_t stepy = 0.0f;
int steps = 0;
int i = 0;
bool solid = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
span = fabsf(dx);
if ( fabsf(dy) > span ) {
span = fabsf(dy);
}
steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
stepx = dx / (float32_t)steps;
stepy = dy / (float32_t)steps;
for ( i = 0; i < steps; i++ ) {
if ( stepx != 0.0f ) {
trial = *box;
trial.x += stepx;
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
if ( solid == true ) {
if ( stepx > 0.0f ) {
box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
} else {
box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
}
stepx = 0.0f;
dest->blocked_x = true;
} else {
box->x = trial.x;
}
}
if ( stepy != 0.0f ) {
trial = *box;
trial.y += stepy;
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
if ( solid == true ) {
if ( stepy > 0.0f ) {
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
} else {
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
}
stepy = 0.0f;
dest->blocked_y = true;
} else {
box->y = trial.y;
}
}
if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
break;
}
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
{
SDL_FRect box;
SDL_FRect probe;
float32_t dx = 0.0f;
float32_t dy = 0.0f;
bool solid = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
dest->blocked_x = false;
dest->blocked_y = false;
dest->grounded = false;
PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
box.x = obj->x + body->x;
box.y = obj->y + body->y;
box.w = body->w;
box.h = body->h;
PASS(errctx, sweep(&box, dx, dy, dest));
/*
* Only a blocked axis is written back. The free axis is left for
* akgl_physics_arcade_move to advance by exactly the amount this function
* predicted -- writing it here as well would move the actor twice.
*
* `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
* effort; a blocked axis has to give up both, or the actor keeps pressing
* into the wall and the next step's prediction is wrong by everything it
* accumulated while stuck.
*
* The environmental term is not set to zero, it is set to *minus one step of
* gravity*, and that is not a nicety. Zeroing it leaves the step about to add
* `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
* quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
* still fatal: the box now overlaps the floor tile, so the *horizontal*
* sweep on the next step finds itself blocked wherever it is, and the actor
* is snapped back a whole tile every time it tries to walk. Pre-loading the
* cancellation leaves the actor resting exactly on the surface instead.
*/
if ( dest->blocked_x == true ) {
obj->x = box.x - body->x;
obj->ex = (float32_t)akgl_physics->gravity_x * dt;
obj->tx = 0.0f;
}
if ( dest->blocked_y == true ) {
obj->y = box.y - body->y;
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
obj->ty = 0.0f;
}
/*
* Standing on a floor is not a state the library records, so it is measured:
* one pixel below where the box will be at the end of this step. Note that
* zeroing `ey` above does not stop gravity -- the step still adds
* `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
* fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
* it straight back off, so a standing actor rests within a pixel of the
* surface forever rather than sinking through it.
*/
probe = box;
probe.y += 1.0f;
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
dest->grounded = solid;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
{
SDL_FRect box;
bool solid = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
/*
* A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
* actor can start out with its box partly inside a step -- level1.tmj puts
* the player at x=32 and a block at tiles (3,11), which is under the right
* half of the player's frame.
*
* The swept resolution cannot fix that. It stops an actor *entering*
* terrain and has nothing to say about one that began inside it; what it
* does instead is refuse every horizontal move, because the box is already
* blocked wherever it goes. So a spawn point gets lifted clear once, before
* the first step, a tile at a time.
*/
for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
box.x = obj->x + body->x;
box.y = obj->y + body->y;
box.w = body->w;
box.h = body->h;
PASS(errctx, ss_collide_box_blocked(&box, &solid));
if ( solid == false ) {
SUCCEED_RETURN(errctx);
}
obj->y -= (float32_t)SS_TILE_SIZE;
SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
}
FAIL_RETURN(
errctx,
AKERR_VALUE,
"%s spawned inside more than %d tiles of terrain at %f, %f",
(char *)obj->name,
SS_COLLIDE_SETTLE_TILES,
obj->x,
obj->y
);
}