Files
libakgl/examples/sidescroller/main.c
Andrew Kesterson 490e62dbbf Delete the sidescroller's collision, and let the library do it
collision.c was 383 lines that re-implemented akgl_physics_simulate's own
arithmetic, because movementlogicfunc runs before gravity, drag and the move
and so had to predict where the actor would end up. The whole file goes. What
replaces it is three lines in main.c, a shape on the player and the blob, and a
`collidable` property on the map's terrain layer -- which also retires
SS_TERRAIN_LAYER_ID, a compiled-in layer id that broke whenever somebody
reordered layers in Tiled.

grounded stays a probe rather than becoming a contact test. A contact says
something pushed back this step; grounded asks whether there is a floor to push
off, and that stays true through the frame after landing. The blob keeps its own
wall and ledge probes for the same reason: the resolver does not say which side
of the blob it was pushed from, and says nothing at all about a floor that is
missing.

The summary line now carries the player's resting position, because exiting 0
was not evidence that collision ran. It reports 136.0,160.0 grounded after 240
autoplay frames -- feet at y=192, flush on the surface of terrain row 12.

The tutorial's collision section is rewritten around the library's, keeping the
quarter-of-a-pixel story as what a predicting resolver costs. The docs harness
learns `json excerpt=`, so the map property the chapter quotes is checked
against the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:35:08 -04:00

501 lines
18 KiB
C

