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