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>
This commit is contained in:
383
examples/sidescroller/collision.c
Normal file
383
examples/sidescroller/collision.c
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user