From 6553da812c49c74a6bf9b96a9d27adf1db96d533 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 08:51:39 -0400 Subject: [PATCH] Rewrite both tutorials as tutorials 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) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- docs/20-tutorial-sidescroller.md | 1453 +++++++++++++++++++---------- docs/21-tutorial-jrpg.md | 1307 ++++++++++++++------------ docs/MAINTENANCE.md | 9 + docs/README.md | 13 +- tests/docs_setups/jrpg.sh | 35 + tests/docs_setups/sidescroller.sh | 36 + 6 files changed, 1758 insertions(+), 1095 deletions(-) create mode 100755 tests/docs_setups/jrpg.sh create mode 100755 tests/docs_setups/sidescroller.sh diff --git a/docs/20-tutorial-sidescroller.md b/docs/20-tutorial-sidescroller.md index cb7917c..13558e8 100644 --- a/docs/20-tutorial-sidescroller.md +++ b/docs/20-tutorial-sidescroller.md @@ -1,87 +1,196 @@ # 20. Tutorial: a 2D sidescroller -A complete game: a Tiled level, a player who runs and jumps, platforms that hold him up, -four coins to collect, a blob that patrols and a moth that flies. It is a header and three -translation units, rather more comment than code, and it builds and runs as part of this -repository's ordinary `ctest` run. +![The finished sidescroller: a player standing on a grass platform, with coins on floating ledges, a blob on the ground and a moth in the air](images/sidescroller.png) -It used to be four translation units. The fourth was 383 lines of collision, and it is gone -— which makes this chapter as much about **where in the frame a thing has to happen** as -about what to write. That is the lesson that survived: the same test, run a few lines -earlier, is wrong in ways that take an afternoon to diagnose. +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 level drawn in [Tiled](https://mapeditor.org), +and runs a character who walks, jumps, lands on platforms, collects coins and dies on hazards. -The program is `examples/sidescroller/`; the art, the map and the JSON are in -`docs/tutorials/assets/sidescroller/`. Every listing below is quoted straight out of those -files by the `docs_examples` test, so this chapter cannot describe a program that no longer -exists. +The finished program is `examples/sidescroller/` 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. You can read that program at any point, but you should not need to: the steps +below are complete. -## Building it and running it +## Before you start -```sh norun -cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -cmake --build build --parallel --target sidescroller -./build/examples/sidescroller/sidescroller -``` +You need a C compiler, CMake 3.10 or newer, and libakgl built. [Chapter 3](03-getting-started.md) +covers getting the library on your machine. You do not need to have read any other chapter. -### Controls +Two conventions used throughout, both explained where they first appear: -| Key | Gamepad | Does | -|---|---|---| -| Left arrow | D-pad left | Run left | -| Right arrow | D-pad right | Run right | -| Space | South button (A) | Jump — hold it longer to jump higher | +- Every libakgl function returns `akerr_ErrorContext AKERR_NOIGNORE *`. That is how failures + travel. [Chapter 4](04-errors.md) is the reference; this chapter shows you the four macros + you actually need. +- Every symbol this game defines is prefixed `ss_`. The `akgl_` prefix belongs to the + library. A game built on it takes its own. -Close the window to quit; there is no key bound to it. +## First principles: what libakgl does for you -Three flags exist for the smoke test and are useful by hand too: `--assets DIR` points -somewhere other than the compiled-in asset directory, `--frames N` (or -`AKGL_SIDESCROLLER_FRAMES`) exits after that many frames, and `--autoplay` drives the -player from a script instead of the keyboard. +libakgl is a library, not an engine. It does not own your `main`, it has no editor, and it +never calls your code except through function pointers you install. You write a program that +calls it. -## The level +Five ideas hold the whole thing up. Read these once; the rest of the chapter is built out of +them. -`level1.tmj` is 40x15 tiles of 16 pixels — a world 640x240 pixels — with three layers: -a `background` tile layer, a `terrain` tile layer, and an `actors` object group. The camera -is a 480x240 window onto it that scrolls sideways. +**A spritesheet is one image file. A sprite is an animation cut out of it.** You give libakgl +a PNG and say how big one frame is; a sprite names that sheet and lists which frames to play, +in what order, at what speed. + +**A character is a mapping from state to sprite.** An actor carries a 32-bit `state` word +made of bits like `AKGL_ACTOR_STATE_MOVING_LEFT` and `AKGL_ACTOR_STATE_FACE_RIGHT`. A +character says "when the state is exactly *these* bits, draw *this* sprite". One character is +shared by every goblin in the game. + +**An actor is one thing in the world.** It has a position, a velocity, a state word, and a +pointer to the character that tells it how to look. Actors come from a fixed pool inside the +library — you never call `malloc`. + +**A tilemap is the level.** It is a [Tiled](https://mapeditor.org) `.tmj` file. libakgl draws +its tile layers, and creates an actor for every object in its object layers. + +**One call per frame does the work.** `akgl_game_update()` updates every actor, steps the +physics, and draws the world. Your frame loop reads input, then calls it. + +## The steps + +1. **Set up the project** — a directory, a `CMakeLists.txt`, and a header for your own + declarations. +2. **Open a window and run a frame loop** — the startup order that works, and the shortest + program that successfully draws nothing. +3. **Describe your art** — the spritesheet and sprite JSON files. +4. **Bind sprites to states** — the character JSON file. +5. **Draw a level** — a Tiled map, its tile layers, and the objects that become actors. +6. **Load the level** — in the one order that works, and honour the physics the map carries. +7. **Turn on collision** — a collision world, a shape on the player, and a `collidable` + layer. +8. **Make the player walk** — control maps, and the two hooks an actor gives you. +9. **Make the player jump** — an impulse, and how to know you are on the ground. +10. **Scroll the camera** — three lines. +11. **Collect coins and die on hazards** — overlap tests, and how to remove an actor. +12. **Add moving enemies** — a blob that patrols and a moth that flies. +13. **Tear down** — the order that matters. + +Each step below is complete on its own. Build and run after each one. + +--- + +## 1. Set up the project + +Make a directory with four files: ```text - 0 1 2 3 - 0123456789012345678901234567890123456789 - 0 ........................................ - 1 ........................................ - 2 ........................................ - 3 ........................................ - 4 ........................................ - 5 ........................................ - 6 ............................##.......... - 7 ..............##........................ - 8 ......................##................ - 9 ........##.............................. - 10 ........................................ - 11 ...#......#...............#......#...... - 12 ##################...################### - 13 ##################...################### - 14 ##################...################### - - the terrain layer, every row of it. `#` is any non-zero tile; the three - empty columns at 18-20 are the pit, the two-tile runs on rows 6 to 9 are - the platforms, and the four single tiles on row 11 are steps. +sidescroller/ + CMakeLists.txt + sidescroller.h your own declarations, shared between the .c files + main.c startup, the frame loop, teardown + player.c the player's behaviour and controls + actors.c everything else the map places ``` -The object group places seven actors: `player`, `coin1` through `coin4`, `blob1` and -`moth1`. Each is a Tiled object of type `actor` with a `character` string property and a -`state` **integer** property — the two custom properties -[Chapter 13](13-tilemaps.md) documents, and `state` is a number here even though character -JSON accepts the symbolic form. `20` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT`, -from the bit table in [Chapter 12](12-actors.md). +Splitting into three `.c` files is not required — it is what keeps each one readable. The +build file: -Nothing in the program creates an actor. **Loading the map does**, which is why the load -order below is what it is. +```cmake +add_executable(sidescroller + main.c + player.c + actors.c +) -## Starting up +target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") -libakgl has exactly one startup order that works and it is written down at the top of -`include/akgl/game.h`. Three of its steps are the ones a first program gets wrong. +target_link_libraries(sidescroller + PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf + SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) +``` + +You link all four SDL libraries even though this game has no text and no sound, because +libakgl itself is built against them. + +Your assets — the PNGs, the JSON and the map — go in a directory of their own. Tell the +program where at compile time, so it can be run from any working directory: + +```cmake +target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"") +``` + +### The header + +`sidescroller.h` holds the constants and declarations the three `.c` files share. Start it +with the level's geometry: + +```c excerpt=examples/sidescroller/sidescroller.h +#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */ +#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */ +#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */ +#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */ +``` + +The game state is one struct with one instance, declared `extern` here and defined in +`main.c`: + +```c excerpt=examples/sidescroller/sidescroller.h +typedef struct { + akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */ + akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */ + akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */ + int coins_taken; + int deaths; +``` + +Fixed arrays, not allocations. The level places a known number of things. + +--- + +## 2. Open a window and run a frame loop + +### How a libakgl function reports failure + +Every call returns a pointer. `NULL` means success; anything else is an error context +carrying a status, a message and a stack trace. You never check it by hand — four macros do +that for you. + +```c +#include +#include +#include + +/* A function that calls libakgl and can fail. */ +akerr_ErrorContext AKERR_NOIGNORE *my_setup(void) +{ + PREPARE_ERROR(errctx); /* declares errctx; always first */ + + FAIL_ZERO_RETURN(errctx, akgl_gamemap, AKERR_NULLPOINTER, "no map"); + PASS(errctx, akgl_set_property("game.screenwidth", "960")); + SUCCEED_RETURN(errctx); /* always last */ +} +``` + +- `PREPARE_ERROR(errctx)` declares the local context. It is the first line of the function. +- `PASS(errctx, call)` makes the call and, if it failed, returns the error to *your* caller + with your function added to the trace. This is what you write most of the time. +- `FAIL_ZERO_RETURN(errctx, ptr, status, msg)` returns an error if `ptr` is `NULL`. Use it on + every pointer parameter before you dereference it. +- `SUCCEED_RETURN(errctx)` returns `NULL`. It is the last line of the function. + +That is enough to write this entire game. [Chapter 4](04-errors.md) covers the rest, +including how to *handle* an error rather than propagate it — which you need exactly once, in +step 13. + +### Startup, in order + +libakgl has one startup sequence that works: + +1. Fill in `akgl_game.name`, `.version` and `.uri`. `akgl_game_init` refuses to run without + all three — they become the window title and SDL's application metadata. +2. Call `akgl_game_init()`. +3. Set the configuration properties. **Before the renderer**, because the renderer reads + them. +4. Call `akgl_render_2d_init(akgl_renderer)`. +5. Call `akgl_physics_init_arcade(akgl_physics)`. + +Here is the first part: ```c excerpt=examples/sidescroller/main.c PASS(errctx, aksl_strncpy( @@ -91,11 +200,11 @@ libakgl has exactly one startup order that works and it is written down at the t sizeof(akgl_game.name) - 1)); ``` -`akgl_game.name`, `.version` and `.uri` are required and have no defaults — -`akgl_game_init` refuses to run without all three, because the window title, SDL's -application metadata and the savegame compatibility check are built from them. `aksl_strncpy` -rather than `strncpy`, per [Chapter 19](19-utilities.md) and `AGENTS.md`: these are -fixed-width fields that end up as registry keys. +`aksl_strncpy` rather than `strncpy`: it reports a truncation as an error instead of quietly +producing a shortened string. The same goes for `aksl_snprintf` and `aksl_atoi` later on. +libakstdlib is documented in `deps/libakstdlib`. + +Then the properties and the renderer: ```c excerpt=examples/sidescroller/main.c PASS(errctx, akgl_set_property("game.screenwidth", "960")); @@ -103,59 +212,345 @@ fixed-width fields that end up as registry keys. PASS(errctx, akgl_render_2d_init(akgl_renderer)); ``` -**Properties before the renderer.** `akgl_render_2d_init` reads both dimensions out of -`AKGL_REGISTRY_PROPERTIES` and an unset one defaults to the string `"0"`, which asks SDL for -a zero-sized window rather than reporting anything. See [Chapter 6](06-the-registry.md). +An unset property defaults to the string `"0"`, which asks SDL for a zero-sized window — so +set them before this call, not after. -The window is 960x480 and the view is 480x240, because libakgl draws in map pixels and has -no scale factor of its own — the only scaling it applies is the tilemap's perspective band, -and this map has none. A 16-pixel tile would otherwise be 16 screen pixels. SDL's logical -presentation supplies the missing factor: +### Scaling up the pixels + +libakgl draws in map pixels. A 16-pixel tile is 16 screen pixels, which is very small on a +modern display. Ask SDL to scale the whole picture by whole multiples: ```c excerpt=examples/sidescroller/main.c - 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() - ); ``` -That is an SDL call, not a libakgl one; it is documented in the -[SDL3 render API](https://wiki.libsdl.org/SDL3/SDL_SetRenderLogicalPresentation). The camera -then has to be told the same thing, because `akgl_render_2d_init` sized it from the window. +The game now renders a 480×240 view into a 960×480 window. Tell the camera the same thing — +the camera is what the game looks *through*, and `akgl_render_2d_init` sized it from the +window: -### `akgl_game_init` does not choose a physics backend +```c excerpt=examples/sidescroller/main.c + 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; +``` -This is the one that produces a crash rather than a message, so it gets its own heading. +### Choosing a physics backend + +`akgl_game_init` does not choose one. Do it yourself: ```c excerpt=examples/sidescroller/main.c PASS(errctx, akgl_physics_init_arcade(akgl_physics)); ``` -`akgl_game_init` points `akgl_physics` at `akgl_default_physics`, which is **zeroed storage -whose four method pointers are all `NULL`**, and never initializes it. There is no -`physics.engine` property either, whatever the `@file` block in `include/akgl/physics.h` -says — [Chapter 14](14-physics.md) has the details and the correction. Skip this call and -the first `akgl_game_update` calls through a `NULL` `simulate`. +Without this line the first frame calls through a null function pointer. Making the library +pick a default is tracked in `TODO.md` under "Known and still open"; until it does, this call +belongs in every libakgl program. -### The low-FPS hook fires every frame for the first second +### The frame loop + +Three things happen per frame: drain the event queue, position the camera, and call +`akgl_game_update`. The library does not clear or present the target, so you bracket that +call with the backend's own `frame_start` and `frame_end`: + +```c excerpt=examples/sidescroller/main.c + 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)); + } +``` + +Hand *every* event to `akgl_controller_handle_event`. An event nothing is bound to is not an +error; it is a call that does nothing. + +```c excerpt=examples/sidescroller/main.c + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); +``` + +`akgl_game_update(NULL)` goes between `frame_start` and `frame_end`. The `NULL` means "no +iterator options" — update every actor on every layer. + +The outer loop calls that once per frame: + +```c excerpt=examples/sidescroller/main.c + while ( running == true ) { + PASS(errctx, frame(&running)); +``` + +Build and run now. You get a window of the renderer's clear colour and nothing else. That is +correct — there is nothing in the world yet. + +### Silencing the frame-rate warning + +`akgl_game.lowfpsfunc` is called on every frame the frame rate is under 30, and +`akgl_game.fps` reads 0 until the first second has elapsed — so the default handler logs a +line per frame for the first second of every run. Install your own: + +```c excerpt=examples/sidescroller/main.c +static void ss_lowfps(void) +{ +} +``` + +Assign it right after `akgl_game_init()`: ```c excerpt=examples/sidescroller/main.c akgl_game.lowfpsfunc = &ss_lowfps; ``` -`akgl_game.fps` is a completed-second average, so it reads 0 for the whole first second of -the process — which is under the threshold, so the default `akgl_game_lowfps` logs a line on -every frame until the second is up. The hook exists to be replaced; a real game sheds work -there. This one replaces it with an empty function so the log is readable. +The hook exists so a game can shed work when it is running slowly. This one has nothing to +shed. The first-second false positive is recorded in `TODO.md`. -## Loading in dependency order +--- + +## 3. Describe your art + +Put your PNG in the asset directory. This game uses a 32×32-per-frame sheet for the player, +laid out left to right. + +A **sprite** is a JSON file naming the sheet and listing frames: + +```json kind=sprite setup=sidescroller +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_run_right", + "width": 32, + "height": 32, + "speed": 90, + "loop": true, + "loopReverse": false, + "frames": [ + 0, + 1, + 2, + 1 + ] +} +``` + +| Field | Means | +|---|---| +| `spritesheet.filename` | The PNG, resolved relative to this file | +| `spritesheet.frame_width`/`_height` | How the sheet is cut into numbered frames, left to right then top to bottom | +| `name` | The registry name. This is how a character asks for it | +| `width`/`height` | How big to draw it. Usually the same as the frame size | +| `speed` | Milliseconds per frame | +| `loop` | Restart at the end, rather than holding the last frame | +| `frames` | Frame numbers, in play order. `0,1,2,1` is a four-step walk cycle from three drawings | + +A still image is the same file with one frame and `"loop": false`. + +This game needs nine sprites: idle, run and jump for the player facing each way, plus one +each for the coin, the blob and the moth. Load them from a list: + +```c excerpt=examples/sidescroller/main.c +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 +}; +``` + +Two sprites naming the same PNG share one texture. The spritesheet registry is keyed on the +file path, so `player.png` is decoded and uploaded once no matter how many sprites use it. + +--- + +## 4. Bind sprites to states + +An actor's whole 32-bit `state` word is the key that picks a sprite. A **character** is the +table that maps one to the other: + +```json kind=character setup=sidescroller +{ + "name": "ss_player", + "speedtime": 120, + "speed_x": 90.0, + "speed_y": 0.0, + "acceleration_x": 600.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE", + "AKGL_ACTOR_STATE_FACE_RIGHT" + ], + "sprite": "ss_player_idle_right" + }, + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE", + "AKGL_ACTOR_STATE_FACE_RIGHT", + "AKGL_ACTOR_STATE_MOVING_RIGHT" + ], + "sprite": "ss_player_run_right" + } + ] +} +``` + +| Field | Means | +|---|---| +| `name` | Registry name. A Tiled object asks for it | +| `speed_x`/`speed_y` | Top speed on each axis, pixels per second | +| `acceleration_x`/`_y` | How fast the actor gets to that speed | +| `speedtime` | Milliseconds between animation frame advances | +| `sprite_mappings[].state` | The state bits, named. They are OR'd together | +| `sprite_mappings[].sprite` | The sprite's registry `name` | + +**A mapping matches the whole state word, not a subset.** `ALIVE|FACE_RIGHT` and +`ALIVE|FACE_RIGHT|MOVING_RIGHT` are two different keys and need two entries. An actor whose +state matches no entry is silently not drawn — if a character disappears, this is the first +thing to check. + +The player needs ten entries: idle and running each way, and jumping each way, with and +without a horizontal direction held. The full file is +`docs/tutorials/assets/sidescroller/character_ss_player.json`. + +`speed_y` is `0.0` deliberately. This character never *thrusts* upward — jumping is an +impulse, added in step 9, and a zero vertical top speed keeps that impulse out of the physics +engine's speed cap. + +Load characters after sprites: + +```c excerpt=examples/sidescroller/main.c +static char *ss_character_files[] = { + "character_ss_player.json", + "character_ss_coin.json", + "character_ss_hazard_blob.json", + "character_ss_hazard_moth.json", + NULL +}; +``` + +A character's JSON names its sprites by registry name and the loader resolves each one as it +reads. Load a character before its sprites and it fails on the first name it cannot find. + +--- + +## 5. Draw a level + +Open Tiled, make a new map, and set it up like this: + +| Setting | Value | +|---|---| +| Orientation | Orthogonal | +| Tile layer format | **CSV** (libakgl does not read compressed layer data) | +| Tile size | 16 × 16 | +| Map size | 40 × 15 tiles | + +Add your tileset image, then three layers: + +| Layer | Type | What it is | +|---|---|---| +| `background` | Tile layer | Sky, clouds. Drawn, never solid | +| `terrain` | Tile layer | Ground and platforms | +| `actors` | Object layer | Where things start | + +### Making a layer solid + +Select the `terrain` layer and add a custom property: + +| Property | Type | Value | +|---|---|---| +| `collidable` | bool | true | + +In the saved `.tmj` that is: + +```json excerpt=docs/tutorials/assets/sidescroller/level1.tmj + "properties": [ + { + "name": "collidable", + "type": "bool", + "value": true + } + ] +``` + +Every non-empty cell of a `collidable` layer is solid. A layer without the property is drawn +and nothing more. + +### Placing actors + +On the `actors` layer, place a rectangle where each thing starts. Give each one: + +- a **Name** — this is how your code finds it, so `player`, `coin1`, `blob1` +- a **Type** of `actor` — this is what tells libakgl to create one +- a custom property `character` (string) naming the character it uses +- a custom property `state` (int) — the starting state word + +In the file, one object looks like this: + +```json excerpt=docs/tutorials/assets/sidescroller/level1.tmj + "height": 32, + "id": 1, + "name": "player", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_player" + }, + { + "name": "state", + "type": "int", + "value": 20 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 32, + "y": 160 +``` + +`20` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT` — 16 plus 4. Tiled has no +symbolic constants, so you write the number. `16` alone is alive and facing nowhere, which is +right for a coin. + +### Giving the map its physics + +Add three custom properties to the **map itself** (Map → Map Properties): + +| Property | Type | Value | Means | +|---|---|---|---| +| `physics.model` | string | `arcade` | Which backend to build | +| `physics.gravity.y` | float | `900.0` | Downward acceleration, px/s² | +| `physics.drag.y` | float | `1.5` | Air resistance on the vertical axis | + +Gravity of 900 px/s² with a drag of 1.5 gives a terminal fall speed of 600 px/s. Drag is what +bounds a fall, so do not set it to zero. (A `terminal_velocity` setting is in `TODO.md` under +"Arcade physics feel".) + +Putting physics in the map rather than in code is what lets a swimming level and a walking +level differ by data. + +--- + +## 6. Load the level + +Order matters: **sprites, then characters, then the map**. The map creates actors that name +characters, and characters name sprites. ```c excerpt=examples/sidescroller/main.c for ( i = 0; ss_sprite_files[i] != NULL; i++ ) { @@ -168,82 +563,66 @@ there. This one replaces it with an empty function so the log is readable. } ``` -Sprites, then characters, then the map. That is not a preference: +`asset_path` is a three-line helper that joins the directory and the file name with +`aksl_snprintf`. -- 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 on the first sprite it cannot find ([Chapter 11](11-characters.md)); -- an `actor` object names its character the same way, and the map loader creates the actor - and binds the character during the load ([Chapter 13](13-tilemaps.md)). - -Nine sprite files and four character files, in a table rather than thirteen calls: - -```c excerpt=examples/sidescroller/main.c -static char *ss_sprite_files[] = { - "sprite_ss_player_idle_left.json", - "sprite_ss_player_idle_right.json", - "sprite_ss_player_run_left.json", - "sprite_ss_player_run_right.json", - "sprite_ss_player_jump_left.json", - "sprite_ss_player_jump_right.json", - "sprite_ss_coin.json", - "sprite_ss_hazard_blob.json", - "sprite_ss_hazard_moth.json", - NULL -}; -``` - -The map goes into `akgl_gamemap`, which already points at `akgl_default_gamemap`: +Then the map: ```c excerpt=examples/sidescroller/main.c PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path))); PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap)); ``` -That is 25 MiB of static storage in the library, and using it rather than declaring one 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. [Chapter 13](13-tilemaps.md) -has the size breakdown. +**Load into `akgl_gamemap`, which already points at storage the library owns.** Do not +declare an `akgl_Tilemap` on the stack: it is about 26 MB, several times a default thread +stack, and you get a segfault before the loader writes a byte. Shrinking it is `TODO.md` +targets 14 and 15. -## The map brings its own physics +That one call creates an actor for every `actor` object in the object layer, binds each to +the character its property names, and publishes it in the actor registry under its Tiled +name. -`level1.tmj` carries `physics.model`, `physics.gravity.y` and `physics.drag.y` as map-level -custom properties, so the loader built a backend for it 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 — so honouring it is one line the game writes: +### Honouring the map's physics + +The loader built a backend from the map's properties but did not switch to it. That is your +call, one line: ```c excerpt=examples/sidescroller/main.c if ( akgl_gamemap->use_own_physics == true ) { akgl_physics = &akgl_gamemap->physics; ``` -The numbers are gravity 900 px/s² and drag 1.5. The drag is doing a job the engine has no -other word for. **There is no terminal velocity in libakgl**: `akgl_physics_arcade_gravity` -adds `gravity_y * dt` to the actor's `ey` every step and nothing bounds it — the simulation -in `tests/physics_sim.c` reaches 560 px/s in 0.7 s and keeps going. Drag is a first-order -decay applied to the same term, so `ey` converges on `gravity_y / drag_y` instead of -diverging: 900 / 1.5 = **600 px/s, and that is this level's terminal velocity**. It is the -only brake there is; `TODO.md`, "Arcade physics feel", records the gap. +### Restamping the clock -Keep `drag * max_timestep` below 1. `ex -= ex * drag_x * dt` only decays while -`drag * dt < 1`; past that it overshoots zero and past 2 it diverges, and nothing rejects -it. With `physics.max_timestep` at its default 0.05 s that needs a drag above 20. - -One more line before the loop starts: +The physics step measures elapsed time from `akgl_physics->gravity_time`. Loading nine +sprites, four characters and a map took real time, and the first step would otherwise try to +simulate all of it at once: ```c excerpt=examples/sidescroller/main.c akgl_physics->gravity_time = SDL_GetTicksNS(); ``` -`dt` is measured from `gravity_time`, not passed in, and everything above — nine sprite -files, four characters, a map and a tileset image — happened between the backend being -created and the first step. The `max_timestep` bound would have caught it, at the cost of -one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md). +Do this after every load, immediately before the first frame. -## Collision, in three lines +### Finding the actors -The game does not implement collision. It attaches a world and the library resolves through -it: +The map published them by name. Look each one up: + +```c excerpt=examples/sidescroller/main.c + player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); + FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player"); +``` + +A miss means the map and the code disagree about what the level contains, which is worth +failing on. + +Run now. The level draws, the actors appear, and everything falls through the floor. + +--- + +## 7. Turn on collision + +Collision is opt-in. Three lines switch it on: ```c excerpt=examples/sidescroller/main.c PASS(errctx, akgl_collision_world_init(&ss_collision, NULL, (float32_t)SS_TILE_SIZE, (float32_t)SS_TILE_SIZE)); @@ -251,139 +630,220 @@ it: akgl_physics->collision = &ss_collision; ``` -Plus a shape on anything that should collide, which is two lines per actor: +`ss_collision` is one global `akgl_CollisionWorld`, defined in `main.c` and declared `extern` +in your header. `NULL` for the second argument means the default spatial index, which is the +one to use. `akgl_collision_bind_tilemap` reads the tile size off the map and finds the +`collidable` layers you marked in step 5. + +Do this *after* loading the map and *after* switching to the map's physics backend. + +### Giving the player a body + +A collision shape is a box measured from the actor's position. Inset it into the sprite frame +— art does not reach the edges of its cell, and a full-frame box catches on doorways the +character visibly clears: + +```c excerpt=examples/sidescroller/sidescroller.h +#define SS_PLAYER_BOX_X 8.0f +#define SS_PLAYER_BOX_Y 0.0f +#define SS_PLAYER_BOX_W 16.0f +#define SS_PLAYER_BOX_H 32.0f +``` ```c excerpt=examples/sidescroller/player.c PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f)); obj->shape_override = true; ``` -That is the whole of it. [Chapter 15](15-collision.md) is the reference; what follows is -what this game had to know. +The `0.0f` is the depth along z; passing 0 lets libakgl choose one. +[Chapter 15](15-collision.md) explains why a 2D shape has a depth at all. +`shape_override = true` says this actor's shape is its own rather than its character's. -### Why it resolves after the move and not before +An actor with a shape collides with map geometry and with *no other actor*. That is the +default, and it is what you want in a level full of scenery. Actor-versus-actor is one added +bit; [Chapter 15](15-collision.md) covers the masks. -The only per-actor hook the physics step used to offer was `movementlogicfunc`, and it runs -in the wrong place: +### Lifting a spawn point clear -```text - akgl_physics_simulate, per actor: +Level editors round objects onto a grid, so a spawn point often overlaps a tile. Collision +stops an actor *entering* geometry and has nothing to say about one that started inside it — +such an actor is simply stuck. Lift it clear once, before the first frame: - tx += ax * dt <- thrust, from the previous step's ax - cap (tx,ty,tz) to the speed ellipse - movementlogicfunc(actor, dt) <- the only hook there used to be - gravity(self, actor, dt) ey += gravity_y * dt - ex -= ex * drag_x * dt (and y, z) - vx = ex + tx (and y, z) - move(self, actor, dt) x += vx * dt - collide(self, actor, dt) <- where resolution actually happens +```c excerpt=examples/sidescroller/player.c + PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0)); ``` -A resolver in `movementlogicfunc` runs before gravity, drag and the move, so it cannot look -at where the actor ended up — the actor has not moved yet. It has to *predict* the step, -which means copying the six lines above into the game, `!= 0` guards included, in a file -that will not be recompiled when they change. +Run now. The player and the blob stand on the ground. Nothing moves yet. -This tutorial used to carry that copy, and 383 lines around it. All of it is gone. `collide` -runs after `move` on a position that already happened, so there is nothing to predict. +--- -**One symptom is worth keeping, because it is what a predicting resolver costs.** The -obvious thing for such a resolver to do on a blocked vertical axis is zero `ey`. It is -wrong: the step is about to add `gravity_y * dt` back and `move` commits `gravity_y * dt²` -of fall — a quarter of a pixel at 900 px/s² and 60 Hz. Invisible, and fatal. That quarter -pixel of overlap means the box intersects the floor tile, so on the *next* step the -horizontal sweep finds itself blocked wherever it goes and snaps back to a tile boundary. -The symptom is a character who cannot walk, jerking backwards by up to a tile every time it -tries, and nothing about it looks like a vertical problem. The fix was to pre-load the -cancellation — `ey = -gravity_y * dt` — which is arithmetic no game should ever have had to -write. Resolving after the move deletes the whole class. +## 8. Make the player walk -### Solid is data now, not a layer id compiled into the game +### The two hooks -The old version matched Tiled's numeric layer id, because **`akgl_TilemapLayer` does not -record the layer's name** — `akgl_tilemap_load_layers` reads `id`, `opacity`, `visible`, -`x`, `y` and `type` and nothing else, so there is no way to ask for "the layer called -terrain". A `#define SS_TERRAIN_LAYER_ID 2` broke the moment somebody reordered layers in -the editor. +An actor carries seven function pointers. Two matter here, and the difference between them is +*when in the frame they run*: -The map says it instead. `level1.tmj`'s terrain layer carries a custom property: +| Hook | Called by | Put here | +|---|---|---| +| `updatefunc` | the actor sweep, before physics | per-frame game logic: picking things up, dying | +| `movementlogicfunc` | the physics step, before gravity and the move | anything that has to affect this step's motion | -```json excerpt=docs/tutorials/assets/sidescroller/level1.tmj - "properties": [ - { - "name": "collidable", - "type": "bool", - "value": true - } - ] +Install them **after** the actor exists. `akgl_actor_initialize` — which the map loader +already ran — overwrites all seven: + +```c excerpt=examples/sidescroller/player.c + obj->movementlogicfunc = &ss_player_movement; + obj->updatefunc = &ss_player_update; ``` -`akgl_collision_bind_tilemap` reads it off every layer and the game names no layer at all. +Replacing a hook does not mean reimplementing it. Call the default first and add to it: -### Standing on something is still measured +```c excerpt=examples/sidescroller/player.c + PASS(errctx, akgl_actor_logic_movement(obj, dt)); +``` -Nothing in libakgl records whether an actor is on the ground, and a contact does not answer -it either — a contact says something pushed back this step, which is a different question -from "is there a floor to push off". So it stays a probe: +`akgl_actor_logic_movement` copies the character's speeds onto the actor and turns the +movement bits into signed acceleration. + +### Binding keys + +A control map ties an event to a handler and a target actor. Fill in an `akgl_Control` and +push it: + +```c excerpt=examples/sidescroller/player.c + control.event_on = SDL_EVENT_KEY_DOWN; + control.event_off = SDL_EVENT_KEY_UP; + + control.key = SDLK_LEFT; + control.handler_on = &akgl_actor_cmhf_left_on; + control.handler_off = &ss_control_left_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); +``` + +`akgl_actor_cmhf_left_on` is one of the library's built-in handlers: it sets +`AKGL_ACTOR_STATE_MOVING_LEFT` and signs the acceleration. There is a matching +`akgl_actor_cmhf_left_off`; step 9 explains why this game supplies its own instead. + +Point the map at the actor, and set both device ids to 0, which means "any": + +```c excerpt=examples/sidescroller/player.c + controlmap->kbid = 0; + controlmap->jsid = 0; +``` + +Use 0 unless you are writing a two-player game on two keyboards. The id a key event carries +is chosen by the video backend and is not the id `SDL_GetKeyboards()` reports. + +A gamepad is the same table with different event types. Clear `key` first — a keyboard event +is matched on `key` whatever else the binding carries, and 0 is a keycode like any other: + +```c excerpt=examples/sidescroller/player.c + control.key = 0; + control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN; + control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP; +``` + +Bind controls **after** the map is loaded. The map is what creates the actor the control map +targets. + +### Keeping the player visible + +Add one line where you set up the player: + +```c excerpt=examples/sidescroller/player.c + obj->movement_controls_face = false; +``` + +The default facing logic clears every facing bit and sets one from the *movement* bits — so +an actor that stops moving is left facing nowhere. Its state drops to bare `ALIVE`, which +your character has no sprite for, and it stops being drawn. Clearing this field leaves the +facing bits wherever the control handlers put them. Making the default behave is tracked in +`TODO.md`. + +Run now. The arrow keys walk the player, the run animation plays, and the player stops at +walls. + +--- + +## 9. Make the player jump + +### Knowing you are on the ground + +Nothing in libakgl records whether an actor is standing on something, and a collision contact +does not answer it either — a contact says something pushed back this step, which is a +different question from "is there a floor to push off". Ask directly: ```c excerpt=examples/sidescroller/main.c 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)); ``` -One pixel, 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. +`akgl_collision_shape_bounds` turns a shape plus a position into a rectangle; +`akgl_collision_box_blocked` says whether that rectangle overlaps anything solid. 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. -The verdict is one frame stale by the time the jump reads it, because `movementlogicfunc` -runs before the step it is deciding about. One frame of coyote time is not something a -player can feel. - -### Spawning inside the scenery - -A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the -player at x=32 with a step at tile (3,11) — under the right half of the player's frame. -Resolution cannot help: it stops an actor *entering* geometry and has nothing to say about -one that began inside it. What it does instead is refuse every move, so the actor is simply -stuck. - -So spawn points get lifted clear once, before the first step: +Call it at the top of `movementlogicfunc`: ```c excerpt=examples/sidescroller/player.c - PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0)); + PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &ss_game.grounded)); ``` -The player and the blob both need it, and both end up standing on the block they were -overlapping. `akgl_collision_settle` refuses loudly after four tiles rather than searching -forever: a spawn point buried that deep is a level bug, and a silent nudge would hide it. +### The jump -### What the blob still asks for itself +An actor's velocity has two parts that are added together every step: -The blob turns around at a wall or a ledge, and the resolver answers neither question — it -says something pushed back, not which side of the blob it was on, and it says nothing at all -about a floor that is *missing*. Both are queries: +- **`tx`, `ty`** — *thrust*. What the character is pushing itself with. Capped against the + character's `speed_x`/`speed_y`. +- **`ex`, `ey`** — *environment*. What the world is doing to it. Gravity accumulates here. -```c excerpt=examples/sidescroller/actors.c - PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead)); - PASS(errctx, akgl_collision_box_blocked(&ss_collision, &ahead, AKGL_COLLISION_LAYER_STATIC, &wall_ahead)); - PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded)); +A jump is an impulse into `ey`, not thrust: + +```c excerpt=examples/sidescroller/player.c + if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) { + obj->ey = -SS_JUMP_SPEED; + } + ss_game.jump_requested = false; ``` -This is what the query API is for: asking without being pushed. +It has to be `ey`, because `ey` is where gravity accumulates and the two must cancel for the +arc to come back down. Written as thrust it would be capped against the character's `speed_y` +of 0 and scaled to nothing. -## Making it feel like a platformer +The key handler only *asks*; whether a jump is allowed is the movement function's decision: -Two of libakgl's documented gaps are about feel rather than correctness, and both are in -`TODO.md` under "Arcade physics feel". The tutorial works around both rather than pretending. +```c excerpt=examples/sidescroller/player.c + ss_game.jump_requested = true; +``` -### Releasing a direction stops the actor dead +Releasing the button early cuts the jump short. Four lines gives you variable jump height: -`akgl_actor_cmhf_left_off` clears the movement bit, zeroes `ax`, **and zeroes `tx`**. There -is no friction and no deceleration anywhere in the arcade backend, so a character at full -speed stops within one frame — 0.0 px of drift, measured. That is correct for a top-down -Zelda and wrong for a sidescroller. +```c excerpt=examples/sidescroller/player.c + if ( obj->ey < 0.0f ) { + obj->ey *= 0.4f; + } +``` -The game binds its own release handlers, which do everything the library's do except the -last part: +### Picking the jump sprite + +Set `AKGL_ACTOR_STATE_MOVING_UP` whenever the actor is off the ground, and your character's +mappings do the rest: + +```c excerpt=examples/sidescroller/player.c + if ( ss_game.grounded == true ) { + AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP); + } else { + AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP); + } +``` + +Use `AKGL_BITMASK_ADD`, `_DEL` and `_HAS` rather than writing `|` and `&` by hand. + +### Friction + +Bind your own release handlers, which clear the movement bit and the acceleration but leave +the thrust alone: ```c excerpt=examples/sidescroller/player.c static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event) @@ -397,106 +857,152 @@ static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event } ``` -and decays `tx` in the movement logic instead, faster on the ground than in the air: +Then decay the thrust yourself, faster on the ground than in the air: ```c excerpt=examples/sidescroller/player.c - friction = SS_FRICTION_AIR; - if ( ss_game.grounded == true ) { - friction = SS_FRICTION_GROUND; - } obj->tx -= obj->tx * friction * dt; if ( fabsf(obj->tx) < 1.0f ) { obj->tx = 0.0f; } ``` -The snap to zero below a pixel per second is there because an exponential decay never -actually arrives. - -The `_on` handlers are the library's unchanged. `akgl_actor_cmhf_right_on` clears -`FACE_ALL | MOVING_ALL`, sets `MOVING_RIGHT | FACE_RIGHT` and signs `ax` from the character -— exactly right. Only the release half needed replacing. [Chapter 16](16-input.md) covers -building a control map; the whole of this game's is six bindings, three keyboard and three -gamepad. - -### A jump is an impulse into `ey`, not thrust - -```c excerpt=examples/sidescroller/player.c - if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) { - obj->ey = -SS_JUMP_SPEED; - } - ss_game.jump_requested = false; +```c excerpt=examples/sidescroller/sidescroller.h +#define SS_FRICTION_GROUND 12.0f +#define SS_FRICTION_AIR 1.5f ``` -It has to be the environmental term. `ey` is the axis gravity accumulates on, and the two -have to cancel for the arc to come back down. Written as thrust it would not work at all: -`ty` is capped against the character's `speed_y`, which is `0.0` for this character, so -`akgl_physics_simulate`'s ellipse cap scales it to nothing. See the thrust-versus-velocity -model in [Chapter 14](14-physics.md). +Snap to zero below a pixel per second, because an exponential decay never actually arrives. -`ey` being an ordinary field that nothing else owns between steps also buys variable jump -height for four lines — releasing the button while still rising cuts the remaining -velocity: +The library's own `akgl_actor_cmhf_left_off` zeroes `tx` outright, which stops the actor dead +in one frame — right for a top-down game, wrong for a sidescroller. Friction and deceleration +in the backend are tracked in `TODO.md` under "Arcade physics feel"; when they land, this +whole section becomes a setting. + +Run now. The player runs, slides to a stop, jumps, and holds the jump sprite through the arc. + +--- + +## 10. Scroll the camera + +`akgl_camera` is a plain `SDL_FRect` in map pixels that the library reads. Moving it is the +whole of scrolling: + +```c excerpt=examples/sidescroller/main.c + 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); +``` + +Centre on the player, clamp to the level, and **floor to a whole pixel**. The tile drawing +truncates the camera position when it works out how much of an edge tile to show, so a camera +that is fractionally different every frame makes the tile grid shimmer. + +Call it once per frame, before `akgl_game_update`. + +--- + +## 11. Collect coins and die on hazards + +This is game logic, not physics, so it goes in `updatefunc`. Call the default first: ```c excerpt=examples/sidescroller/player.c - if ( obj->ey < 0.0f ) { - obj->ey *= 0.4f; + PASS(errctx, akgl_actor_update(obj)); +``` + +### Overlap tests + +`akgl_collide_rectangles` answers "do these two rectangles overlap" with a `bool`. That is +the right tool for a pickup — you want to know, not to be pushed: + +```c excerpt=examples/sidescroller/player.c + PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other)); + PASS(errctx, akgl_collide_rectangles(&player, &other, &hit)); +``` + +`hitbox` insets a rectangle into the 32×32 frame, for the same reason the collision shape was +inset: a hazard box the full size of the frame kills a player who is visibly nowhere near it. + +### Removing an actor + +There is no "despawn" call. Giving the pool slot back is what unregisters the actor and stops +it being drawn: + +```c excerpt=examples/sidescroller/player.c + PASS(errctx, akgl_heap_release_actor(ss_game.coins[i])); + ss_game.coins[i] = NULL; + ss_game.coins_taken += 1; +``` + +Releasing another actor from inside the update sweep is safe: the sweep re-reads the +reference count at the top of every iteration and skips a slot that has gone free. Clear your +own pointer to it in the same breath. + +### Falling out of the level + +Nothing stops an actor leaving the map, so check for it: + +```c excerpt=examples/sidescroller/player.c + if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) { + PASS(errctx, respawn(obj)); + SUCCEED_RETURN(errctx); } ``` -## The state word chooses the sprite, and one bit is missing +### Respawning -An actor's whole 32-bit `state` is the key that selects a sprite ([Chapter 12](12-actors.md)). -The player's character binds ten combinations: idle and running each way, and jumping each -way. - -The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever the actor is -not on the ground: +Clear everything the simulation carries between steps, not just the position: ```c excerpt=examples/sidescroller/player.c - if ( ss_game.grounded == true ) { - AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP); - } else { - AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP); - } + obj->x = data->home_x; + obj->y = data->home_y; + obj->ex = 0.0f; + obj->ey = 0.0f; + obj->tx = 0.0f; + obj->ty = 0.0f; + obj->vx = 0.0f; + obj->vy = 0.0f; ``` -Nothing else reads the bit here. `speed_y` and `acceleration_y` are both `0.0` in -`character_ss_player.json`, so the vertical thrust it would authorise is capped to nothing — -the bit is being used purely as an animation selector, which is a thing the state mask is -good for. +`ey` is where the fall accumulated. A player who respawns still holding a full-speed fall +lands dead again immediately. -The missing bit is the one that makes the player disappear. The default `facefunc`, -`akgl_actor_automatic_face`, clears every facing bit and then sets one from the *movement* -bits — so **an actor that stops moving is left facing nowhere**, its state drops to bare -`ALIVE`, and there is no sprite for that. It is not an error: `akgl_actor_render` handles -the `AKERR_KEY`, treats the actor as invisible, and draws nothing. A player who stops -walking vanishes. +### Where to keep per-actor data -The documented way out is to take the facing bits off the library entirely: +`akgl_Actor::actorData` is a `void *` the library never reads or frees. Point it at a fixed +table: -```c excerpt=examples/sidescroller/player.c - obj->movement_controls_face = false; +```c excerpt=examples/sidescroller/actors.c +static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT]; ``` -`akgl_actor_automatic_face` leaves an actor alone when that is clear, so the facing bits stay -wherever the control handlers last put them — and `akgl_actor_cmhf_left_off` clears -`MOVING_LEFT` without touching `FACE_LEFT`, which is exactly the behaviour a standing sprite -needs. The alternative, mapping bare `ALIVE` to something, is in -[Chapter 12](12-actors.md). +```c excerpt=examples/sidescroller/sidescroller.h +typedef struct { + float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */ + float32_t home_y; + float32_t phase; /**< Seconds of flight, for the moth's orbit. */ + float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */ +} ss_ActorData; +``` -## `AKGL_ERR_LOGICINTERRUPT`, in a game +--- -The one status in libakgl that is not a failure. Raised from a `movementlogicfunc` it means -"skip the rest of this tick for me" — no gravity, no drag, no move — and -`akgl_physics_simulate` handles it and carries on to the next actor. +## 12. Add moving enemies -Two of this game's three non-player behaviours are built on it, for two different reasons. +### Something that does not move at all -The coins need it because **gravity is not thrust**. Their character declares no speed and -no acceleration, so they cannot thrust; `akgl_physics_arcade_gravity` accumulates into `ey` -for every actor it is handed regardless, and a coin left to the default logic falls out of -the level with everything else. +The coins need a `movementlogicfunc` of their own. Their character has no speed and no +acceleration so they cannot thrust — but gravity is not thrust, and a coin left to the +default logic falls out of the level with everything else. + +Raising `AKGL_ERR_LOGICINTERRUPT` is how an actor opts out of the rest of its step. It is +**not a failure**: the physics step catches it, skips gravity, drag, the move and collision +for that actor, and carries on to the next one. ```c excerpt=examples/sidescroller/actors.c static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt) @@ -508,8 +1014,51 @@ static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt) } ``` -The moth needs it because it flies and the level's physics has gravity, which the player -needs. Rather than fighting the backend it writes its own position and opts out: +**Only a `movementlogicfunc` may raise it.** From anywhere else it aborts the whole physics +step and leaves every remaining actor unsimulated. + +### A blob that patrols + +The blob walks under gravity like the player does and the library resolves it the same way. +Its hook only decides which way to face next. + +Set the facing and movement bits, then let the default logic sign the acceleration: + +```c excerpt=examples/sidescroller/actors.c + AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL)); + if ( data->facing < 0.0f ) { + AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT)); + } else { + AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT)); + } +``` + +Then probe for a wall ahead and a floor ahead: + +```c excerpt=examples/sidescroller/actors.c + PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead)); + PASS(errctx, akgl_collision_box_blocked(&ss_collision, &ahead, AKGL_COLLISION_LAYER_STATIC, &wall_ahead)); + PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded)); +``` + +```c excerpt=examples/sidescroller/actors.c + PASS(errctx, akgl_collision_solid_at(&ss_collision, probe_x, probe_y, &floor_ahead)); +``` + +`akgl_collision_solid_at` is the cheapest query there is: is the tile under this one point +solid. + +Turn around on either: + +```c excerpt=examples/sidescroller/actors.c + if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) { + data->facing = -data->facing; +``` + +### A moth that flies + +A flying enemy wants no gravity, and the map has gravity because the player needs it. Rather +than fighting the backend, write the position directly and then opt out: ```c excerpt=examples/sidescroller/actors.c data->phase += dt; @@ -517,105 +1066,29 @@ needs. Rather than fighting the backend it writes its own position and opts out: obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f); ``` -Two rules go with it, both from [Chapter 4](04-errors.md): +```c excerpt=examples/sidescroller/actors.c + FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name); +``` -- **Only `movementlogicfunc` gets this treatment.** The backend's `gravity` and `move` are - called through `PASS` in the same block, so a raise from either aborts the whole step — - leaving every remaining actor unsimulated and `gravity_time` unadvanced. -- The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN` - inside one returns past `CLEANUP`. +Two sines at a 1:2 ratio is a figure eight. -The blob is the counter-example: it stays inside the physics step and walks under the -level's gravity like the player does, and the library resolves it the same way. All its hook -decides is which way to face next — a wall ahead, or no floor one pixel past its leading -edge: +### Wiring them up + +Find each actor by its Tiled name and install its hook: ```c excerpt=examples/sidescroller/actors.c - if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) { - data->facing = -data->facing; + PASS(errctx, find_actor("blob1", &ss_game.hazards[0])); + PASS(errctx, akgl_collision_shape_box(&ss_game.hazards[0]->shape, &ss_blob_body, 0.0f)); + ss_game.hazards[0]->shape_override = true; ``` -### Per-actor data +The moth needs no shape — it never touches terrain. -`akgl_Actor::actorData` is a `void *` the library never reads or frees, and it is where the -blob's patrol direction and the moth's flight phase live. It points into a fixed table: +--- -```c excerpt=examples/sidescroller/actors.c -static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT]; -``` +## 13. Tear down -A table rather than an allocation, for the same reason libakgl has pools rather than a -`malloc`: the level places a known number of things, and a game that cannot run out of -memory at runtime is one fewer failure mode. See [Chapter 5](05-the-heap.md). - -## Picking things up - -Collecting a coin is a rectangle test from [Chapter 19](19-utilities.md) and a pool release: - -```c excerpt=examples/sidescroller/player.c - PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other)); - PASS(errctx, akgl_collide_rectangles(&player, &other, &hit)); - if ( hit == true ) { -``` - -```c excerpt=examples/sidescroller/player.c - PASS(errctx, akgl_heap_release_actor(ss_game.coins[i])); - ss_game.coins[i] = NULL; - ss_game.coins_taken += 1; -``` - -**There is no despawn call.** Giving the pool slot back is what clears the registry entry -and stops the actor being drawn, and it is safe to do from inside `akgl_game_update`'s actor -sweep: that loop re-reads `refcount` at the top of every iteration and skips a slot that has -gone free. Releasing an actor does not touch its character, so the other coins are -unaffected. - -This runs in the player's `updatefunc` rather than its `movementlogicfunc`, and the split is -the frame's own order. `akgl_game_update` calls every actor's `updatefunc`, *then* steps the -physics, *then* draws. Game logic that is not movement belongs in the first pass; anything -that has to happen inside the physics step has only the one hook. - -## The frame - -```c excerpt=examples/sidescroller/main.c - PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); -``` - -```c excerpt=examples/sidescroller/main.c - PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); -``` - -`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 own calls — see -[Chapter 7](07-the-game-and-the-frame.md) and [Chapter 8](08-rendering.md). - -Note the failure contract. **`akgl_game_update` returns holding the game state lock on every -one of its failure paths.** 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: some -actors advanced and some not. Treat it as terminal, which is what `PASS` does here. - -Events go through `akgl_controller_handle_event` unconditionally — an event that no control -map binds is not an error, it is a call that did nothing, which is what lets a host pump -everything through one path. - -## The camera - -```c excerpt=examples/sidescroller/main.c - 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); -``` - -`akgl_camera` is a plain `SDL_FRect` in map pixels that `akgl_tilemap_draw` and -`akgl_actor_render` both read. Moving it is the whole of scrolling; there is no camera -object and no follow behaviour to configure. It is floored to a whole pixel afterwards -because `akgl_tilemap_draw` truncates it when working out how much of the edge tiles to -show, and a camera that is fractionally different every frame makes the tile grid shimmer. - -The camera is updated before `akgl_game_update` and therefore uses the player's position -from the end of the *previous* step. Correcting that would mean splitting the update from -the draw, which `akgl_game_update` does not offer. - -## Teardown +**There is no `akgl_game_shutdown`.** Teardown is yours: ```c excerpt=examples/sidescroller/main.c IGNORE(akgl_text_unloadallfonts()); @@ -624,135 +1097,85 @@ the draw, which `akgl_game_update` does not offer. IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i])); } } - if ( akgl_gamemap != NULL ) { - IGNORE(akgl_tilemap_release(akgl_gamemap)); - } - TTF_Quit(); - MIX_Quit(); - SDL_Quit(); ``` -The `NULL` guard on the map is not defensive padding. This function is called from the -program's `CLEANUP` block, which is reached whether startup succeeded or not — and a bad -`--assets` path fails before `akgl_game_init` has pointed `akgl_gamemap` at anything. +`IGNORE` is the fourth macro: make the call and discard the error. It is the right thing on a +teardown path, where there is nobody left to report to. It is the *wrong* thing anywhere +else. -**There is no `akgl_game_shutdown`.** Teardown belongs to the application, 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 -([Chapter 17](17-text-and-fonts.md)). This game loads no fonts and calls it anyway, because -the ordering is the thing worth copying. +**`akgl_text_unloadallfonts` must run before `TTF_Quit`**, which destroys the fonts +underneath the registry that still points at them. -What this does **not** do is unwind the sprite, spritesheet and character pools. They are -static storage in a process that is exiting and the objects still reference each other; a -game that loads a second level has to do it properly, and this one does not pretend to. Two -things to know before you write that code: +Do not call `akgl_tilemap_release` unless you are loading a second level — it has a +double-free, recorded in `TODO.md` under "Known and still open" item 2. A process that is +exiting can leave the textures to `SDL_Quit`. -- `akgl_heap_release_character` calls `akgl_character_state_sprites_iterate` on every - release, and that function is an `SDL_EnumerateProperties` callback ending in - `FINISH_NORETURN` — **an error inside it exits the process**. This is an ordinary path, - not an unusual one. [Chapter 11](11-characters.md) and [Chapter 4](04-errors.md). -- `akgl_tilemap_release` does not release the actors an object layer created. Those are - yours, which is what the loop above is for. +### Reporting a failure from `main` -## Known defects you will see running this +`main` returns `int`, so the usual `FINISH` will not compile there. Use this shape: -Each is cross-referenced to `TODO.md`. None is worked around, because in each case the -workaround would be worse than the symptom. - -**The leftmost column of tiles is drawn from the wrong part of the tileset when the camera -is within one tile of x=0.** `akgl_tilemap_draw` special-cases the first visible column to -show a partial tile, and writes `src.x += (int)viewport->x % map->tilewidth` — a `+=` onto -whatever `src.x` was left holding by the last tile of the previous row, rather than an -assignment onto that tile's own offset (`src/tilemap.c:766`). At `viewport->x = 0` the -added term is zero and the stale value is used unchanged. The visible result in this game is -a stray tile at the bottom-left of the screen, which disappears the moment the camera scrolls -away from the origin and comes back when the player walks home. `TODO.md`, "Performance" -item 5, records it alongside the tileset scan it lives in; the same shape applies to `src.y` -and the top row. - -**An actor on layer 16 or higher is never drawn.** `akgl_Actor::layer` is a `uint32_t` and -nothing range-checks it, but `akgl_render_2d_draw_world` stops at `AKGL_TILEMAP_MAX_LAYERS`. -This level's object group is layer index 2, so it does not bite here — but a map with -seventeen layers loses its actors silently. [Chapter 12](12-actors.md). - -**A wrong name in an asset file can kill the process rather than raise.** The six -`SDL_EnumerateProperties` callbacks in libakgl end in `FINISH_NORETURN`, and libakerror's -default unhandled-error handler calls `akerr_exit()`. `akgl_game_update` does not go through -one — it sweeps the pool directly and propagates — so this game reaches it only through -`akgl_heap_release_character`. Install your own `akerr_handler_unhandled_error` if a game -should survive it, and call `akerr_exit()` from it. [Chapter 4](04-errors.md). - -**`akgl_tilemap_load` releases nothing on a failed load.** Textures already uploaded and -actors already created stay where they are and the destination holds a half-built map. This -game treats a failed load as fatal, which is the only honest thing to do with one map. -[Chapter 13](13-tilemaps.md). - -## The smoke test - -The game is registered as a CTest case, so a change that breaks it turns the suite red -rather than waiting for a reader to notice: - -```cmake -add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay) -set_tests_properties(example_sidescroller PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" -) +```c excerpt=examples/sidescroller/main.c + 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"); ``` -Four seconds of scripted play under the headless drivers. `--autoplay` holds *right* and -taps jump every forty-five frames, and it does it by pushing synthetic events through -`akgl_controller_handle_event` rather than by calling the handlers: +- `ATTEMPT` … `CLEANUP` … `PROCESS` … `HANDLE_DEFAULT` … `FINISH_NORETURN` is the + error-handling block. `CATCH` inside it is what `PASS` is outside it. +- `CLEANUP` runs on every path, success or failure. Teardown goes there. +- **Never use a `*_RETURN` macro inside `ATTEMPT`.** It returns past `CLEANUP`, so every + release and `fclose` is skipped. +- Set a flag in `HANDLE_DEFAULT` and return it after `FINISH_NORETURN`. Returning from inside + a `HANDLE` block leaks the error context's pool slot. -```c excerpt=examples/sidescroller/player.c - if ( frame == 1 ) { - synthetic.type = SDL_EVENT_KEY_DOWN; - synthetic.key.which = SS_AUTOPLAY_KBID; - synthetic.key.key = SDLK_RIGHT; - PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic)); - FAIL_ZERO_RETURN( - errctx, - AKGL_BITMASK_HAS(ss_game.player->state, AKGL_ACTOR_STATE_MOVING_RIGHT), - AKGL_ERR_BEHAVIOR, - "the right arrow did not reach the player; the control map matched nothing" - ); - } +--- + +## Build it and run it + +```sh norun +cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build --parallel +./build/examples/sidescroller/sidescroller ``` -**That distinction is the whole value of the smoke test, and the first version got it -wrong.** Calling the handlers directly is easier and it works — but it skips the binding -table, which is the part with something to get wrong. This game shipped with a control map -that matched no keyboard on earth, and a smoke test that called the handlers passed anyway. -A test that cannot fail is not a test, so the script drives the real dispatch and then -asserts the press actually arrived. Break the binding and the run exits non-zero naming the -reason. +| Control | Keyboard | Gamepad | +|---|---|---| +| Walk left / right | ← → | D-pad left / right | +| Jump | Space | A (south) | +| Quit | Close the window | — | -`SS_AUTOPLAY_KBID` is deliberately not `0`: the map binds keyboard `0` meaning *any* -keyboard, and driving it from a non-zero device id is what proves that. In four seconds the -script walks the level, jumps, collides with terrain, collects a coin, falls in the pit and -respawns — so the smoke test exercises the input path, the collision and the pickup, not -just the startup. +Holding the jump key longer jumps higher. -A run ends with a line naming what happened, which is worth reading when a change moves the -feel: +To run it without a display — in CI, or to check that it still works: + +```sh norun +SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \ + ./build/examples/sidescroller/sidescroller --frames 240 --autoplay +``` + +It prints where the player finished: ```text -sidescroller: 240 frames, 1 of 4 coins, 1 deaths +sidescroller: 240 frames, 0 of 4 coins, 0 deaths, player at 136.0,160.0 grounded ``` -The two counts are not fixed. `dt` is measured from the wall clock rather than passed in -([Chapter 14](14-physics.md)), so a busier machine takes slightly different steps and the -script lands in slightly different places. That is why the smoke test asserts a clean exit -rather than a score — an assertion on the numbers would be a test of the scheduler. +`grounded` and a sensible `y` are how you know collision ran. If the player is hundreds of +pixels below the level, revisit step 7. ## Where to look next -- [Chapter 14](14-physics.md) — thrust, environmental velocity, the speed ellipse, and the - full list of what is not implemented. -- [Chapter 12](12-actors.md) — the state mask, the six hooks, and the control handlers. -- [Chapter 13](13-tilemaps.md) — what the map loader accepts, and the three extensions to - Tiled this level uses. -- [Chapter 16](16-input.md) — control maps, and why a binding that never fires is usually - the wrong device id. -- [Chapter 21](21-tutorial-jrpg.md) — the same library from the other end: a top-down game - with no gravity, where the content pipeline is the interesting part. +- [Chapter 15](15-collision.md) — the collision masks, the query API, and what happens when + something moves faster than 1280 px/s. +- [Chapter 14](14-physics.md) — thrust versus environment, and the four gaps in the feel this + chapter works around. +- [Chapter 12](12-actors.md) — the other five behaviour hooks, and parent/child actors. +- [Chapter 13](13-tilemaps.md) — the rest of the map format and the limits that bind a level. +- [Chapter 21](21-tutorial-jrpg.md) — the same shape of program with no gravity, NPCs and a + text box. diff --git a/docs/21-tutorial-jrpg.md b/docs/21-tutorial-jrpg.md index b1bd055..e64d976 100644 --- a/docs/21-tutorial-jrpg.md +++ b/docs/21-tutorial-jrpg.md @@ -1,92 +1,152 @@ # 21. Tutorial: a top-down JRPG -A town, three people standing in it, a party member who follows you around, and -a box that tells you what the elder thinks of the road north. About six hundred -lines of C, in `examples/jrpg/`, built by `all` and run headless by `ctest`. +![The finished JRPG: a player and a follower standing in a walled town beside a villager, with a dialogue panel open at the bottom of the screen reading "ELDER: The old road north is closed."](images/jrpg.png) -This is the **content-pipeline** tutorial. Its subject is the road from a -directory of JSON and PNG to a world with people in it: twenty-four sprites, three -characters, one Tiled map that spawns its own actors, four-way per-facing -animation, and text on top. [Chapter 20](20-tutorial-sidescroller.md) is the -physics one — gravity, a jump, a coin — and the two are deliberately -complementary. If you want to know how `ey` accumulates, read that one. +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](https://mapeditor.org), +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. -Everything the chapter shows is quoted out of the program with `excerpt` blocks -rather than retyped, so a chapter that no longer matches the game fails the -build. The program itself is the specification. +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. -Six things in it are workarounds for gaps in the library rather than choices, -and each one is called out where you would hit it. They are collected at the end -under [What this costs](#what-this-costs). +## Before you start -## Build it and run it +You need a C compiler, CMake 3.10 or newer, and libakgl built. [Chapter 3](03-getting-started.md) +covers getting the library on your machine. -```sh norun -cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -cmake --build build --parallel -./build/examples/jrpg/jrpg +**[Chapter 20](20-tutorial-sidescroller.md) 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 + +1. **Set up the project** — the directory, the build file, and where the font comes from. +2. **Start up and load the town** — the same order as chapter 20, plus a font. +3. **Describe four-way art** — twenty-four sprites from a naming convention. +4. **Bind eight states per character** — the character JSON for four-way movement. +5. **Draw the town** — a Tiled map with zero gravity and a solid decoration layer. +6. **Keep the NPCs visible** — one line, and the reason it is needed. +7. **Wall the town in** — solid tiles for buildings, static proxies for the map's edge. +8. **Walk in four directions** — one call binds the whole arrow-key set. +9. **Follow the player** — a child actor, and the one thing to know about drawing it. +10. **Put text on screen** — a font, a panel and a line of dialogue. +11. **Talk to somebody** — a proximity test and a key binding of your own. +12. **Freeze the world** — `AKGL_ERR_LOGICINTERRUPT`, in a game. +13. **Follow with the camera, and tear down**. + +--- + +## 1. Set up the project + +```text +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 ``` -Arrow keys walk. Space talks to whoever is standing next to you, and dismisses -the box again. There is nothing else; it is a tutorial, not a game. +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: -Two options exist for the sake of the smoke run. `--frames N` stops after N -frames, and `--demo` drives the arrow keys from a script and steps the physics -clock by a fixed 1/60 s so that a run is deterministic and takes no wall-clock -time at all: - -```sh norun -SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \ - ./build/examples/jrpg/jrpg --frames 320 --demo +```cmake +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" +) ``` -Both blocks are `norun`: the first rebuilds the tree the documentation suite is -running inside, and the second is registered as a CTest case already, so running -it from here would run it twice. +Give them defaults in the header so the file still compiles on its own: -## What is in the directory - -| File | What it holds | -|---|---| -| `jrpg.h` | Every declaration, the compile-time asset paths, and the constants | -| `jrpg.c` | `main`, startup order, the frame, teardown, the demo script | -| `world.c` | Asset loading, the actors the map spawned, walls, the follower | -| `textbox.c` | The dialogue panel | -| `CMakeLists.txt` | The target, the baked-in asset paths, and the smoke test | - -The art and the data are in `docs/tutorials/assets/jrpg/`: `tiles.png`, -three 32×32 character sheets, `town.tmj`, three character definitions and -twenty-four sprite definitions. They are CC0 — `docs/tutorials/assets/PROVENANCE.md` -has the row-per-file provenance — so a reader who copies this into their own game -inherits no obligation. - -## Sprites, then characters, then the map - -The load order is not a preference. Each step resolves the previous one's output -by name through a registry, so getting it wrong fails on every single mapping: - -- `akgl_character_load_json` looks each sprite up in `AKGL_REGISTRY_SPRITE` and - refuses one it cannot find. See [Chapter 11](11-characters.md). -- `akgl_tilemap_load` resolves each actor object's `character` property through - `AKGL_REGISTRY_CHARACTER` while it spawns the actor. See - [Chapter 13](13-tilemaps.md). - -```c excerpt=examples/jrpg/world.c - 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])); - } - } - } - for ( c = 0; c < CAST_COUNT; c++ ) { - PASS(errctx, character_load(CAST[c])); - } +```c excerpt=examples/jrpg/jrpg.h +#ifndef JRPG_ASSET_DIR +#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg" ``` -Twenty-four sprites, and not one of them is named in the source. The file names -are a product of three tables, because a product of three tables is what the -naming convention *is* — one sprite per character, per motion, per facing: +The screen is smaller than the sidescroller's and is not scaled up, because the town's art is +drawn at map scale: + +```c excerpt=examples/jrpg/jrpg.h +#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: + +```c excerpt=examples/jrpg/jrpg.c + 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)); +``` + +```c excerpt=examples/jrpg/jrpg.c + 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: + +```c excerpt=examples/jrpg/jrpg.c + 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: + +```c excerpt=examples/jrpg/jrpg.c + 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: ```c excerpt=examples/jrpg/world.c static char *CAST[] = { @@ -99,113 +159,126 @@ static char *MOTIONS[] = { "idle", "walk" }; static char *FACINGS[] = { "up", "down", "left", "right" }; ``` -A missing combination then arrives as a load failure naming the file it could -not open, at startup. Write the twenty-four names out by hand and a missing one -arrives as art that never appears, in the middle of a frame, silently — because -an actor with no sprite for its state is skipped rather than reported. That is -the same trade the whole error protocol is about: fail early and loudly rather -than surprisingly. - -### Full four-way idle and walk is eight mappings per character - -The state-to-sprite lookup is an **exact match on the whole state word**, with no -subset fallback — `akgl_character_sprite_get` builds a decimal key out of the -`int32_t` and asks the property set for it. [Chapter 11](11-characters.md) -covers the consequences; the one that binds here is that four facings times -{standing, walking} is eight distinct combinations, and every one of them needs -its own mapping or the actor vanishes when it reaches that state: - -| State | Value | Sprite | -|---|---|---| -| `ALIVE|FACE_DOWN` | 17 | `jrpg_player_idle_down` | -| `ALIVE|FACE_DOWN|MOVING_DOWN` | 1041 | `jrpg_player_walk_down` | -| `ALIVE|FACE_LEFT` | 18 | `jrpg_player_idle_left` | -| `ALIVE|FACE_LEFT|MOVING_LEFT` | 146 | `jrpg_player_walk_left` | -| `ALIVE|FACE_RIGHT` | 20 | `jrpg_player_idle_right` | -| `ALIVE|FACE_RIGHT|MOVING_RIGHT` | 276 | `jrpg_player_walk_right` | -| `ALIVE|FACE_UP` | 24 | `jrpg_player_idle_up` | -| `ALIVE|FACE_UP|MOVING_UP` | 536 | `jrpg_player_walk_up` | - -There is no diagonal row and there does not need to be one. The default arrow -bindings clear *every* facing and movement bit before setting their own -(`akgl_actor_cmhf_left_on` and its five siblings all do), so holding two -directions moves in whichever was pressed last rather than diagonally. That is -documented in [Chapter 16](16-input.md), and it is why an eight-way character -sheet would be wasted on the default bindings. - -## The map spawns the actors - -`town.tmj` is 30×20 cells of 16×16 tiles with three layers: `ground`, -`decoration`, and an object group holding three objects of type `actor`. - -| Object `name` | `character` (string) | `state` (int) | At | -|---|---|---|---| -| `player` | `jrpg_player` | 17 | (224, 256) | -| `shopkeeper` | `jrpg_shopkeeper` | 17 | (80, 128) | -| `elder` | `jrpg_elder` | 17 | (272, 112) | - -Loading the map creates all three, publishes them in `AKGL_REGISTRY_ACTOR` under -the object's `name`, binds each to its character, and sets its position, -visibility and layer. No code in this game creates them. The format's rules — -every object needs a `type` string, `state` is an **int** here and not the -string array that character JSON accepts, and the tileset must be **embedded** — -are [Chapter 13](13-tilemaps.md)'s subject and are not restated. - -The map also declares `physics.model`, and that is worth a paragraph, because -what the library does with it is half of what you would expect: +Then one nested loop loads them all: ```c excerpt=examples/jrpg/world.c - if ( akgl_gamemap->use_own_physics ) { - akgl_physics = &akgl_gamemap->physics; - } -``` - -`akgl_tilemap_load` reads the property, builds a whole `akgl_PhysicsBackend` on -the map from it, and sets `use_own_physics`. It does not switch to it. -`akgl_game_update` simulates through the global `akgl_physics`, whatever that -happens to point at, and nothing in the library ever consults `use_own_physics`. -Honouring the map is the caller's job, and the two lines above are it. - -### Every actor the map spawned is invisible on frame one - -This is the first workaround, and it is the one most likely to cost you an -afternoon, because the symptom is that nothing happens. - -`akgl_actor_initialize` sets `movement_controls_face` to true and installs -`akgl_actor_automatic_face` as the `facefunc`. That function clears every facing -bit and then sets the one matching a *movement* bit. An NPC has no movement -bits. So on the first update its state falls from `ALIVE|FACE_DOWN` (17) to -`ALIVE` (16) — a combination no character JSON maps a sprite to — and -`akgl_actor_render` skips it rather than reporting anything. The player goes the -same way the moment they stop walking. - -`actor.h` says as much in a `@note` on `akgl_actor_automatic_face`, and the -implementation carries a `TODO : This doesn't really work properly` above the -line that does it. The fix is one field: - -```c excerpt=examples/jrpg/world.c - for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { - if ( akgl_heap_actors[i].refcount == 0 ) { - continue; + 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])); + } } - akgl_heap_actors[i].movement_controls_face = false; } ``` -Turning the automatic facing *off* is not a compromise here. The arrow-key -handlers already set the facing bit alongside the movement bit on the way down, -and `akgl_actor_cmhf_*_off` clears only the movement bit on the way up — so the -facing an actor is left with is exactly the one it was walking in. The automatic -`facefunc` is for an actor whose facing is not otherwise decided. +The helper builds the path from the three components: -## Walls +```c excerpt=examples/jrpg/world.c + aksl_snprintf( + &written, + path, + sizeof(path), + "%s/sprite_jrpg_%s_%s_%s.json", + JRPG_ASSET_DIR, + cast, + motion, + facing + )); +``` -The library resolves them ([Chapter 15](15-collision.md)); this game says which -things are solid and gets out of the way. +A missing combination now shows up as a load failure naming the exact file, rather than as +art that silently never appears. -Two rules, and they arrive by two different mechanisms because they are two -different kinds of geometry. Every non-empty cell of the `decoration` layer — a -building, a tree, a lamp post — is solid, and the layer says so itself: +Each sprite file is the format chapter 20 covers. A walk cycle: + +```json kind=sprite setup=jrpg +{ + "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: + +```json kind=character setup=jrpg +{ + "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: + +```c excerpt=examples/jrpg/world.c + 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: ```json excerpt=docs/tutorials/assets/jrpg/town.tmj "properties": [ @@ -217,49 +290,114 @@ building, a tree, a lamp post — is solid, and the layer says so itself: ] ``` -That replaces a `#define JRPG_LAYER_SOLID 1`, and the replacement matters more -than it looks. **`akgl_TilemapLayer` has no `name` member** — the loader reads a -layer's `id`, `type`, `opacity`, `visible` and offset and drops the name Tiled -wrote, so a game could not say "the layer called decoration" and had to agree -with the map on an *index*. Insert a layer in Tiled and the game starts colliding -with the scenery. Now the map carries the answer and the game names no layer at -all. +### Zero gravity -The second rule is the edge of the world, which is not on any layer. That is four -static proxies: +On the map itself, three properties: -```c excerpt=examples/jrpg/world.c - 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; +| 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: + +```json excerpt=docs/tutorials/assets/jrpg/town.tmj + "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 ``` -Four proxies rather than a hundred solid tiles, because a long thin box costs one -pool slot whatever its length — this is what proxies are for, and tiles are the -other mechanism precisely because there are a hundred thousand of them. +`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. -Three details in five lines: +### Loading it -- **`layermask` is set by hand.** `akgl_collision_shape_box` defaults a shape to - `AKGL_COLLISION_LAYER_ACTOR`, which is right for an actor and wrong for - scenery. An actor responds to `AKGL_COLLISION_LAYER_STATIC` and nothing else, - so a wall left on the default layer is a wall everything walks through. -- **`collidemask` is cleared.** The wall is not asking to be pushed out of - anything. -- **The acquire and the initialize are adjacent, with nothing between them.** - `akgl_heap_next_collision_proxy` finds 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](05-the-heap.md) covers why the pools are built - that way. +Sprites, then characters, then the map: -The walls cover the map's outermost ring of cells *and* extend a tile beyond it. -The ring is what the art expects; the overhang is what stops an actor arriving -fast enough from being resolved out the far side of a wall exactly one tile -thick. +```c excerpt=examples/jrpg/world.c + aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR)); + PASS(errctx, akgl_tilemap_load(path, akgl_gamemap)); +``` -Then the player gets a footprint: +Then switch to the physics the map declared: + +```c excerpt=examples/jrpg/world.c + if ( akgl_gamemap->use_own_physics ) { + akgl_physics = &akgl_gamemap->physics; + } +``` + +--- + +## 6. Keep the NPCs visible + +Add this loop right after the map loads: + +```c excerpt=examples/jrpg/world.c + 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: + +```c excerpt=examples/jrpg/world.c + 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: ```c excerpt=examples/jrpg/world.c body = (SDL_FRect){ FEET_X, FEET_TOP, FEET_W, FEET_H }; @@ -267,48 +405,315 @@ Then the player gets a footprint: player->shape_override = true; ``` -A 20×10 box at the bottom of the 32×32 frame rather than the whole frame, because -a character stands on their feet and every doorway in the town is one tile wide. -Testing the whole frame would make every corridor two tiles wide. - -**Only the player gets one.** 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 the player's position every step, so a shape on it would fight -the snap rather than stop anything. That is what the default masks are for — an -actor with a shape collides with map geometry and with no other actor until you -add a bit. - -### What went away - -This chapter used to carry a `movementlogicfunc` that *predicted* the step: - -```text - if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) { obj->tx = 0.0f; ... } +```c excerpt=examples/jrpg/world.c +#define FEET_X 6.0f +#define FEET_W 20.0f +#define FEET_TOP 20.0f +#define FEET_H 10.0f ``` -It worked, and it came with a qualifier: velocity is recomputed as `e + t` every -step, and it was only exact because this map has zero gravity and zero drag, so -`e` stays zero and `v` *is* `t`. A map with gravity would have had to fold `ey` -into the prediction as well — which is the game re-implementing the library's -integrator, in a file that would not be recompiled when the integrator changed. +**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. -Resolution runs after the move now, on a position that already happened, so -there is nothing to predict and the qualifier goes away with it. Both this game -and the sidescroller land in exactly the same pixel they did before: holding left -into a building stops the player at x=122, and holding up into the map's edge -stops them at y=-4, with either implementation. +### The edge of the world -## Freezing the world with `AKGL_ERR_LOGICINTERRUPT` +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: -`AKGL_ERR_LOGICINTERRUPT` is the one status in libakgl that is a control-flow -signal rather than a failure. Raised from a `movementlogicfunc` it means *skip -the rest of this tick for this actor*, and `akgl_physics_simulate` swallows it in -a `HANDLE` block: gravity, drag, the velocity recompute and the move are all -skipped, and the frame carries on. Raised from `gravity` or `move` instead it -aborts the whole step for every actor, which is not the same thing at all — see -[Chapter 14](14-physics.md). +```c excerpt=examples/jrpg/world.c + 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])); +``` -A conversation is exactly the case it was made for: +Three details in those lines: + +- **Set `layermask` by hand.** `akgl_collision_shape_box` defaults a shape to + `AKGL_COLLISION_LAYER_ACTOR`, which is right for an actor and wrong for scenery. An actor + responds to `AKGL_COLLISION_LAYER_STATIC` and 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_proxy` finds 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](05-the-heap.md) explains why the pools work that way. + +The walls cover the map's outer ring of cells *and* extend a tile beyond it: + +```c excerpt=examples/jrpg/world.c +#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: + +```c excerpt=examples/jrpg/jrpg.c + 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: + +```c excerpt=examples/jrpg/world.c + 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: + +```c excerpt=examples/jrpg/world.c + 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: + +```c excerpt=examples/jrpg/world.c + 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: + +```c excerpt=examples/jrpg/world.c + 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: + +```c excerpt=examples/jrpg/textbox.c +static char textbox_text[JRPG_TEXTBOX_MAX_TEXT]; +static bool textbox_visible = false; +``` + +Opening the box copies the line in: + +```c excerpt=examples/jrpg/textbox.c + 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: + +```c excerpt=examples/jrpg/textbox.c + font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL); +``` + +```c excerpt=examples/jrpg/textbox.c + 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)); +``` + +```c excerpt=examples/jrpg/textbox.c + 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: + +```c excerpt=examples/jrpg/textbox.c + 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: + +```c excerpt=examples/jrpg/jrpg.c + 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: + +```c excerpt=examples/jrpg/world.c +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. + +```c excerpt=examples/jrpg/world.c + if ( jrpg_textbox_showing() ) { + jrpg_textbox_close(); + SUCCEED_RETURN(errctx); + } +``` + +```c excerpt=examples/jrpg/world.c + 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); + } +``` + +```c excerpt=examples/jrpg/world.c +#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: + +```c excerpt=examples/jrpg/jrpg.c + 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_off` must be non-`NULL`.** `akgl_controller_handle_event` calls it without + checking. `jrpg_cmhf_ignore` is a handler that does nothing, which is what you want on the + release of a key whose press is the whole action. +- **Set `button` to `SDL_GAMEPAD_BUTTON_INVALID`.** The keyboard and gamepad arms of the + match are evaluated together, and 0 is a real button. Recorded in `TODO.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`: ```c excerpt=examples/jrpg/world.c if ( jrpg_textbox_showing() ) { @@ -324,266 +729,76 @@ A conversation is exactly the case it was made for: } ``` -`FAIL_RETURN` and not `FAIL_BREAK`, because this is not inside an `ATTEMPT` -block — the `_BREAK` variants belong inside one and the `_RETURN` variants -outside, and a `_RETURN` inside an `ATTEMPT` returns straight past `CLEANUP`. -[Chapter 4](04-errors.md) has the whole protocol. +`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. -The two lines above the raise are not decoration. Thrust has already been -integrated for this step, so leaving it would let it pile up for as long as the -box is open and lurch the actor forward on the first step after it closes. -Clearing the movement bits stops the walk cycle marching on the spot, at the -price of one honest wart: a direction held down across the dismissal has to be -pressed again, because nothing will re-set the bit until the next key-down. A -game with a real conversation state would push a different control map instead -of leaving the walk bindings live. +Two things happen before the raise, and both matter: -## The party member, and a defect in the parent/child mechanism +- **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. -`akgl_actor_add_child` attaches one actor to another. A child is not simulated: -`akgl_physics_simulate` snaps it to the parent's position plus its own velocity -fields, used as a fixed offset, and `continue`s. The parent takes a reference, -and releasing the parent releases the child with it. For a carried lantern, a -turret on a tank, or a party member walking a step behind you, that is exactly -right. +**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: ```c excerpt=examples/jrpg/world.c - 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)); + PASS(errctx, akgl_actor_logic_movement(obj, dt)); ``` -Note what the follower costs: one pool slot, and a `basechar` pointer at a -character that is already registered and already dressed in eight sprites. That -is the whole point of splitting the instance from the template -([Chapter 11](11-characters.md)) — the elder and the companion are two actors -sharing one character. +--- -The offset is set **after** `addchild`, because `addchild` is what makes the -velocity fields mean an offset: +## 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: ```c excerpt=examples/jrpg/world.c - follower->vx = FOLLOWER_OFFSET_X; - follower->vy = FOLLOWER_OFFSET_Y; -``` + akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f); + akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f); -### The parent's position is counted twice at draw time - -This is a real defect, it is not recorded in `TODO.md`, and it makes the -parent/child mechanism unusable as shipped for any parent that is not sitting at -the world origin. - -`akgl_physics_simulate` writes a child's position as an **absolute world -coordinate**: - -```c excerpt=src/physics.c - if ( actor->parent != NULL ) { - // Children don't move independently of their parents, they just have an offset - actor->x = actor->parent->x + actor->vx; - actor->y = actor->parent->y + actor->vy; - actor->z = actor->parent->z + actor->vz; - continue; - } -``` - -`akgl_actor_render` then reads the same field as an **offset** and adds the -parent's position to it again: - -```c excerpt=src/actor.c - if ( obj->parent != NULL ) { - dest.x = (obj->parent->x + obj->x - akgl_camera->x); - dest.y = (obj->parent->y + obj->y - akgl_camera->y); - } else { - dest.x = (obj->x - akgl_camera->x); - dest.y = (obj->y - akgl_camera->y); + 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; } ``` -`actor.h` documents both readings without noticing that they contradict each -other: the comment on `akgl_Actor::x` says *"For a child, an offset from the -parent"*, and the one on `akgl_actor_render` says *"A child actor is drawn at -its parent's position plus its own, which is what makes an offset mean an -offset"* — while the field it is describing has held an absolute position since -the physics step ran. `actor_visible`, three lines further up the same function, -tests the camera against the raw `obj->x`, treating it as absolute. Two readings, -one field, one function. +The `+ 16.0f` centres on the middle of a 32-pixel sprite rather than its top-left corner. -Measured rather than reasoned about. At frame 300 of the scripted demo the -player is at (280, 146), the follower's own `x` and `y` read (266, 156) — the -player's position plus the (−14, +10) offset, so absolute — and the camera is at -(136, 42). The guarded draw puts the sprite at (130, 114), on a 320×240 screen. -The unguarded one computes `280 + 266 − 136` and `146 + 156 − 42` and puts it at -(410, 260), off the screen entirely and off the 480×320 map. Rendering the same -frame with and without the guard produces different pixels. - -It is invisible only while the parent sits at the origin, which is where a first -test tends to put it. - -**The guard**, until the library picks one of the two readings: give the child a -`renderfunc` that detaches the parent for the duration of the draw, so -`akgl_actor_render` takes the branch that does not add it. - -```c excerpt=examples/jrpg/world.c - parent = obj->parent; - obj->parent = NULL; - ATTEMPT { - CATCH(errctx, akgl_actor_render(obj)); - } CLEANUP { - obj->parent = parent; - } PROCESS(errctx) { - } FINISH(errctx, true); -``` - -`CLEANUP` restores it on every path, including the failing one. An actor left -holding a `NULL` parent would stop being snapped and start being simulated as a -free agent on the very next step, which is a stranger bug than the one being -worked around. - -The alternative was to drive the follower by hand — no `addchild`, a position -written from the game's own logic every frame — and it is a perfectly reasonable -choice. This game does not take it, because the mechanism is worth showing and -because a fifteen-line hook is cheaper than reimplementing the parent/child -lifecycle. The real fix is one line in either `src/physics.c` or `src/actor.c` -and a decision about which reading is canonical. - -## The text box - -Text in libakgl is immediate mode. Every `akgl_text_rendertextat` call -rasterizes the string through SDL_ttf, uploads it to a texture, blits it, and -destroys the texture and the surface again — there is no glyph atlas and no text -object to keep. [Chapter 17](17-text-and-fonts.md) says plainly that this is -wrong for a page of static prose redrawn sixty times a second, and it is exactly -right for a line of dialogue that is on screen only while somebody is talking. - -```c excerpt=examples/jrpg/textbox.c - 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)); -``` - -The panel is placed off `akgl_camera->w` and `akgl_camera->h` rather than off a -second copy of the window size, because `akgl_render_2d_init` copies the -`game.screenwidth` and `game.screenheight` properties onto the camera on its way -past. Two numbers, one place. Text coordinates are screen coordinates and do not -go through the camera at all, so the box stays put while the world scrolls under -it. - -Three smaller things this box has to know: - -- **The empty string is refused, not drawn as nothing.** SDL_ttf reports "Text - has zero width" and libakgl passes it on as `AKERR_NULLPOINTER`, while - `akgl_text_measure` accepts it happily. The two disagree; `TODO.md`, "Known and - still open", carries it. Anything that might draw an empty line checks first. -- **Fonts are not reference counted.** `akgl_text_unloadfont` invalidates every - `TTF_Font *` anybody fetched earlier. Fetching it from `AKGL_REGISTRY_FONT` per - frame is the cheap way to stay honest about that. -- **The font is `tests/assets/akgl_test_mono.ttf`**, which ships beside its - licence file. The two RPG-Maker-named images elsewhere in this tree do not, and - that is recorded in `TODO.md` rather than fixed here. - -## Startup, the frame, and teardown - -The startup order is [Chapter 7](07-the-game-and-the-frame.md)'s subject. Three -things in it are easy to get wrong, and this block is where two of them land: +### The frame ```c excerpt=examples/jrpg/jrpg.c - PASS(errctx, akgl_game_init()); - akgl_game.lowfpsfunc = &lowfps_quiet; + PASS(errctx, jrpg_camera_follow()); - 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)); -``` - -- **The properties go between `akgl_game_init` and `akgl_render_2d_init`**, - because the second one reads them. Set them earlier and the properties registry - does not exist yet; set them later and you get a zero-sized window. -- **`akgl_game.name`, `.version` and `.uri` have to be filled in before - `akgl_game_init`**, which refuses to run without all three. They are written - with `aksl_strncpy`, never `strncpy`, because they are fixed-width fields. -- **`akgl_game.lowfpsfunc` is worth replacing.** `akgl_game.fps` is a - completed-second average, so it reads 0 for the first second of every process - — which is under the threshold — and the default hook logs a line on *every* - frame until the first second is up. The hook exists to be replaced. - -Then the fourth thing, which is the third workaround and the one that segfaults: - -```c excerpt=examples/jrpg/jrpg.c - PASS(errctx, akgl_physics_init_arcade(akgl_physics)); -``` - -`akgl_game_init` points `akgl_physics` at `akgl_default_physics` and stops. -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 `SIGSEGV` on frame one rather than an error -context. - -Be precise about what saves this particular program: `town.tmj` declares its own -`physics.model`, and the two-line switch above hands `akgl_physics` a backend -that `akgl_tilemap_load` *did* initialize. Remove the line above alone and this -game still runs. Remove it **and** that switch and frame one is a segmentation -fault — measured, by removing both. So the line is not belt and braces for a map -that declares no physics, which is most of them, and it is the only thing -covering the window between `akgl_game_init` and the map being loaded. - -Nothing in the library calls the factory for you, and -`physics.h`'s claim that `akgl_game_init` "passes whatever the `physics.engine` -property holds" to the factory is false: no code anywhere reads that property. A top-down game wants the arcade backend with -zero gravity, which is what the property defaults already give you. - -### One frame - -```c excerpt=examples/jrpg/jrpg.c PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); - PASS(errctx, akgl_game_update(&opflags)); - PASS(errctx, jrpg_textbox_draw()); ``` -`akgl_game_update` is update-every-actor, step the physics, draw the world. It -does not clear the target and it does not present, 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. That is the entire trick to a -HUD. - -A failing frame is treated as terminal, and that is deliberate: -**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 — it would just be running on top of a frame -that never finished. Bailing out is the honest response. - -The loop is the last thing in its `ATTEMPT` block, and that is load-bearing: +This game passes an iterator to `akgl_game_update` rather than `NULL`: ```c excerpt=examples/jrpg/jrpg.c - while ( running ) { - started = SDL_GetTicks(); - CATCH(errctx, frame(frameno)); - frameno += 1; - if ( (frame_limit > 0) && (frameno >= frame_limit) ) { - running = false; - } + akgl_Iterator opflags = { + .flags = AKGL_ITERATOR_OP_UPDATE, + .layerid = 0 + }; ``` -`CATCH` reports failure by `break`ing, and a `break` inside a loop binds to the -loop rather than to the block. With nothing after the loop, a failing frame -leaves it and falls straight into `CLEANUP`, `PROCESS` and `FINISH`, which is -what is wanted. Put a statement after the loop and it would run after a failure -as well. `akgl_tilemap_load_layer_objects` carries the same note over the same -shape; `AGENTS.md` states the rule. +`AKGL_ITERATOR_OP_UPDATE` asks for the update pass. [Chapter 7](07-the-game-and-the-frame.md) +covers the other flags. -### Teardown is yours - -There is no `akgl_game_shutdown`. Two of these three lines are load-bearing and -the third thing here is a deliberate omission: +### Teardown ```c excerpt=examples/jrpg/jrpg.c IGNORE(akgl_text_unloadallfonts()); @@ -594,114 +809,56 @@ the third thing here is a deliberate omission: SDL_Quit(); ``` -- **`akgl_text_unloadallfonts` must run before `SDL_Quit`.** Fonts live 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. - [Chapter 17](17-text-and-fonts.md) covers the ordering. -- **`akgl_tilemap_release` is not called**, and that is the fourth workaround. - 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, and `tilemap.h` carries the warning too. `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. Releasing the characters would be actively risky: - `akgl_heap_release_character` enumerates the state-sprite map through - `akgl_character_state_sprites_iterate`, an SDL callback that returns `void`, - ends in `FINISH_NORETURN`, and therefore **exits the process** on any error. - [Chapter 5](05-the-heap.md) and [Chapter 4](04-errors.md) both cover it. +`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. -## The headless smoke run +`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. -```c excerpt=examples/jrpg/CMakeLists.txt -add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo) -set_tests_properties(example_jrpg PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" -) +The pools are static storage and the process is exiting, so there is nothing else to free. + +--- + +## Build it and run it + +```sh norun +cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build --parallel +./build/examples/jrpg/jrpg ``` -A smoke test that only proves `main` returns is not worth registering, so this -one walks. `--demo` synthesizes real `SDL_Event`s and pushes them through -`akgl_controller_handle_event`, so the run goes through the control-map scan and -the binding exactly as a keypress does: +| Control | Keyboard | Gamepad | +|---|---|---| +| Walk | ← → ↑ ↓ | D-pad | +| Talk / dismiss | Space | — | +| Quit | Close the window | — | -```c excerpt=examples/jrpg/jrpg.c -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 } -}; +Stand next to the elder or the shopkeeper and press Space. + +To run it without a display: + +```sh norun +SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \ + ./build/examples/jrpg/jrpg --frames 320 --demo ``` -320 frames of that would be five and a third seconds of test suite if the loop -ran at 60 Hz, and it does not run at 60 Hz — headless with a software renderer it -runs as fast as it can, which would make the walk cover about a fifth of the -ground. Both problems have the same answer, and it is the one -`tests/physics_sim.c` already uses: **drive the clock, do not sleep on it.** - -```c excerpt=examples/jrpg/jrpg.c - akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS; -``` - -`akgl_physics_simulate` measures `dt` from `gravity_time`, which is a public -field. Setting it one fixed step into the past before each update makes every -frame worth exactly 1/60 s of simulated time no matter how fast the loop -actually runs. The whole 320-frame run finishes in about a third of a second and -lands the player in the same place every time. [Chapter 14](14-physics.md) -covers `dt`, `max_timestep` and why the first step used to be however long the -level took to load. - -The run prints where the player ended up, so the result can be read rather than -merely passed: +`--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: ```text -jrpg: 320 frames, player at (280, 146) +jrpg: 320 frames, player at (280, 130) ``` -## What this costs - -Four things in this program exist because the library does not do them. None of -them is hidden, and none of them is a criticism of a design — they are the -current state of a library that is honest about being unfinished. Two more were -here in 0.7.0 and are gone: the hard-coded collision layer index, and the -tile-based blocking in the player's `movementlogicfunc`. Collision closed both. - -| # | Gap | What this game does | Recorded in | -|---|---|---|---| -| 1 | `movement_controls_face` erases the facing bit of any actor that is not moving, so every map-spawned actor stops being drawn on frame one | Clears the field on every live actor after loading the map | `actor.h` `@note`; a `TODO` in `src/actor.c`. Not in `TODO.md` | -| 2 | `akgl_default_physics` is zeroed BSS and `akgl_game_init` never calls the factory; `akgl_game_update` calls `simulate` through a `NULL` pointer | Calls `akgl_physics_init_arcade` explicitly, and switches to the map's backend when it declares one | The false `physics.engine` claim in `physics.h` is in the plan's out-of-scope list | -| 3 | A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice | A `renderfunc` that detaches the parent for the duration of the draw | **Not in `TODO.md`.** `actor.h` documents both readings | -| 4 | `akgl_tilemap_release` double-frees tileset textures and never frees image-layer ones | Does not call it; lets `SDL_Quit` reclaim them | `TODO.md`, "Known and still open" item 2 | - -Two more that are not workarounds but will bite anyone extending this: `akgl_Actor::layer` -is a `uint32_t` with no bound, while `akgl_render_2d_draw_world` stops at -`AKGL_TILEMAP_MAX_LAYERS` (16) — an actor on layer 20 is simulated and never -drawn. And the SDL enumeration callbacks (`akgl_registry_iterate_actor`, -`akgl_character_state_sprites_iterate`) return `void` and end in -`FINISH_NORETURN`, so an error inside one **exits the process** rather than -failing a frame. [Chapter 4](04-errors.md) covers installing your own -`akerr_handler_unhandled_error` if that is not what you want. - ## Where to look next -- [Chapter 20](20-tutorial-sidescroller.md) — the same shape of program with - gravity, a jump and platforms, which is where the physics model earns its keep. -- [Chapter 13](13-tilemaps.md) — the map format, the three libakgl extensions to - Tiled, and the four constraints the loader really enforces. -- [Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) — the template - and the instance, and why the state word is the whole key. -- [Chapter 16](16-input.md) — control maps, the default bindings, and the - keystroke ring this game does not use. -- [Chapter 22](22-appendix-limits.md) — every compile-time ceiling in one place, - including the 64-actor pool this town uses four of. +- [Chapter 20](20-tutorial-sidescroller.md) — the same shape of program with gravity, a jump + and platforms. +- [Chapter 15](15-collision.md) — the masks, the four queries and the static-proxy path this + chapter used for the town walls. +- [Chapter 17](17-text-and-fonts.md) — measuring text, and the teardown ordering trap. +- [Chapter 12](12-actors.md) — parents, children, and the seven behaviour hooks. +- [Chapter 7](07-the-game-and-the-frame.md) — the iterator flags, and what `akgl_game_update` + does in what order. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 89830d6..f11e6b4 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -38,6 +38,7 @@ rather than a free pass. | `c wrap=NAME` | The same, with `tests/docs_preludes/NAME.pre` before it and `NAME.post` after | | `c run=NAME` | Wrapped in `NAME`, **linked against `akgl` and run headless**. Must exit 0 and must not report an unhandled or ignored error | | `c excerpt=PATH` | Must still appear in `PATH`, ignoring comments and whitespace. **Not compiled** | +| `json excerpt=PATH` | The same check against an asset file, for a fragment too large to show whole | | `c screenshot=NAME` | Compiled as an `akglframe` body, and the source of `docs/images/NAME.png`. Optionally `size=WxH` | | `json kind=KIND` | Written to a file and loaded through libakgl's own loader. `KIND` is `sprite`, `character`, `tilemap` or `properties` | | `output` | The **exact stdout** of the runnable block above it, compared byte for byte | @@ -156,8 +157,16 @@ the `KINDS` table in `tools/docs_checkjson.c` loads them: | Setup | What it stages | What loads it | |---|---|---| +| `setup=spritesheet` | `./spritesheet.png`, a 12x8 grid of 48x48 frames | the `sprite` row's `prepare` | | `setup=character` | `./sprites/*.json` plus a sheet, named `hero standing left` and `hero walking left` | the `character` row's `prepare` | | `setup=tilemap` | `./sprites/*.json`, `./characters/testcharacter.json`, and `assets/tileset.png` | the `tilemap` row's `prepare` | +| `setup=sidescroller` | every PNG and sprite file from `docs/tutorials/assets/sidescroller`, in `./` and `./sprites` | both rows' `prepare` | +| `setup=jrpg` | the same, from `docs/tutorials/assets/jrpg` | both rows' `prepare` | + +The last two are how the tutorials show a reader a *real* asset file rather than a +generic one. A beginner following chapter 20 or 21 types what is on the page, so +what is on the page has to be a file that loads — the setup stages the tutorial's +own art so the block on the page is the block in the repository. Those names are a contract between the setup script and the chapter using it. Change one and change the other, in the same commit. `assets/tileset.png` is diff --git a/docs/README.md b/docs/README.md index b0e9d6a..c47e41f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,13 +32,16 @@ per-function reference, build the Doxygen output with `doxygen Doxyfile`. | **[17. Text and fonts](17-text-and-fonts.md)** | Loading, drawing, measuring, and the teardown order that matters | | **[18. Audio](18-audio.md)** | The three-voice synthesizer, and the separate background-music path | | **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings | -| **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Gravity, a jump, collision, coins and hazards | -| **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | A town map, NPCs spawned from map objects, four-way animation, a text box, a follower | +| **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Thirteen steps from an empty directory to a game with gravity, a jump, coins and hazards. **Start here** | +| **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | Thirteen more, for four-way movement, NPCs, a text box, a follower, and freezing the world | | **[22. Appendix](22-appendix-limits.md)** | Every limit, every status, every configuration property | -Both tutorials are complete programs under [`examples/`](../examples). They build with the -library and run headless in CI, and the chapters quote them rather than restating them — so -a tutorial cannot drift from a program that compiles. +**If you are new, read chapter 20 first and read it in order.** It builds a working game from +an empty directory and teaches the library as it needs each piece; chapter 21 assumes it. +Both are complete programs under [`examples/`](../examples) — they build with the library and +run headless in CI, and the chapters quote them rather than restating them, so a tutorial +cannot drift from a program that compiles. The picture at the top of each chapter is a frame +out of the game itself, regenerated by `cmake --build build --target docs_game_figures`. ## Every example here is checked diff --git a/tests/docs_setups/jrpg.sh b/tests/docs_setups/jrpg.sh new file mode 100755 index 0000000..2dce80a --- /dev/null +++ b/tests/docs_setups/jrpg.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# +# Stage the JRPG tutorial's own assets in the sandbox. +# +# Same job as sidescroller.sh, and the same reason: docs/21-tutorial-jrpg.md +# shows the reader real sprite and character files out of +# docs/tutorials/assets/jrpg, and a beginner following the chapter types what is +# on the page -- so what is on the page has to be a file that loads. +# +# Two directories are staged, because the checker looks in two places: +# +# ./ a `json kind=sprite` block's spritesheet path is resolved +# relative to the sprite file, which lives in the sandbox root +# ./sprites tools/docs_checkjson.c loads everything here through +# akgl_sprite_load_json before it touches a `kind=character` +# block, since a character resolves its sprites by name +# +# $1 is the repository root. The caller runs this with the sandbox as the +# working directory. + +set -u + +ROOT="${1:-}" +if [ -z "${ROOT}" ]; then + echo "usage: jrpg.sh " >&2 + exit 2 +fi + +ASSETS="${ROOT}/docs/tutorials/assets/jrpg" + +cp "${ASSETS}"/*.png . || exit 1 + +mkdir -p sprites || exit 1 +cp "${ASSETS}"/*.png sprites/ || exit 1 +cp "${ASSETS}"/sprite_*.json sprites/ || exit 1 diff --git a/tests/docs_setups/sidescroller.sh b/tests/docs_setups/sidescroller.sh new file mode 100755 index 0000000..8f6b25a --- /dev/null +++ b/tests/docs_setups/sidescroller.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +# Stage the sidescroller tutorial's own assets in the sandbox. +# +# docs/20-tutorial-sidescroller.md shows the reader a real sprite file and a real +# character file out of docs/tutorials/assets/sidescroller. Those are worth +# running through the loader exactly as printed rather than through a generic +# fixture, because a beginner following the chapter types what is on the page -- +# so what is on the page has to be a file that loads. +# +# Two directories are staged, because the checker looks in two places: +# +# ./ a `json kind=sprite` block's spritesheet path is resolved +# relative to the sprite file, which lives in the sandbox root +# ./sprites tools/docs_checkjson.c loads everything here through +# akgl_sprite_load_json before it touches a `kind=character` +# block, since a character resolves its sprites by name +# +# $1 is the repository root. The caller runs this with the sandbox as the +# working directory. + +set -u + +ROOT="${1:-}" +if [ -z "${ROOT}" ]; then + echo "usage: sidescroller.sh " >&2 + exit 2 +fi + +ASSETS="${ROOT}/docs/tutorials/assets/sidescroller" + +cp "${ASSETS}"/*.png . || exit 1 + +mkdir -p sprites || exit 1 +cp "${ASSETS}"/*.png sprites/ || exit 1 +cp "${ASSETS}"/sprite_*.json sprites/ || exit 1