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:
2026-08-01 20:59:00 -04:00
parent b938460127
commit 88aaa184e4
72 changed files with 8946 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
/**
* @file actors.c
* @brief Everything level1.tmj places that is not the player.
*
* None of these are created here. The map's object layer creates all seven
* actors, registers them under the names Tiled gave them, and binds each one to
* the character its `character` property names -- so this file's whole job is to
* find them again by name and give them behaviour.
*
* Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is the one
* status in libakgl that is not a failure. Raised from a `movementlogicfunc` it
* means "skip the rest of this tick for me": no gravity, no drag, no move for
* that actor this step, and akgl_physics_simulate carries on to the next one.
* That is exactly what an actor that has just placed itself wants.
*/
#include <math.h>
#include <akstdlib.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include "sidescroller.h"
/** @brief The blob's collision box, offset into its 32x32 frame. */
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
/**
* @brief Per-actor data for everything in this file, one slot per actor.
*
* A fixed table rather than an allocation, for the same reason the library has
* pools rather than a `malloc`: the level places a known number of things, and a
* game that cannot run out of memory at runtime is one fewer failure mode.
*/
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
/**
* @brief Look an actor up by the name its Tiled object carried.
*
* A miss here means the map and the code disagree about what the level
* contains, which is worth failing on rather than working around.
*/
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
SUCCEED_RETURN(errctx);
}
/**
* @brief A `movementlogicfunc` for something that does not move at all.
*
* The coins need this. Their character declares no speed and no acceleration, so
* they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity`
* accumulates into `ey` for every actor it is handed, so a coin left to the
* default logic falls out of the level along with everything else.
*
* Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its
* step. It is not an error and nothing logs it.
*/
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
{
PREPARE_ERROR(errctx);
(void)dt;
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
}
/**
* @brief The blob: walk, and turn around at a wall or a ledge.
*
* This one stays inside the physics step -- it walks on the ground under the
* map's gravity like the player does, and uses the same swept resolution.
*/
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
{
ss_ActorData *data = NULL;
ss_Contact contact;
float32_t probe_x = 0.0f;
float32_t probe_y = 0.0f;
bool floor_ahead = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
data = (ss_ActorData *)obj->actorData;
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
if ( data->facing < 0.0f ) {
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
} else {
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
}
/* Signs the acceleration from the movement bits just set. */
PASS(errctx, akgl_actor_logic_movement(obj, dt));
PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
/* One pixel past the leading edge of the box and one pixel below its feet:
* no floor there means the next step walks off. */
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
if ( data->facing < 0.0f ) {
probe_x = obj->x + ss_blob_body.x - 1.0f;
} else {
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
}
PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
data->facing = -data->facing;
/* The resolution already zeroed `tx` on a blocked axis; zero it on the
* ledge path too, or the blob keeps its old momentum through the turn. */
obj->tx = 0.0f;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief The moth: a figure-eight around where the map put it.
*
* It flies, so it wants no gravity -- and the map's physics has gravity, because
* the player needs it. Rather than fighting the backend the moth writes its own
* position and then opts out of the rest of the step, which is the second thing
* #AKGL_ERR_LOGICINTERRUPT is for.
*/
static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt)
{
ss_ActorData *data = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
data = (ss_ActorData *)obj->actorData;
data->phase += dt;
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL);
if ( cosf(data->phase) < 0.0f ) {
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT);
} else {
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT);
}
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
}
akerr_ErrorContext *ss_actors_bind(void)
{
char name[32];
int count = 0;
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
}
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
ss_hazard_data[0].facing = -1.0f;
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
ss_hazard_data[1].facing = 1.0f;
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
SUCCEED_RETURN(errctx);
}