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>
370 lines
14 KiB
C
370 lines
14 KiB
C
/**
|
|
* @file jrpg.c
|
|
* @brief A small top-down JRPG: startup, the frame loop, and teardown.
|
|
*
|
|
* Chapter 20 of the manual quotes this program rather than restating it, so
|
|
* what is here is what the chapter teaches. Run it with no arguments for a
|
|
* window you can walk around in:
|
|
*
|
|
* ./examples/jrpg/jrpg
|
|
*
|
|
* Arrow keys walk, space talks to whoever is standing next to you and dismisses
|
|
* the box again. `--frames N` stops after N frames, which is what makes this
|
|
* runnable as a headless smoke test; `--demo` drives the arrow keys from a
|
|
* script and steps the physics clock by a fixed interval so the run is
|
|
* deterministic and takes no wall-clock time.
|
|
*/
|
|
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3_ttf/SDL_ttf.h>
|
|
#include <akerror.h>
|
|
#include <akstdlib.h>
|
|
|
|
#include <akgl/controller.h>
|
|
#include <akgl/error.h>
|
|
#include <akgl/game.h>
|
|
#include <akgl/iterator.h>
|
|
#include <akgl/physics.h>
|
|
#include <akgl/registry.h>
|
|
#include <akgl/renderer.h>
|
|
#include <akgl/text.h>
|
|
#include <akgl/tilemap.h>
|
|
|
|
#include "jrpg.h"
|
|
|
|
/** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */
|
|
#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
|
|
/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
|
|
#define JRPG_FRAME_BUDGET_MS 16
|
|
|
|
/*
|
|
* The scripted walk `--demo` drives. The player starts at (224, 256) and the
|
|
* elder stands at (272, 112), so this is: right for a second, up for two and a
|
|
* bit, talk, try to walk while frozen, dismiss, walk away. Every one of those
|
|
* is a path the interactive game has and a headless run would otherwise never
|
|
* touch.
|
|
*
|
|
* Frame Key Down What it is for
|
|
*/
|
|
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
|
|
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
|
|
{ 70, SDLK_RIGHT, false },
|
|
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
|
|
{ 205, SDLK_UP, false },
|
|
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
|
|
{ 216, SDLK_SPACE, false },
|
|
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
|
|
{ 245, SDLK_LEFT, false },
|
|
{ 255, SDLK_SPACE, true }, /* dismiss */
|
|
{ 256, SDLK_SPACE, false },
|
|
{ 265, SDLK_DOWN, true }, /* and walk away */
|
|
{ 285, SDLK_DOWN, false }
|
|
};
|
|
|
|
#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0]))
|
|
|
|
static long frame_limit = 0;
|
|
static bool demo = false;
|
|
static bool running = true;
|
|
static int exitstatus = 0;
|
|
static bool lowfps_warned = false;
|
|
|
|
/**
|
|
* @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second.
|
|
*
|
|
* akgl_game.fps is a completed-second average, so it reads 0 for the first
|
|
* second of every process -- which is under the threshold, so the default hook
|
|
* logs a line on every frame until the first second is up. The hook exists to
|
|
* be replaced; a real game sheds work here rather than talking about it.
|
|
*/
|
|
static void lowfps_quiet(void)
|
|
{
|
|
if ( lowfps_warned == false ) {
|
|
lowfps_warned = true;
|
|
SDL_Log("Frame rate is under 30 and this game does nothing about it");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Read `--frames N` and `--demo` off the command line.
|
|
*
|
|
* @param argc Argument count, from `main`.
|
|
* @param argv Argument vector, from `main`. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p argv is `NULL`.
|
|
* @throws AKERR_VALUE If `--frames` is last, or its argument is not a number.
|
|
*/
|
|
static akerr_ErrorContext *parse_args(int argc, char *argv[])
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
int i = 0;
|
|
int frames = 0;
|
|
|
|
FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv");
|
|
|
|
for ( i = 1; i < argc; i++ ) {
|
|
if ( strcmp(argv[i], "--demo") == 0 ) {
|
|
demo = true;
|
|
} else if ( strcmp(argv[i], "--frames") == 0 ) {
|
|
if ( (i + 1) >= argc ) {
|
|
FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count");
|
|
}
|
|
i += 1;
|
|
PASS(errctx, aksl_atoi(argv[i], &frames));
|
|
frame_limit = frames;
|
|
} else {
|
|
FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]");
|
|
}
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Bring the library up, load the town, and bind the controls.
|
|
*
|
|
* The order below is the one game.h documents and the only one that works.
|
|
* Three things in it are not obvious and are what chapter 20 spends its time
|
|
* on: the properties have to be set between akgl_game_init and
|
|
* akgl_render_2d_init, because that is what reads them; the physics backend has
|
|
* to be initialized by hand, because akgl_game_init does not; and the assets
|
|
* have to be loaded sprites-then-characters-then-map.
|
|
*
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
*/
|
|
static akerr_ErrorContext *startup(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
akgl_Control talk;
|
|
|
|
// Required before akgl_game_init, which refuses to run without all three:
|
|
// the window title, SDL's application metadata and the savegame
|
|
// compatibility check are built from them.
|
|
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", sizeof(akgl_game.name) - 1));
|
|
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1));
|
|
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.jrpg", sizeof(akgl_game.uri) - 1));
|
|
|
|
PASS(errctx, akgl_game_init());
|
|
akgl_game.lowfpsfunc = &lowfps_quiet;
|
|
|
|
PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH));
|
|
PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT));
|
|
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
|
|
|
// akgl_game_init points akgl_physics at akgl_default_physics and stops
|
|
// there. That storage is BSS, so all four of its method pointers are NULL,
|
|
// and akgl_game_update calls `akgl_physics->simulate(...)` without checking
|
|
// it: a NULL function pointer, and a segmentation fault on frame one rather
|
|
// than an error context.
|
|
//
|
|
// town.tmj happens to declare its own physics, and jrpg_world_load switches
|
|
// to the backend that builds -- so removing this line alone leaves *this*
|
|
// program working, and removing it along with that switch is a SIGSEGV on
|
|
// frame one. Measured, by removing both. This line is what makes the
|
|
// program correct for a map that declares no physics, which is most of
|
|
// them. Nothing calls the factory for you either way. A top-down game wants
|
|
// the arcade backend with no gravity, which is what the property defaults
|
|
// already give.
|
|
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
|
|
|
PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE));
|
|
|
|
PASS(errctx, jrpg_world_load());
|
|
PASS(errctx, jrpg_world_populate());
|
|
|
|
// The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in
|
|
// one call. Keyboard id 0 and gamepad id 0 mean "the first of each".
|
|
PASS(errctx, akgl_controller_default(0, "player", 0, 0));
|
|
|
|
// And one binding of our own on the same map. A binding is a struct, copied
|
|
// in, so a stack local is fine. handler_off has to be non-NULL --
|
|
// akgl_controller_handle_event does not check it before calling it -- and
|
|
// the button is set to something no gamepad reports, because the gamepad
|
|
// and keyboard arms of the match are evaluated together.
|
|
PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk)));
|
|
talk.event_on = SDL_EVENT_KEY_DOWN;
|
|
talk.event_off = SDL_EVENT_KEY_UP;
|
|
talk.key = SDLK_SPACE;
|
|
talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID;
|
|
talk.handler_on = &jrpg_cmhf_talk;
|
|
talk.handler_off = &jrpg_cmhf_ignore;
|
|
PASS(errctx, akgl_controller_pushmap(0, &talk));
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Feed the scripted keystrokes due on this frame into the control maps.
|
|
*
|
|
* Synthesized `SDL_Event`s rather than direct calls to the handlers, so the
|
|
* demo goes through akgl_controller_handle_event, the control map lookup and
|
|
* the binding -- the same path a real key takes. A test that skipped that would
|
|
* not be testing the thing that breaks.
|
|
*
|
|
* @param frameno The frame about to be drawn.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
*/
|
|
static akerr_ErrorContext *demo_step(long frameno)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
SDL_Event event;
|
|
size_t i = 0;
|
|
|
|
for ( i = 0; i < JRPG_DEMO_STEPS; i++ ) {
|
|
if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) {
|
|
continue;
|
|
}
|
|
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
|
|
if ( JRPG_DEMO_SCRIPT[i].down ) {
|
|
event.type = SDL_EVENT_KEY_DOWN;
|
|
} else {
|
|
event.type = SDL_EVENT_KEY_UP;
|
|
}
|
|
event.key.which = 0;
|
|
event.key.key = JRPG_DEMO_SCRIPT[i].key;
|
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief One frame: events, camera, the library's own update, the box, present.
|
|
*
|
|
* akgl_game_update is update-every-actor, step the physics, draw the world. It
|
|
* does *not* clear or present the target, so the frame is bracketed by the
|
|
* backend's own frame_start and frame_end, and anything drawn between the
|
|
* update and frame_end lands on top of the world.
|
|
*
|
|
* @param frameno The frame number, for the demo script.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
*/
|
|
static akerr_ErrorContext *frame(long frameno)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
SDL_Event event;
|
|
akgl_Iterator opflags = {
|
|
.flags = AKGL_ITERATOR_OP_UPDATE,
|
|
.layerid = 0
|
|
};
|
|
|
|
while ( SDL_PollEvent(&event) ) {
|
|
if ( event.type == SDL_EVENT_QUIT ) {
|
|
running = false;
|
|
}
|
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
|
}
|
|
if ( demo ) {
|
|
PASS(errctx, demo_step(frameno));
|
|
// Drive the clock rather than sleeping on it. akgl_physics_simulate
|
|
// measures dt from gravity_time, which is a public field, so setting it
|
|
// one fixed step into the past makes every frame worth exactly 1/60 of
|
|
// a second of simulated time no matter how fast the loop actually runs.
|
|
// tests/physics_sim.c does the same, for the same reason: a simulated
|
|
// second should not cost a real one.
|
|
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
|
|
}
|
|
|
|
PASS(errctx, jrpg_camera_follow());
|
|
|
|
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
|
// Every failure path out of akgl_game_update returns with the game state
|
|
// mutex still held. SDL's mutexes are recursive and this loop is single
|
|
// threaded, so the next frame would not deadlock -- but it would be running
|
|
// on top of a frame that did not finish. Treat a failed frame as terminal.
|
|
PASS(errctx, akgl_game_update(&opflags));
|
|
PASS(errctx, jrpg_textbox_draw());
|
|
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Give back what has to be given back, in the order that works.
|
|
*
|
|
* There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three
|
|
* lines here are load-bearing and the third is a deliberate omission:
|
|
*
|
|
* - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept in
|
|
* an SDL property registry, and `SDL_Quit` destroys the registry -- taking
|
|
* the last reference to every font still in it with no way left to close
|
|
* them.
|
|
* - akgl_tilemap_release is **not** called. Its layer loop destroys
|
|
* `tilesets[i].texture` rather than `layers[i].texture`, so it double-frees
|
|
* every tileset texture and never frees an image layer's, and it NULLs
|
|
* nothing, so a second call is a use-after-free. TODO.md, "Known and still
|
|
* open" item 2. `SDL_Quit` reclaims the textures correctly; calling the
|
|
* function that is supposed to would be worse than not.
|
|
* - The pools are static storage. There is nothing to free and the process is
|
|
* about to exit.
|
|
*/
|
|
static void teardown(void)
|
|
{
|
|
IGNORE(akgl_text_unloadallfonts());
|
|
if ( akgl_window != NULL ) {
|
|
SDL_DestroyWindow(akgl_window);
|
|
akgl_window = NULL;
|
|
}
|
|
SDL_Quit();
|
|
}
|
|
|
|
int main(int argc, char *argv[])
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
akgl_Actor *player = NULL;
|
|
long frameno = 0;
|
|
uint64_t started = 0;
|
|
uint64_t spent = 0;
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, parse_args(argc, argv));
|
|
CATCH(errctx, startup());
|
|
// Taken here rather than after FINISH: the registry is an SDL property
|
|
// set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards
|
|
// finds nothing. The actor itself is in a static pool and outlives both.
|
|
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
|
|
|
// The loop is the last thing in this ATTEMPT block on purpose. CATCH
|
|
// reports failure by `break`ing, and a `break` inside a loop binds to
|
|
// the loop rather than to the block -- so a failing frame leaves the
|
|
// loop and falls straight into CLEANUP, which is what is wanted. Put
|
|
// anything after this loop and it would run after a failure instead.
|
|
// src/tilemap.c carries the same note over the same shape.
|
|
while ( running ) {
|
|
started = SDL_GetTicks();
|
|
CATCH(errctx, frame(frameno));
|
|
frameno += 1;
|
|
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
|
|
running = false;
|
|
}
|
|
if ( demo == false ) {
|
|
spent = SDL_GetTicks() - started;
|
|
if ( spent < JRPG_FRAME_BUDGET_MS ) {
|
|
SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent));
|
|
}
|
|
}
|
|
}
|
|
} CLEANUP {
|
|
teardown();
|
|
} PROCESS(errctx) {
|
|
} HANDLE_DEFAULT(errctx) {
|
|
LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish");
|
|
// Set a flag and act on it after FINISH. A bare `return` from inside a
|
|
// HANDLE block leaves before RELEASE_ERROR and leaks the context's pool
|
|
// slot; AGENTS.md spells that out, and an example is a bad place to
|
|
// teach it wrong.
|
|
exitstatus = 1;
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
// Enough for the smoke run to be read rather than merely passed. A run that
|
|
// exits 0 having drawn nothing looks exactly like one that worked.
|
|
if ( player != NULL ) {
|
|
printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
|
|
} else {
|
|
printf("jrpg: %ld frames, no player\n", frameno);
|
|
}
|
|
return exitstatus;
|
|
}
|