Fill the gaps two readers found in the tutorials
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 22s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 20s

Two agents were given nothing but the chapter text and asked to build the game
from it. Between them they named every place the chapters showed a fragment and
called it an explanation. This closes those.

Chapter 20 now shows what it previously only named: the whole ss_Game struct
rather than a truncated one, SS_COIN_COUNT and SS_HAZARD_COUNT, ss_ActorData,
the player and blob body rectangles, asset_path, hitbox, find_actor, respawn,
both jump handlers, the control-map function head, the gamepad buttons, the
blob's probe arithmetic, and a complete standalone CMakeLists rather than three
lines out of one. It also names the library globals a reader is expected to use
without declaring, lists all seven actor hooks rather than the two this game
replaces, and says in order what each of the two hook functions ends up doing.

Chapter 21 gains the same treatment: the header's includes and declarations, the
textbox colours and their SDL_Color type, TTF_Font, the jrpg_Townsfolk row type,
character_load in full, the player lookup, the frame loop, and per-file include
tables for both games.

Two claims were wrong and are corrected. "Each step is complete on its own" was
not true of a cumulative tutorial. And the first argument to
akgl_controller_handle_event is not a game-state word the dispatcher reads -- the
header says nothing reads it and it is required only as a non-NULL token.

Every step that produces code now ends with what the reader should see when they
run it. Excerpts across docs/ go from 137 to 190.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 09:08:57 -04:00
parent 6553da812c
commit 149bee0c99
3 changed files with 805 additions and 44 deletions

View File

