They read as design commentary: why a choice was made, what the previous state of the project was, which library defect a line works around. That is useful to somebody who already knows the library and useless to somebody trying to build a game, which is who a tutorial is for. Both chapters now open with a screenshot of what the reader is building and a numbered list of the steps to get there, and each step is its own section: what you are trying to achieve, the code that achieves it, and the two or three things about that code that are easy to get wrong. Chapter 20 starts from first principles -- what a sprite, a character, an actor and a tilemap are, and the four error macros -- and chapter 21 assumes it and covers what a top-down game adds. Where a line exists because of a defect, the line comes first and the defect comes after it, with a pointer to the TODO entry that will remove the need. A reader who is copying code should not have to read an apology before they can use it. The library's own history is gone from both. That belongs in the design chapters and in git. Two new docs_examples setups stage the tutorials' own art, so the sprite and character files a reader is shown are loaded exactly as printed rather than through a generic fixture. `json excerpt=` is new for the same reason: a Tiled object is now quoted out of the real map instead of being retyped. Excerpts across docs/ go from 91 to 137. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
28 KiB
21. Tutorial: a top-down JRPG
This chapter builds the game in that picture, from an empty directory. When you finish, you will have a program that opens a window, loads a town drawn in Tiled, and runs a character who walks in four directions, is blocked by buildings, is followed by a party member, and can talk to the townsfolk.
The finished program is examples/jrpg/ in this repository, and every listing below is
quoted from it by the docs_examples test — so nothing here can describe code that does not
exist.
Before you start
You need a C compiler, CMake 3.10 or newer, and libakgl built. Chapter 3 covers getting the library on your machine.
Chapter 20 is the better place to start if you have not used libakgl before. It covers the same ground from a standing start — the error macros, the startup order, the sprite and character JSON formats, the Tiled setup — and this chapter assumes them rather than repeating them. What is new here is four-way movement, NPCs, text on screen, a follower and freezing the world.
Every symbol this game defines is prefixed jrpg_.
First principles: what a top-down game changes
The library is the same. Four things about using it differ from a sidescroller, and they are why this chapter exists.
Gravity is zero on both axes. The map declares it. Nothing falls, so speed_y on a
character is a real walking speed rather than 0.
There are four facing directions, not two. A character needs eight sprite mappings — idle and walking, up, down, left and right — instead of the sidescroller's four.
Most actors never move. NPCs stand where the map put them, and are spoken to rather than walked into. That changes what you do about their facing bits, and it means they get no collision shape.
The world can be frozen. While a conversation is open the player stops, and everything else stops with them. libakgl has a status for exactly this.
The steps
- Set up the project — the directory, the build file, and where the font comes from.
- Start up and load the town — the same order as chapter 20, plus a font.
- Describe four-way art — twenty-four sprites from a naming convention.
- Bind eight states per character — the character JSON for four-way movement.
- Draw the town — a Tiled map with zero gravity and a solid decoration layer.
- Keep the NPCs visible — one line, and the reason it is needed.
- Wall the town in — solid tiles for buildings, static proxies for the map's edge.
- Walk in four directions — one call binds the whole arrow-key set.
- Follow the player — a child actor, and the one thing to know about drawing it.
- Put text on screen — a font, a panel and a line of dialogue.
- Talk to somebody — a proximity test and a key binding of your own.
- Freeze the world —
AKGL_ERR_LOGICINTERRUPT, in a game. - Follow with the camera, and tear down.
1. Set up the project
jrpg/
CMakeLists.txt
jrpg.h shared declarations
jrpg.c startup, the frame loop, teardown, controls
world.c assets, the map, the actors and their behaviour
textbox.c the dialogue panel
The build file is the sidescroller's, plus one thing: this game draws text, so it needs a TrueType font at runtime. Bake both paths in at compile time:
target_compile_definitions(jrpg PRIVATE
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
)
Give them defaults in the header so the file still compiles on its own:
#ifndef JRPG_ASSET_DIR
#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg"
The screen is smaller than the sidescroller's and is not scaled up, because the town's art is drawn at map scale:
#define JRPG_SCREEN_WIDTH "320"
#define JRPG_SCREEN_HEIGHT "240"
These are strings because akgl_set_property takes strings. akgl_render_2d_init copies
them onto akgl_camera on the way past, so everything downstream reads the camera rather
than a second copy of the same two numbers.
2. Start up and load the town
The sequence is chapter 20's. Name the game, initialize, set the properties, then the renderer, then the physics backend:
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));
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
The arcade backend with no gravity is exactly what a top-down game wants, and zero gravity is what the property defaults already give.
Then one new call — load the font:
PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE));
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.
Then the world, in two functions — everything the assets need, then everything the actors need:
PASS(errctx, jrpg_world_load());
PASS(errctx, jrpg_world_populate());
3. Describe four-way art
Twenty-four sprites: three characters × two motions × four facings. Rather than a list of twenty-four file names, make the names a product of three tables — the naming convention is the data:
static char *CAST[] = {
"player", /* jrpg_player jrpg_player_* */
"elder", /* jrpg_elder jrpg_elder_* */
"shopkeeper" /* jrpg_shopkeeper jrpg_shopkeeper_* */
};
static char *MOTIONS[] = { "idle", "walk" };
static char *FACINGS[] = { "up", "down", "left", "right" };
Then one nested loop loads them all:
for ( c = 0; c < CAST_COUNT; c++ ) {
for ( m = 0; m < MOTION_COUNT; m++ ) {
for ( f = 0; f < FACING_COUNT; f++ ) {
PASS(errctx, sprite_load(CAST[c], MOTIONS[m], FACINGS[f]));
}
}
}
The helper builds the path from the three components:
aksl_snprintf(
&written,
path,
sizeof(path),
"%s/sprite_jrpg_%s_%s_%s.json",
JRPG_ASSET_DIR,
cast,
motion,
facing
));
A missing combination now shows up as a load failure naming the exact file, rather than as art that silently never appears.
Each sprite file is the format chapter 20 covers. A walk cycle:
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
0,
1,
0,
2
]
}
All eight of a character's sprites name the same PNG. The spritesheet registry is keyed on the file path, so the image is decoded and uploaded once.
4. Bind eight states per character
A four-way character needs eight mappings — an idle and a walking sprite for each facing:
{
"name": "jrpg_player",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_player_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_player_walk_down"
}
]
}
Repeat that pair for UP, LEFT and RIGHT. speed_y is a real speed here — 60 px/s, the
same as speed_x, so diagonal movement is not faster than straight movement.
Load the characters after the sprites:
for ( c = 0; c < CAST_COUNT; c++ ) {
PASS(errctx, character_load(CAST[c]));
}
The NPC characters are the same eight mappings against their own art. They never walk, but giving them the walking sprites costs nothing and means an NPC that starts moving later already works.
5. Draw the town
Set the map up as chapter 20 describes — orthogonal, CSV layer format, 16×16 tiles — at 30 × 20 tiles, with three layers:
| Layer | Type | What it is |
|---|---|---|
ground |
Tile layer | Grass, paths, water. Drawn, never solid |
decoration |
Tile layer | Buildings, trees, lamp posts |
actors |
Object layer | The player and the townsfolk |
Mark decoration with a collidable boolean custom property, exactly as in chapter 20:
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
]
Zero gravity
On the map itself, three properties:
| Property | Type | Value |
|---|---|---|
physics.model |
string | arcade |
physics.gravity.x |
float | 0.0 |
physics.gravity.y |
float | 0.0 |
Set both axes explicitly rather than leaving them out. A map that states its physics is a map you can read.
The actors
Place a rectangle for the player and one for each townsperson. Each needs a Name, a
Type of actor, and two custom properties:
"height": 32,
"id": 1,
"name": "player",
"properties": [
{
"name": "character",
"type": "string",
"value": "jrpg_player"
},
{
"name": "state",
"type": "int",
"value": 17
}
],
"rotation": 0,
"type": "actor",
"visible": true,
"width": 32,
"x": 224,
"y": 256
17 is AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN — 16 plus 1. Facing down is the
convention for a character standing still and looking at the player.
Loading it
Sprites, then characters, then the map:
aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR));
PASS(errctx, akgl_tilemap_load(path, akgl_gamemap));
Then switch to the physics the map declared:
if ( akgl_gamemap->use_own_physics ) {
akgl_physics = &akgl_gamemap->physics;
}
6. Keep the NPCs visible
Add this loop right after the map loads:
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount == 0 ) {
continue;
}
akgl_heap_actors[i].movement_controls_face = false;
}
akgl_actor_initialize sets movement_controls_face, and the default facing logic it
installs clears every facing bit and then sets the one matching a movement bit. An NPC has
no movement bits, so on the first frame its state falls from ALIVE|FACE_DOWN (17) to
ALIVE (16) — a combination no character maps a sprite to. An actor with no sprite for its
state is skipped rather than reported, so every NPC in the town disappears on frame one,
silently, and so does the player as soon as they stop walking.
Clearing the field leaves the facing bits wherever they were last set, which is what a
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.
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.
7. Wall the town in
Buildings are solid tiles, and they are already handled — the collidable property in step 5
did it. Turn collision on:
PASS(errctx, akgl_collision_world_init(&jrpg_collision, NULL, 16.0f, 16.0f));
PASS(errctx, akgl_collision_bind_tilemap(&jrpg_collision, akgl_gamemap));
PASS(errctx, wall_off_the_edges());
akgl_physics->collision = &jrpg_collision;
Give the player a footprint rather than a full-frame box. A character stands on the bottom third of their sprite, and that is the part that has to fit through a doorway — testing the whole frame would make every corridor two tiles wide:
body = (SDL_FRect){ FEET_X, FEET_TOP, FEET_W, FEET_H };
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
player->shape_override = true;
#define FEET_X 6.0f
#define FEET_W 20.0f
#define FEET_TOP 20.0f
#define FEET_H 10.0f
Only the player gets a shape. The NPCs stand still and are spoken to, not walked into; shapes on them would make the town a pinball table. The follower is a child snapped to its parent every step, so a shape on it would fight the snap rather than stop anything.
The edge of the world
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:
PASS(errctx, akgl_heap_next_collision_proxy(&jrpg_edges[i]));
PASS(errctx, akgl_collision_shape_box(&jrpg_edge_shapes[i], &sides[i], 0.0f));
jrpg_edge_shapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
jrpg_edge_shapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
jrpg_edge_shapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;
PASS(errctx, akgl_collision_proxy_initialize(jrpg_edges[i], NULL, &jrpg_edge_shapes[i],
0.0f, 0.0f, 0.0f));
PASS(errctx, jrpg_collision.partitioner.insert(&jrpg_collision.partitioner, jrpg_edges[i]));
Three details in those lines:
- Set
layermaskby hand.akgl_collision_shape_boxdefaults a shape toAKGL_COLLISION_LAYER_ACTOR, which is right for an actor and wrong for scenery. An actor responds toAKGL_COLLISION_LAYER_STATICand nothing else, so a wall left on the default layer is a wall everything walks through. - Clear
collidemask. The wall is not asking to be pushed out of anything. - Keep the acquire and the initialize adjacent, with nothing between them.
akgl_heap_next_collision_proxyfinds a free slot and does not claim it; the initialize takes the reference. Between the two lines a second acquire returns the same pointer. Chapter 5 explains why the pools work that way.
The walls cover the map's outer ring of cells and extend a tile beyond it:
#define EDGE_OVERHANG 16.0f
The overhang is what stops an actor arriving fast enough from being resolved out the far side of a wall exactly one tile thick.
8. Walk in four directions
The arrow keys and the D-pad, wired to the library's own handlers, in one call:
PASS(errctx, akgl_controller_default(0, "player", 0, 0));
The arguments are the control map id, the actor's registry name, the keyboard id and the
gamepad id. 0 for both devices means "the first of each", which is what a one-player game
wants.
That is all four-way movement needs. akgl_controller_default binds all eight
akgl_actor_cmhf_* handlers — up, down, left, right, on and off — and those set the movement
and facing bits your character mappings key on. A sidescroller replaces the _off handlers
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:
player->movementlogicfunc = &jrpg_actor_logic_walk;
9. Follow the player
A party member is an ordinary actor made a child of the player. Nothing in the map creates it:
PASS(errctx, akgl_heap_next_actor(&follower));
PASS(errctx, akgl_actor_initialize(follower, JRPG_FOLLOWER_NAME));
PASS(errctx, akgl_actor_set_character(follower, "jrpg_elder"));
follower->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN);
follower->movement_controls_face = false;
follower->visible = true;
follower->layer = player->layer;
follower->renderfunc = &jrpg_follower_render;
PASS(errctx, player->addchild(player, follower));
akgl_heap_next_actor takes a free slot from the pool; akgl_actor_initialize claims it and
gives it a name. Write those two adjacent, with nothing between them — the slot is not yours
until the initialize.
The follower borrows an existing character. An actor is an instance; a character is a template. That is what splitting them is for.
Where a child stands
A child's velocity fields are read as a fixed offset from its parent, not as a velocity.
The physics step snaps a child to parent->x + vx and never simulates it:
follower->vx = FOLLOWER_OFFSET_X;
follower->vy = FOLLOWER_OFFSET_Y;
Set them after addchild. That call is what makes them mean an offset.
Drawing a child
Give the follower a renderfunc that detaches the parent for the duration of the draw:
parent = obj->parent;
obj->parent = NULL;
ATTEMPT {
CATCH(errctx, akgl_actor_render(obj));
} CLEANUP {
obj->parent = parent;
} PROCESS(errctx) {
} FINISH(errctx, true);
The physics step writes a child's x and y as an absolute world position, and
akgl_actor_render adds the parent's position to it a second time. Detaching takes the
branch that does not add it, and CLEANUP puts the parent back on every path including the
failing one — an actor left with a NULL parent would be simulated as a free agent on the
next step. This is TODO.md, "Known and still open"; when the two agree on one reading, this
hook becomes akgl_actor_render again.
10. Put text on screen
Text in libakgl is immediate mode: each call rasterizes the string, uploads it, blits it and 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:
static char textbox_text[JRPG_TEXTBOX_MAX_TEXT];
static bool textbox_visible = false;
Opening the box copies the line in:
aksl_strncpy(
textbox_text,
sizeof(textbox_text),
text,
sizeof(textbox_text) - 1
));
textbox_visible = true;
aksl_strncpy rather than strncpy: this is a fixed-width field, and strncpy at exactly
the field width leaves it unterminated. Bounding at one less truncates an over-long line
rather than refusing it.
Drawing the panel
Fetch the font from the registry, size a panel from the camera, and draw three things — a filled rectangle, an outline, and the text:
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
panel.x = TEXTBOX_MARGIN;
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
panel.h = TEXTBOX_HEIGHT;
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
akgl_text_rendertextat(
font,
textbox_text,
TEXTBOX_INK,
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
(int)(panel.x + TEXTBOX_PADDING),
(int)(panel.y + TEXTBOX_PADDING)
));
The fourth argument is a wrap width in pixels; the last two are the top-left corner. These
are screen coordinates, not map coordinates, which is why the panel is sized from the
camera's w and h.
Fetch the font every frame rather than caching the pointer. Fonts live in a registry under a
name with no reference counting, so whoever calls akgl_text_unloadfont invalidates every
pointer anybody else is holding.
Guard against the empty string:
if ( textbox_text[0] == '\0' ) {
SUCCEED_RETURN(errctx);
}
akgl_text_rendertextat refuses a zero-width string while akgl_text_measure accepts one.
The disagreement is recorded in TODO.md under "Known and still open"; the check above is
the guard until it is settled.
Drawing it over the world
The panel is drawn after akgl_game_update, so it lands on top of everything:
PASS(errctx, akgl_game_update(&opflags));
PASS(errctx, jrpg_textbox_draw());
akgl_game_update does not clear or present, so anything you draw between it and
frame_end is on top of the world.
11. Talk to somebody
Keep the dialogue in a table beside the actor names the map gave them:
static jrpg_Townsfolk TOWNSFOLK[] = {
{ "elder", "ELDER: The old road north is closed. Nothing to be done about it,\nand nothing beyond it worth the walk." },
{ "shopkeeper", "SHOPKEEPER: Nothing in stock but tiles and good intentions.\nCome back when I have inventory." }
};
\n in a line is a real line break — akgl_text_rendertextat honours it.
The handler does three things: close the box if it is open, otherwise find a townsperson in range, otherwise do nothing.
if ( jrpg_textbox_showing() ) {
jrpg_textbox_close();
SUCCEED_RETURN(errctx);
}
for ( i = 0; i < TOWNSFOLK_COUNT; i++ ) {
npc = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, TOWNSFOLK[i].actor, NULL);
if ( npc == NULL ) {
continue;
}
dx = npc->x - obj->x;
dy = npc->y - obj->y;
if ( sqrtf((dx * dx) + (dy * dy)) > TALK_RANGE ) {
continue;
}
PASS(errctx, jrpg_textbox_open(TOWNSFOLK[i].line));
SUCCEED_RETURN(errctx);
}
#define TALK_RANGE 64.0f
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.
Binding the key
akgl_controller_default used control map 0; push one more binding onto the same map:
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));
Three things to get right, all of them cheap:
- Zero the struct first. A binding is a struct copied into the map, so a stack local is fine — but an uninitialized field is a match against garbage.
handler_offmust be non-NULL.akgl_controller_handle_eventcalls it without checking.jrpg_cmhf_ignoreis a handler that does nothing, which is what you want on the release of a key whose press is the whole action.- Set
buttontoSDL_GAMEPAD_BUTTON_INVALID. The keyboard and gamepad arms of the match are evaluated together, and 0 is a real button. Recorded inTODO.md; until it is settled, name a button no gamepad reports.
12. Freeze the world
While a conversation is open, nothing should move. Put the check at the top of the player's
movementlogicfunc:
if ( jrpg_textbox_showing() ) {
obj->tx = 0.0f;
obj->ty = 0.0f;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_ALL);
FAIL_RETURN(
errctx,
AKGL_ERR_LOGICINTERRUPT,
"%s does not move while a conversation is open",
(char *)obj->name
);
}
AKGL_ERR_LOGICINTERRUPT is the one status in libakgl that is not a failure. Raised from
a movementlogicfunc it means "skip the rest of this tick for this actor": the physics step
catches it, so gravity, drag, the velocity recompute, the move and collision are all skipped,
and the frame carries on to the next actor.
Two things happen before the raise, and both matter:
- Zero the thrust. By the time this hook runs, this step's thrust has already been integrated. Leaving it would let it accumulate while frozen and lurch on the first step after the box closes.
- Clear the movement bits. Otherwise the walk animation marches on the spot. The cost is that a direction held across the close has to be pressed again.
Only a movementlogicfunc may raise it. Raised from a backend's gravity or move it
aborts the whole physics step, leaving every remaining actor unsimulated.
If the box is not showing, the default logic runs as usual:
PASS(errctx, akgl_actor_logic_movement(obj, dt));
13. Follow with the camera, and tear down
The camera centres on the player and clamps to the map on both axes — the sidescroller only had to do one:
akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f);
akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f);
if ( akgl_camera->x < 0.0f ) {
akgl_camera->x = 0.0f;
}
if ( akgl_camera->y < 0.0f ) {
akgl_camera->y = 0.0f;
}
if ( akgl_camera->x > (mapw - akgl_camera->w) ) {
akgl_camera->x = mapw - akgl_camera->w;
}
if ( akgl_camera->y > (maph - akgl_camera->h) ) {
akgl_camera->y = maph - akgl_camera->h;
}
The + 16.0f centres on the middle of a 32-pixel sprite rather than its top-left corner.
The frame
PASS(errctx, jrpg_camera_follow());
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
This game passes an iterator to akgl_game_update rather than NULL:
akgl_Iterator opflags = {
.flags = AKGL_ITERATOR_OP_UPDATE,
.layerid = 0
};
AKGL_ITERATOR_OP_UPDATE asks for the update pass. Chapter 7
covers the other flags.
Teardown
IGNORE(akgl_text_unloadallfonts());
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
akgl_text_unloadallfonts must run before SDL_Quit. Fonts live in an SDL property
registry and SDL_Quit destroys it, taking the last reference to every font with no way left
to close them.
akgl_tilemap_release is not called. It double-frees tileset textures, which is TODO.md
"Known and still open" item 2; SDL_Quit reclaims them correctly. A game that loads a second
level has to unwind properly, and that is what the fix will make possible.
The pools are static storage and the process is exiting, so there is nothing else to free.
Build it and run it
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
./build/examples/jrpg/jrpg
| Control | Keyboard | Gamepad |
|---|---|---|
| Walk | ← → ↑ ↓ | D-pad |
| Talk / dismiss | Space | — |
| Quit | Close the window | — |
Stand next to the elder or the shopkeeper and press Space.
To run it without a display:
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
./build/examples/jrpg/jrpg --frames 320 --demo
--demo drives a scripted walk: right, then north past the buildings, then Space to open the
elder's line, an arrow key that the freeze eats, Space to dismiss, and a walk away. It prints
where the player finished:
jrpg: 320 frames, player at (280, 130)
Where to look next
- Chapter 20 — the same shape of program with gravity, a jump and platforms.
- Chapter 15 — the masks, the four queries and the static-proxy path this chapter used for the town walls.
- Chapter 17 — measuring text, and the teardown ordering trap.
- Chapter 12 — parents, children, and the seven behaviour hooks.
- Chapter 7 — the iterator flags, and what
akgl_game_updatedoes in what order.