/**
* @file main.c
* @brief Startup, the frame loop, and teardown for the sidescroller tutorial.
*
* The order everything happens in is the point of this file. libakgl has one
* startup sequence that works, documented at the top of `include/akgl/game.h`,
* and three of its steps are ones a reader gets wrong the first time:
*
* - the configuration properties have to be set *before* the renderer and the
* physics backend are initialized, because both read them;
* - `akgl_game_init` does **not** choose a physics backend, so the application
* has to;
* - characters name sprites and maps name characters, so the assets load
* sprites, then characters, then the map -- and the map creates the actors.
*/
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include "sidescroller.h"
/** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */
#ifndef SS_ASSET_DIR
#define SS_ASSET_DIR "."
#endif
/** @brief Longest asset path this program will build. */
#define SS_PATH_MAX 1024
ss_Game ss_game;
akgl_CollisionWorld ss_collision;
akerr_ErrorContext *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest)
{
SDL_FRect feet;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "shape");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
/* One pixel down, and only one: a taller probe reports a floor the actor is
* still falling towards, and a jump that fires off it looks like the player
* jumped out of thin air. */
PASS(errctx, akgl_collision_shape_bounds(shape, x, y + 1.0f, &feet));
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &feet, AKGL_COLLISION_LAYER_STATIC, dest));
SUCCEED_RETURN(errctx);
}
/**
* @brief The sprites, loaded in this order.
*
* Sprites first and characters second is not a preference. A character's JSON
* names its sprites by registry name and `akgl_character_load_json` looks each
* one up as it reads the mapping, so a character loaded first fails with
* `AKERR_NULLPOINTER` on the first sprite it cannot find.
*/
static char *ss_sprite_files[] = {
"sprite_ss_player_idle_left.json", /* one frame, held */
"sprite_ss_player_idle_right.json",
"sprite_ss_player_run_left.json", /* four frames at 90 ms */
"sprite_ss_player_run_right.json",
"sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */
"sprite_ss_player_jump_right.json",
"sprite_ss_coin.json",
"sprite_ss_hazard_blob.json",
"sprite_ss_hazard_moth.json",
NULL
};
/** @brief The characters. Each one binds state bitmasks to the sprites above. */
static char *ss_character_files[] = {
"character_ss_player.json",
"character_ss_coin.json",
"character_ss_hazard_blob.json",
"character_ss_hazard_moth.json",
NULL
};
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
static int ss_failed = 0;
/**
* @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame.
*
* The default is `akgl_game_lowfps`, and it fires on **every frame** the frame
* rate is under 30 -- including every frame of the first second of the process,
* because `akgl_game.fps` is a completed-second average and reads 0 until the
* first second is up. The hook exists to be replaced; the point of it is that a
* game can shed work rather than log about it. This one has nothing to shed.
*/
static void ss_lowfps(void)
{
}
/**
* @brief Join the asset directory and a file name into @p dest.
*
* `aksl_snprintf` rather than `snprintf`, because a path that does not fit has
* to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it
* reports itself later as a missing file with a name nobody wrote.
*/
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
{
int count = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
SUCCEED_RETURN(errctx);
}
/**
* @brief Bring the library up and open the window.
*
* `akgl_game.name`, `.version` and `.uri` are filled in first because
* `akgl_game_init` refuses to run without all three: the window title, SDL's
* application metadata and the savegame compatibility check are built from them.
*/
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.name,
sizeof(akgl_game.name),
"libakgl sidescroller tutorial",
sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.version,
sizeof(akgl_game.version),
"1.0.0",
sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.uri,
sizeof(akgl_game.uri),
"net.aklabs.libakgl.sidescroller",
sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
akgl_game.lowfpsfunc = &ss_lowfps;
/* Properties before the renderer: akgl_render_2d_init reads both of these
* out of the registry, and an unset one defaults to the string "0", which
* asks SDL for a zero-sized window. */
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
PASS(errctx, akgl_set_property("game.screenheight", "480"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
/*
* libakgl draws in map pixels and has no scale factor of its own -- the only
* scaling it applies is the tilemap's perspective band, which this map does
* not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more
* than that. SDL's logical presentation is the answer: the game renders a
* 480x240 view and SDL scales it up by whole multiples to fill the window.
*/
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
SS_VIEW_WIDTH,
SS_VIEW_HEIGHT,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
AKGL_ERR_SDL,
"%s",
SDL_GetError()
);
/* akgl_render_2d_init sized the camera from the window. The view is what the
* camera looks through, so it has to say the same thing. */
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = (float32_t)SS_VIEW_WIDTH;
akgl_camera->h = (float32_t)SS_VIEW_HEIGHT;
/*
* akgl_game_init does NOT choose a physics backend. It points akgl_physics
* at akgl_default_physics, which is zeroed storage -- all four of its method
* pointers are NULL -- and never initializes it. There is no
* `physics.engine` property either, whatever physics.h says. Skip this call
* and the first akgl_game_update calls through a NULL `simulate`.
*
* The map replaces this backend below with one of its own. It is still done
* here, because a game that loads a map without physics properties gets a
* working backend rather than a crash.
*/
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
SUCCEED_RETURN(errctx);
}
/**
* @brief Load the sprites, the characters and the level, in that order.
*/
static akerr_ErrorContext *load_level(char *assetdir)
{
char path[SS_PATH_MAX];
akgl_Actor *player = NULL;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_sprite_load_json((char *)&path));
}
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_character_load_json((char *)&path));
}
/*
* `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static
* storage in the library. That is not an accident and it is not a
* micro-optimisation: sizeof(akgl_Tilemap) is three times a default 8 MiB
* thread stack, so a local one is a segfault before the loader writes a
* byte.
*
* Loading the map creates every `actor` object in its object layers, binds
* each to the character its properties name, and publishes it in
* AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first.
*/
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
/*
* The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`,
* so the loader built a backend of its own and set `use_own_physics`.
* Nothing in the library acts on that flag: akgl_game_update steps the
* global akgl_physics and never looks at the map's. Honouring it is this
* line, and it is what lets a swimming level and a walking level differ by
* data rather than by code.
*/
if ( akgl_gamemap->use_own_physics == true ) {
akgl_physics = &akgl_gamemap->physics;
SDL_Log(
"Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s",
akgl_physics->gravity_y,
akgl_physics->drag_y,
(akgl_physics->gravity_y / akgl_physics->drag_y));
}
/*
* Collision is opt-in: a backend with no world attached moves an actor and
* consults nothing, which is what this game used to rely on and then spend
* 383 lines of its own working around. The cell arguments are placeholders
* -- akgl_collision_bind_tilemap overwrites them from the map's own tile
* size, and reads which layers are solid off each layer's `collidable`
* property rather than a layer id compiled into the game.
*/
PASS(errctx, akgl_collision_world_init(&ss_collision, NULL, (float32_t)SS_TILE_SIZE, (float32_t)SS_TILE_SIZE));
PASS(errctx, akgl_collision_bind_tilemap(&ss_collision, akgl_gamemap));
akgl_physics->collision = &ss_collision;
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
PASS(errctx, ss_player_bind(player));
PASS(errctx, ss_actors_bind());
PASS(errctx, ss_player_controls(0, "player"));
/*
* Re-stamp the clock the simulation measures against. Everything above --
* nine sprite files, four characters, a map and its tileset image -- happened
* between the backend being created and the first step, and dt is measured
* from `gravity_time`. The bound on `physics.max_timestep` would catch it,
* at the cost of one visibly slow-motion frame.
*/
akgl_physics->gravity_time = SDL_GetTicksNS();
SUCCEED_RETURN(errctx);
}
/**
* @brief Centre the camera on the player, clamped to the level.
*
* The camera is a plain `SDL_FRect` in map pixels that the library reads; moving
* it is the whole of scrolling. It is floored to a whole pixel because
* `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles
* to show, and a camera that is fractionally different every frame makes the
* tile grid shimmer.
*/
static akerr_ErrorContext *update_camera(void)
{
float32_t limit = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera");
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
if ( akgl_camera->x > limit ) {
akgl_camera->x = limit;
}
if ( akgl_camera->x < 0.0f ) {
akgl_camera->x = 0.0f;
}
akgl_camera->x = (float32_t)((int)akgl_camera->x);
akgl_camera->y = 0.0f;
SUCCEED_RETURN(errctx);
}
/**
* @brief One frame: events, then the camera, then the library's own tick.
*
* `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It
* does not clear or present, so the frame is bracketed by the backend's
* `frame_start` and `frame_end` here.
*/
static akerr_ErrorContext *frame(bool *running)
{
SDL_Event event;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
while ( SDL_PollEvent(&event) == true ) {
if ( event.type == SDL_EVENT_QUIT ) {
*running = false;
}
/* Every event, unconditionally: one that no control map binds is not an
* error, it is a call that did nothing. */
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
ss_game.frame += 1;
if ( ss_game.autoplay == true ) {
PASS(errctx, ss_player_autoplay(ss_game.frame));
}
PASS(errctx, update_camera());
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
/*
* Note the failure contract: akgl_game_update takes the game state lock and
* every one of its failure paths returns with the lock still held. SDL
* mutexes are recursive so a single-threaded loop does not deadlock on the
* next frame, but a frame that failed has left the world half-stepped.
* Treat it as terminal, which is what PASS does here.
*/
PASS(errctx, akgl_game_update(NULL));
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *run(int frames)
{
bool running = true;
PREPARE_ERROR(errctx);
while ( running == true ) {
PASS(errctx, frame(&running));
if ( (frames > 0) && (ss_game.frame >= frames) ) {
running = false;
}
/* A crude frame limiter. A game with a window on a real display should
* ask SDL for vsync instead; this one has to work under the dummy video
* driver, where there is nothing to sync to. */
SDL_Delay(16);
}
/* Recorded here and not in main(): shutdown_game() runs in main()'s CLEANUP
* block and releases the actor pool, so ss_game.player is stale by the time
* the summary is printed. */
if ( ss_game.player != NULL ) {
ss_game.final_x = ss_game.player->x;
ss_game.final_y = ss_game.player->y;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Give back what the process is holding.
*
* **There is no `akgl_game_shutdown`.** Teardown is the application's, and the
* order matters in one place: `akgl_text_unloadallfonts` has to run before
* `TTF_Quit` or `SDL_Quit`, because those destroy the fonts underneath the
* registry that still points at them.
*
* The pools are not walked beyond the actors. They are static storage in a
* process that is exiting, and the sprites and characters are still referenced
* by each other; a game that loads a second level has to unwind that properly,
* and this one does not pretend to.
*/
static void shutdown_game(void)
{
int i = 0;
IGNORE(akgl_text_unloadallfonts());
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount > 0 ) {
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
}
}
/* Guarded: this runs from CLEANUP, which is reached even when startup failed
* before akgl_game_init pointed akgl_gamemap at anything. */
if ( akgl_gamemap != NULL ) {
IGNORE(akgl_tilemap_release(akgl_gamemap));
}
TTF_Quit();
MIX_Quit();
SDL_Quit();
}
static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames)
{
const char *env = NULL;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES");
if ( env != NULL ) {
PASS(errctx, aksl_atoi((char *)env, frames));
}
for ( i = 1; i < argc; i++ ) {
if ( strcmp(argv[i], "--autoplay") == 0 ) {
ss_game.autoplay = true;
} else if ( strcmp(argv[i], "--frames") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
PASS(errctx, aksl_atoi(argv[i], frames));
} else if ( strcmp(argv[i], "--assets") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
*assetdir = argv[i];
} else {
FAIL_RETURN(
errctx,
AKERR_VALUE,
"usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
);
}
}
SUCCEED_RETURN(errctx);
}
int main(int argc, char *argv[])
{
char *assetdir = SS_ASSET_DIR;
int frames = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, parse_args(argc, argv, &assetdir, &frames));
CATCH(errctx, startup());
CATCH(errctx, load_level(assetdir));
CATCH(errctx, run(frames));
} CLEANUP {
shutdown_game();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
/*
* Set a flag rather than returning: leaving a HANDLE block early skips
* FINISH's RELEASE_ERROR and leaks the context's pool slot, and the
* 129th such call aborts the process. AGENTS.md spells it out.
*/
ss_failed = 1;
/*
* FINISH_NORETURN rather than FINISH: FINISH expands a
* `return __err_context` that an int-returning function cannot compile,
* even on a branch that cannot be reached.
*/
} FINISH_NORETURN(errctx);
/*
* The position is in the summary because exiting 0 is not evidence that
* collision ran. The map is 240 pixels tall and gravity is 900 px/s^2: an
* unresolved player is hundreds of pixels below the floor within a second,
* and this line is what makes that visible in a CI log instead of passing.
*/
SDL_Log(
"sidescroller: %d frames, %d of %d coins, %d deaths, player at %.1f,%.1f%s",
ss_game.frame,
ss_game.coins_taken,
SS_COIN_COUNT,
ss_game.deaths,
ss_game.final_x,
ss_game.final_y,
((ss_game.grounded == true) ? " grounded" : " airborne"));
return ss_failed;
}