@@ -58,6 +58,10 @@ else stops with them. libakgl has a status for exactly this.
12. **Freeze the world**`AKGL_ERR_LOGICINTERRUPT`, in a game.
13. **Follow with the camera, and tear down**.
The steps are cumulative — each one adds to the same files rather than replacing them. Steps
3, 4 and 5 produce data files rather than code. From step 6 onward the program builds and
runs at the end of every step, and the text says what you should see.
---
## 1. Set up the project
@@ -100,6 +104,107 @@ These are strings because `akgl_set_property` takes strings. `akgl_render_2d_ini
them onto `akgl_camera` on the way past, so everything downstream reads the camera rather
than a second copy of the same two numbers.
### What the header has to include, and declare
Each `.c` file includes `jrpg.h` plus whatever it uses directly:
| File | Adds |
|---|---|
| `world.c` | `<limits.h>`, `<math.h>`, `akstdlib.h`, `akgl/character.h`, `akgl/error.h`, `akgl/game.h`, `akgl/heap.h`, `akgl/physics.h`, `akgl/registry.h`, `akgl/sprite.h`, `akgl/tilemap.h` |
| `jrpg.c` | `<string.h>`, `akstdlib.h`, `SDL3_ttf/SDL_ttf.h`, `akgl/controller.h`, `akgl/error.h`, `akgl/game.h`, `akgl/iterator.h`, `akgl/physics.h`, `akgl/registry.h`, `akgl/renderer.h`, `akgl/text.h`, `akgl/tilemap.h` |
| `textbox.c` | listed in step 10 |
`akgl/collision.h` and `akgl/actor.h` come in through `jrpg.h`, so nothing needs to include
them twice. libakgl's headers are self-contained: including the one that declares what you
are calling is always enough.
The header itself needs:
```c excerpt=examples/jrpg/jrpg.h
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/collision.h>
#include <akgl/types.h>
```
Then the collision world and the twelve functions the four `.c` files share:
```c excerpt=examples/jrpg/jrpg.h
extern akgl_CollisionWorld jrpg_collision;
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text);
/** @brief Dismiss the text box. */
void jrpg_textbox_close(void);
/** @brief True while a line of dialogue is on screen. */
bool jrpg_textbox_showing(void);
```
```c excerpt=examples/jrpg/jrpg.h
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void);
```
Which file defines what, and which step writes it:
| Function | Defined in | Step |
|---|---|---|
| `jrpg_world_load` | `world.c` | 3, 5, 7 |
| `jrpg_world_populate` | `world.c` | 6, 7, 8, 9 |
| `jrpg_actor_logic_walk` | `world.c` | 12 |
| `jrpg_follower_render` | `world.c` | 9 |
| `jrpg_camera_follow` | `world.c` | 13 |
| `jrpg_cmhf_talk`, `jrpg_cmhf_ignore` | `world.c` | 11 |
| `jrpg_textbox_open`, `_close`, `_showing`, `_draw` | `textbox.c` | 10 |
Two more constants the chapters below use:
```c excerpt=examples/jrpg/jrpg.h
#define JRPG_FONT_NAME "jrpg"
```
```c excerpt=examples/jrpg/jrpg.h
#define JRPG_FONT_SIZE 12
```
```c excerpt=examples/jrpg/jrpg.h
#define JRPG_TEXTBOX_MAX_TEXT 256
```
```c excerpt=examples/jrpg/jrpg.h
#define JRPG_FOLLOWER_NAME "companion"
```
---
## 2. Start up and load the town
@@ -130,7 +235,11 @@ Then one new call — load the font:
```
A font is registered under a name and a size. The size is baked into the handle, so 12pt and
24pt of the same file are two registrations.
24pt of the same file are two registrations. `JRPG_FONT_NAME` is the key the text box looks
it up under in step 10.
`lowfps_quiet` is the do-nothing frame-rate hook chapter 20 explains — a `static void
lowfps_quiet(void) { }` in `jrpg.c`.
Then the world, in two functions — everything the assets need, then everything the actors
need:
@@ -173,6 +282,18 @@ Then one nested loop loads them all:
The helper builds the path from the three components:
```c excerpt=examples/jrpg/world.c
static akerr_ErrorContext *sprite_load(char *cast, char *motion, char *facing)
{
PREPARE_ERROR(errctx);
char path[PATH_MAX];
int written = 0;
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
FAIL_ZERO_RETURN(errctx, motion, AKERR_NULLPOINTER, "motion");
FAIL_ZERO_RETURN(errctx, facing, AKERR_NULLPOINTER, "facing");
```
```c excerpt=examples/jrpg/world.c
aksl_snprintf(
&written,
@@ -184,6 +305,41 @@ The helper builds the path from the three components:
motion,
facing
));
PASS(errctx, akgl_sprite_load_json(path));
SUCCEED_RETURN(errctx);
}
```
`character_load` is the same shape against a different file name and a different loader:
```c excerpt=examples/jrpg/world.c
static akerr_ErrorContext *character_load(char *cast)
{
PREPARE_ERROR(errctx);
char path[PATH_MAX];
int written = 0;
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
PASS(errctx,
aksl_snprintf(
&written,
path,
sizeof(path),
"%s/character_jrpg_%s.json",
JRPG_ASSET_DIR,
cast
));
PASS(errctx, akgl_character_load_json(path));
SUCCEED_RETURN(errctx);
}
```
`PATH_MAX` comes from `<limits.h>`. The counts come from the tables:
```c excerpt=examples/jrpg/world.c
#define CAST_COUNT (sizeof(CAST) / sizeof(CAST[0]))
#define MOTION_COUNT (sizeof(MOTIONS) / sizeof(MOTIONS[0]))
#define FACING_COUNT (sizeof(FACINGS) / sizeof(FACINGS[0]))
```
A missing combination now shows up as a load failure naming the exact file, rather than as
@@ -378,9 +534,15 @@ Clearing the field leaves the facing bits wherever they were last set, which is
top-down game wants. `TODO.md` tracks making the default behave; until then this loop belongs
in every game with a standing NPC in it.
The loop covers the player as well as the NPCs, which is what you want — a player who stops
walking would disappear for the same reason. Chapter 20 sets the same field on one actor
because that game only has one that stands still.
`akgl_heap_actors` is the actor pool, and walking it directly is the supported way to touch
every live actor. A slot with `refcount == 0` is free.
Run now. The town draws, the player and the townsfolk are visible, and nothing moves.
---
## 7. Wall the town in
@@ -420,7 +582,32 @@ parent every step, so a shape on it would fight the snap rather than stop anythi
The map's edge is not on any layer, and nothing else stops an actor walking off it. Four
static collision proxies do the job — one per side. A long thin box costs one pool slot
whatever its length, which is what proxies are for:
whatever its length, which is what proxies are for.
Keep the proxies and the shapes they were built from in file-scope arrays, because both have
to outlive the function that made them:
```c excerpt=examples/jrpg/world.c
static akgl_CollisionProxy *jrpg_edges[4];
static akgl_CollisionShape jrpg_edge_shapes[4];
```
Work the four rectangles out from the map's own size:
```c excerpt=examples/jrpg/world.c
ow = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) + (2.0f * EDGE_OVERHANG);
oh = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) + (2.0f * EDGE_OVERHANG);
tw = (float32_t)akgl_gamemap->tilewidth + EDGE_OVERHANG;
th = (float32_t)akgl_gamemap->tileheight + EDGE_OVERHANG;
/* x y w h */
sides[0] = (SDL_FRect){ -EDGE_OVERHANG, -EDGE_OVERHANG, tw, oh }; /* west */
sides[1] = (SDL_FRect){ (ow - tw - EDGE_OVERHANG), -EDGE_OVERHANG, tw, oh }; /* east */
sides[2] = (SDL_FRect){ -EDGE_OVERHANG, -EDGE_OVERHANG, ow, th }; /* north */
sides[3] = (SDL_FRect){ -EDGE_OVERHANG, (oh - th - EDGE_OVERHANG), ow, th }; /* south */
```
Then register each one, in a loop over the four:
```c excerpt=examples/jrpg/world.c
PASS(errctx, akgl_heap_next_collision_proxy(&jrpg_edges[i]));
@@ -474,12 +661,25 @@ and facing bits your character mappings key on. A sidescroller replaces the `_of
to get friction; a top-down game does not, because stopping dead when you release a direction
is exactly right here.
Install the player's own movement hook too — step 12 is what goes in it:
Run now. The arrow keys walk the player in four directions with the right animation for each,
and buildings and the map's edge stop them.
The player itself comes out of the registry, under the name its Tiled object carried:
```c excerpt=examples/jrpg/world.c
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
FAIL_ZERO_RETURN(
errctx,
player,
AKGL_ERR_REGISTRY,
"town.tmj spawned no actor named \"player\""
);
player->movementlogicfunc = &jrpg_actor_logic_walk;
```
That lookup and everything after it lives in `jrpg_world_populate`, which runs after
`jrpg_world_load` has loaded the map. Step 12 is what goes in the hook.
---
## 9. Follow the player
@@ -503,6 +703,12 @@ it:
gives it a name. Write those two adjacent, with nothing between them — the slot is not yours
until the initialize.
`addchild` is one of the actor's seven behaviour hooks, not a library function, which is why
it is called through the actor and takes it as the first argument:
`player->addchild(player, follower)` reads as "player, add this child". Every hook works that
way, so a game can replace one on a single actor — chapter 20 replaces `updatefunc` and
`movementlogicfunc` the same way.
The follower borrows an existing character. An actor is an instance; a character is a
template. That is what splitting them is for.
@@ -540,6 +746,8 @@ failing one — an actor left with a `NULL` parent would be simulated as a free
next step. This is `TODO.md`, "Known and still open"; when the two agree on one reading, this
hook becomes `akgl_actor_render` again.
Run now. A second character walks a fixed distance behind and below the player.
---
## 10. Put text on screen
@@ -548,13 +756,59 @@ Text in libakgl is immediate mode: each call rasterizes the string, uploads it,
destroys the texture. That is the wrong shape for a page of prose redrawn sixty times a
second and exactly right for one line of dialogue.
Keep the state in two variables:
`textbox.c` needs these includes:
```c excerpt=examples/jrpg/textbox.c
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/text.h>
```
The colours are `SDL_Color`, which is four bytes of RGBA:
```c excerpt=examples/jrpg/textbox.c
/*
* Colour R G B A
*/
static const SDL_Color TEXTBOX_FILL = { 24, 20, 37, 235 };
static const SDL_Color TEXTBOX_EDGE = { 240, 236, 214, 255 };
static const SDL_Color TEXTBOX_INK = { 240, 236, 214, 255 };
```
An alpha of 235 on the fill lets the world show faintly through the panel.
```c excerpt=examples/jrpg/textbox.c
#define TEXTBOX_MARGIN 8.0f
```
```c excerpt=examples/jrpg/textbox.c
#define TEXTBOX_HEIGHT 56.0f
```
```c excerpt=examples/jrpg/textbox.c
#define TEXTBOX_PADDING 8.0f
```
Keep the state in two file-scope variables:
```c excerpt=examples/jrpg/textbox.c
static char textbox_text[JRPG_TEXTBOX_MAX_TEXT];
static bool textbox_visible = false;
```
`jrpg_textbox_showing` returns `textbox_visible` and `jrpg_textbox_close` sets it to `false`.
Both are two-line functions returning `bool` and `void` — they cannot fail, so they do not
return an error context.
Opening the box copies the line in:
```c excerpt=examples/jrpg/textbox.c
@@ -577,9 +831,24 @@ Fetch the font from the registry, size a panel from the camera, and draw three t
a filled rectangle, an outline, and the text:
```c excerpt=examples/jrpg/textbox.c
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
TTF_Font *font = NULL;
SDL_FRect panel;
```
```c excerpt=examples/jrpg/textbox.c
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
FAIL_ZERO_RETURN(
errctx,
font,
AKGL_ERR_REGISTRY,
"No font registered as \"%s\"",
JRPG_FONT_NAME
);
```
A font is an SDL_ttf `TTF_Font *`. libakgl does not wrap it in a type of its own — the
registry hands you SDL_ttf's handle.
```c excerpt=examples/jrpg/textbox.c
panel.x = TEXTBOX_MARGIN;
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
@@ -633,11 +902,21 @@ The panel is drawn *after* `akgl_game_update`, so it lands on top of everything:
`akgl_game_update` does not clear or present, so anything you draw between it and
`frame_end` is on top of the world.
Nothing opens the box yet, so there is nothing to see until step 11.
---
## 11. Talk to somebody
Keep the dialogue in a table beside the actor names the map gave them:
Keep the dialogue in a table beside the actor names the map gave them. The row type is your
own:
```c excerpt=examples/jrpg/world.c
typedef struct {
char *actor; /**< Registry key, which is the `name` of the map object that spawned them. */
char *line; /**< What they say. */
} jrpg_Townsfolk;
```
```c excerpt=examples/jrpg/world.c
static jrpg_Townsfolk TOWNSFOLK[] = {
@@ -678,6 +957,10 @@ range, otherwise do nothing.
#define TALK_RANGE 64.0f
```
```c excerpt=examples/jrpg/world.c
#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
```
Look the NPC up by name every time rather than caching the pointer. An actor that has been
released is out of the registry, so the lookup returns `NULL` and the loop skips it — a
cached pointer would be stale.
@@ -708,6 +991,9 @@ Three things to get right, all of them cheap:
match are evaluated together, and 0 is a real button. Recorded in `TODO.md`; until it is
settled, name a button no gamepad reports.
Run now. Standing near the elder and pressing Space opens their line; pressing it again
dismisses the box. The player can still walk while it is open — step 12 fixes that.
---
## 12. Freeze the world
@@ -751,6 +1037,9 @@ If the box is not showing, the default logic runs as usual:
PASS(errctx, akgl_actor_logic_movement(obj, dt));
```
Run now. Opening a conversation stops the player where they stand, and the arrow keys do
nothing until the box is dismissed.
---
## 13. Follow with the camera, and tear down
@@ -780,6 +1069,19 @@ The `+ 16.0f` centres on the middle of a 32-pixel sprite rather than its top-lef
### The frame
The loop is chapter 20's, with two additions. Drain the events first:
```c excerpt=examples/jrpg/jrpg.c
while ( SDL_PollEvent(&event) ) {
if ( event.type == SDL_EVENT_QUIT ) {
running = false;
}
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
```
Then the camera, the world, and the panel on top of it:
```c excerpt=examples/jrpg/jrpg.c
PASS(errctx, jrpg_camera_follow());