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>
457 lines
16 KiB
C
457 lines
16 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;
|
|
|
|
/**
|
|
* @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));
|
|
}
|
|
|
|
PASS(errctx, ss_collide_bind(akgl_gamemap));
|
|
|
|
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);
|
|
}
|
|
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);
|
|
|
|
SDL_Log(
|
|
"sidescroller: %d frames, %d of %d coins, %d deaths",
|
|
ss_game.frame,
|
|
ss_game.coins_taken,
|
|
SS_COIN_COUNT,
|
|
ss_game.deaths);
|
|
return ss_failed;
|
|
}
|