Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was navigation and worked examples. Chapters teach a task and link to the Doxygen output; where a declaration or a constant table has to be in front of the reader it arrives as a `c excerpt=` block, so the text *is* the header and cannot diverge from it. That also preserves the hand-aligned bit-flag tables scripts/reindent.el goes out of its way not to destroy. The manual does not re-document its dependencies. libakerror owns the ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events, Tiled owns the map format, jansson owns json_t. A chapter that restated any of them would be wrong the day upstream changed and nothing here would notice -- the same drift this work exists to fix, arriving from a different direction. So each chapter says what libakgl adds or constrains and links out for the rest. Chapter 4 is the exception and the reason for it: libakerror documents the mechanism, but only libakgl can say which statuses its own functions raise and what they mean here, and that was written down nowhere. It carries three tables -- libakgl's five status codes, the libakerror statuses libakgl actually raises with their meaning in this library, and the exit-status trap where `exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256. Every chapter was written against src/ rather than against the header comments, which is how 27 false claims in those comments came to light. Where a chapter documents a known defect rather than a design decision it says so and points at TODO.md. README.md keeps the development process and hands the reader to docs/. Its task-oriented FAQ is deleted rather than moved, because one source of truth per topic is the whole point and that FAQ's examples did not compile. Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run through the real loaders, one linked-and-executed program with its output compared byte for byte, one generated figure. 11 norun blocks, each justified. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
325
README.md
325
README.md
@@ -1,274 +1,71 @@
|
||||
## How do I initialize a game
|
||||
# libakgl
|
||||
|
||||
Initialize the global game object with info about your game
|
||||
A C library for building 2D games on SDL3. Not an engine: no editor, no scripting layer, no
|
||||
inheritance, and no runtime `malloc`. Behaviour attaches to a struct as function pointers,
|
||||
objects come from fixed pools, and every call reports failure through an error context the
|
||||
caller cannot silently ignore.
|
||||
|
||||
```c
|
||||
strncpy((char *)&game.name, "sdl3-gametest", 256);
|
||||
strncpy((char *)&game.version, "0.0.1", 32);
|
||||
strncpy((char *)&game.uri, "net.aklabs.games.sdl3-gametest", 256);
|
||||
## The manual
|
||||
|
||||
**[`docs/`](docs/README.md) is the manual** — twenty-one chapters covering every subsystem,
|
||||
plus two complete tutorial games. Start at
|
||||
[Chapter 3, Getting started](docs/03-getting-started.md) if you want a window on screen, or
|
||||
[Chapter 2, Design philosophy](docs/02-design-philosophy.md) if you want to know why the
|
||||
library is shaped the way it is.
|
||||
|
||||
The two tutorials build real, running programs that live under [`examples/`](examples):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [A 2D sidescroller](docs/19-tutorial-sidescroller.md) | Gravity, a jump, collision you write yourself, coins and hazards |
|
||||
| [A top-down JRPG](docs/20-tutorial-jrpg.md) | A town map, NPCs spawned from map objects, four-way animation, a text box, a follower |
|
||||
|
||||
For per-function reference, build the Doxygen output with `doxygen Doxyfile`; it is a CI
|
||||
gate, so an undocumented symbol fails the build.
|
||||
|
||||
This README covers the development process only — hooks, mutation testing, benchmarks and
|
||||
memory checking. Everything about *using* the library is in `docs/`.
|
||||
|
||||
> **The task-oriented FAQ that used to live here has moved into `docs/`, corrected.** It was
|
||||
> not merely incomplete: its examples did not compile. Two unbalanced `PASS()` calls, a
|
||||
> `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a
|
||||
> bitmask expression, an `int screenwidth = NULL`, and — in the first snippet a reader ever
|
||||
> saw — the exact `strncpy` call `AGENTS.md` forbids. The prose had drifted with it: its
|
||||
> claim that the engine "ONLY supports TilED TMJ tilemaps with tileset external references"
|
||||
> is backwards, and the loader accepts only *embedded* tilesets. Writing the manual turned
|
||||
> up twenty-seven such claims across the headers. Every example in `docs/` is now compiled,
|
||||
> linked, run or matched against the source tree by `ctest`, so that class of drift fails a
|
||||
> build instead of reaching a reader.
|
||||
|
||||
## Documentation examples
|
||||
|
||||
Every fenced example in `docs/` carries an info string saying what it is, and the
|
||||
`docs_examples` test acts on it: `c` blocks are compiled, `c run=` blocks are linked against
|
||||
the library and executed headless with their output compared byte for byte, `c excerpt=`
|
||||
blocks must still appear verbatim in the file they quote, `json` blocks are loaded through
|
||||
the real `akgl_*_load_json`, and `c screenshot=` blocks generate the figures in
|
||||
`docs/images/`. A block with no info string is a hard error, because the failure mode the
|
||||
harness exists to prevent is passing while checking nothing.
|
||||
|
||||
```sh
|
||||
ctest --test-dir build -R docs_examples --output-on-failure
|
||||
ctest --test-dir build -R docs_screenshots --output-on-failure
|
||||
cmake --build build --target docs_screenshots # regenerate the figures
|
||||
```
|
||||
|
||||
Call the game initialization routines and lock the game state for further initialization
|
||||
While editing one chapter, run it directly rather than the whole suite:
|
||||
|
||||
```c
|
||||
PASS(e, akgl_game_init());
|
||||
PASS(e, akgl_game_state_lock());
|
||||
```
|
||||
|
||||
If you have a registry properties file, load it. If you don't have a properties file, use `akgl_set_property("prop_name", "prop_value")` to populate the required game properties.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_registry_load_properties(YOUR_REGISTR_FILEPATH));
|
||||
```
|
||||
|
||||
Initialize your physics engine and renderer of choice
|
||||
|
||||
```c
|
||||
PASS(e, akgl_render_2d_init(akgl_renderer));
|
||||
PASS(e, akgl_physics_init_arcade(akgl_physics));
|
||||
```
|
||||
|
||||
Unlock the game state
|
||||
|
||||
```c
|
||||
PASS(e, akgl_game_state_unlock());
|
||||
```
|
||||
|
||||
## What is in a properties file (or, What properties must I set if I don't have one?)
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"game.screenwidth": "640",
|
||||
"game.screenheight": "480",
|
||||
"physics.gravity.y": "1024.0",
|
||||
"physics.drag.y": "1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Physics properties (gravity and drag along X, Y and Z) are optional and default to `0`.
|
||||
|
||||
## How do I update and render the game world in my main loop
|
||||
|
||||
In your game loop (or in your `SDL_AppIterate` method), lock the game state, call the game update function, and then unlock the game state
|
||||
|
||||
```c
|
||||
PASS(e, akgl_game_state_lock());
|
||||
PASS(e, akgl_renderer->frame_start(akgl_renderer));
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
||||
PASS(e, akgl_game_update(NULL));
|
||||
PASS(e, akgl_renderer->frame_end(akgl_renderer));
|
||||
PASS(e, akgl_game_state_unlock());
|
||||
```
|
||||
|
||||
## How do I get an actor on screen
|
||||
|
||||
Load a sprite for a character. Sprites are JSON documents describing 2D sprites, frames, and looping. Sprites are named, and sprite names must be unique.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_sprite_load_json(SOME_FILENAME))
|
||||
```
|
||||
|
||||
Load a character from a JSON file. Characters map sprites to actor states and define physics characteristics like movement speed. Each character is named, and character names must be unique.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_character_load_json(SOME_FILENAME))
|
||||
```
|
||||
|
||||
You don't strictly have to load sprites and characters from json files, you can initialize them yourself, it's just tedious work. Here's an example of initializing a 32x32 sprite from a spritesheet that uses the first 4 frames in a looping animation.
|
||||
|
||||
```c
|
||||
akgl_SpriteSheet *sheet;
|
||||
akgl_Sprite *sprite;
|
||||
akgl_Character *character;
|
||||
PASS(e, akgl_heap_next_spritesheet(&sheet);
|
||||
PASS(e, akgl_spritesheet_initialize(sheet, 32, 32, IMAGE_FILENAME));
|
||||
PASS(e, akgl_heap_next_sprite(&sprite));
|
||||
PASS(e, akgl_sprite_initialize(sprite, SPRITE_NAME, &sheet);
|
||||
sprite->frames = 4;
|
||||
sprite->frameids = [0, 1, 2, 3];
|
||||
sprite->width = 32;
|
||||
sprite->height = 32;
|
||||
sprite->speed = 1000;
|
||||
sprite->loop = true;
|
||||
strncpy((char *)&sprite->name, "SPRITE NAME", AKGL_SPRITE_MAX_NAME_LENGTH);
|
||||
PASS(e, akgl_heap_next_character(&character));
|
||||
PASS(e, akgl_character_initialize(&character, "CHAR NAME"));
|
||||
PASS(e, akgl_character_sprite_add(&character, &sprite, STATE_MASK));
|
||||
// Set the character acceleration and scale if desired
|
||||
character->ax = 64.0;
|
||||
character->ay = 64.0;
|
||||
character->sx = 2.0;
|
||||
character->sy = 2.0;
|
||||
```
|
||||
|
||||
Initialize an actor. Actors are named ("player", "Quest NPC", whatever) and names must be unique.
|
||||
|
||||
```c
|
||||
akgl_Actor *myactor = NULL;
|
||||
PASS(e, akgl_heap_next_actor(&myactor);
|
||||
PASS(e, akgl_actor_initialize(&myactor, "ACTOR_NAME"));
|
||||
```
|
||||
|
||||
Assign a character to the actor by looking up the `akgl_Character` from the AKGL registry and assign it.
|
||||
|
||||
```c
|
||||
myactor->basechar = SDL_GetPointerProperty(
|
||||
AKGL_REGISTRY_CHARACTER,
|
||||
"CHARACTER_NAME",
|
||||
NULL);
|
||||
FAIL_ZERO_BREAK(e, myactor->basechar, AKERR_REGISTRY, "Character missing");
|
||||
```
|
||||
|
||||
Give the actor a position and a state, and turn it visible.
|
||||
|
||||
```c
|
||||
myactor->state = 9AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
myactor->x = 320;
|
||||
myactor->y = 240;
|
||||
myactor->visible = true;
|
||||
```
|
||||
|
||||
## What are in Sprite and Character files
|
||||
|
||||
Sprite files:
|
||||
|
||||
```json
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "RELATIVE_IMAGE_FILE_REFERENCE",
|
||||
"frame_width": int,
|
||||
"frame_height": int
|
||||
},
|
||||
"name": "UNIQUE_SPRITE_NAME",
|
||||
"width": int,
|
||||
"height": int,
|
||||
"speed": int,
|
||||
"loop": boolean,
|
||||
"loopReverse": boolean,
|
||||
"frames": [
|
||||
int
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* `frames` references the frame indexes in the spritesheet that should be used for this animation. Spritesheets are counted from the top left corner going to the right according to the spritesheet `frame_width` and `frame_height`.
|
||||
* `loop` says whether or not we should loop the animation
|
||||
* `loopReverse` says whether or not we should "bounce" the animation (when we reach the end of the frames, start counting back to the beginning, then count to the end, etc). Otherwise the frames are displayed from 0..n and then cycles back to 0.
|
||||
* `speed` is the number of milliseconds each frame in the animation should appear on the screen
|
||||
|
||||
Character files:
|
||||
|
||||
```c
|
||||
{
|
||||
"name": "UNIQUE_CHARACTER_NAME",
|
||||
"speedtime": 8,
|
||||
"speed_x": 0,
|
||||
"speed_y": 0,
|
||||
"acceleration_x": 0,
|
||||
"acceleration_y": 0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_UP",
|
||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||
],
|
||||
"sprite": "menupointer"
|
||||
}[, ...]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* `speedtime` appears to be legacy and unused.
|
||||
* `speed_[xy]` and `acceleration_[xy]` are physics parameters that specify the top speed and acceleration rate (in pixels per nanosecond) of the character in physics simulations. The effect of acceleration depends on the physics simulation being used at the time (which may or may not account for gravity, drag, etc).
|
||||
* `sprite_mappings` map a set of actor state flag bitmasks (assume everything in `state` is `OR`ed together) to a sprite name. The game engine uses this to automatically pick the correct sprite (by name) for a given set of state flags. You need one of these for every possible state the character may be used in.
|
||||
|
||||
## How do I load a tilemap from the filesystem and display it on screen with my actors
|
||||
|
||||
The engine ONLY supports TilED TMJ tilemaps with tileset external references. Load a tilemap into the global `akgl_Tilemap *gamemap` object.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_tilemap_load(PATHSTRING, akgl_gamemap));
|
||||
```
|
||||
|
||||
Actors will be automatically populated from objects in the tilemap object layers. Actor state flags here must be expressed as an integer, you can't (yet) use the same array of strings that is used in character json files.
|
||||
|
||||
```json
|
||||
"objects":[
|
||||
{
|
||||
"gid":147,
|
||||
"height":16,
|
||||
"id":1,
|
||||
"name":"player",
|
||||
"properties":[
|
||||
{
|
||||
"name":"character",
|
||||
"type":"string",
|
||||
"value":"little guy"
|
||||
},
|
||||
{
|
||||
"name":"state",
|
||||
"type":"int",
|
||||
"value":24
|
||||
}],
|
||||
"rotation":0,
|
||||
"type":"actor",
|
||||
"visible":true,
|
||||
"width":16,
|
||||
"x":440.510088317656,
|
||||
"y":140.347239175702
|
||||
}[, ... ]
|
||||
```
|
||||
|
||||
Check if the tilemap wants to use its own physics, and if you want to allow that, override the global physics simulation
|
||||
|
||||
```c
|
||||
if ( akgl_gamemap->use_own_physics == true ) {
|
||||
akgl_physics = &akgl_gamemap->physics;
|
||||
}
|
||||
```
|
||||
|
||||
Tilemap physics specification follows. A map can specify its own physics properties (drag, gravity) without specifying a custom model.
|
||||
|
||||
```json
|
||||
"properties":[
|
||||
{
|
||||
"name":"physics.drag.y",
|
||||
"type":"float",
|
||||
"value":0
|
||||
},
|
||||
{
|
||||
"name":"physics.gravity.y",
|
||||
"type":"float",
|
||||
"value":0
|
||||
},
|
||||
{
|
||||
"name":"physics.model",
|
||||
"type":"string",
|
||||
"value":"arcade"
|
||||
}],
|
||||
```
|
||||
|
||||
The global `akgl_gamemap` object is automatically displayed if it is populated. Actors are drawn at the appropriate map layer depending on the actor's `layer` property (warning: this may be replaced with a `z` property soon.)
|
||||
|
||||
## How do I get the screen width and height
|
||||
|
||||
The most direct is to call `SDL_GetCurrentDisplayMode` to get the parameters from the returned `SDL_DisplayMode` structure (`->w` and `->h`).
|
||||
|
||||
The simplest way is to check the global `akgl_camera` object's `akgl_camera->w` and `akgl_camera->h` object. You may have more than one camera on a scene, and it's theoretically possible that the global camera object has been overriden and no longer represents the full screen.
|
||||
|
||||
The most reliable engine-centric way is to use `akgl_get_property` to get the property from the engine. Properties are read and stored as strings, so if you need to do these kinds of things a lot, cache the integer value somewhere.
|
||||
|
||||
```c
|
||||
akgl_String *width = NULL;
|
||||
int screenwidth = NULL;
|
||||
PASS(e, akgl_get_property("game.screenwidth", &width, "0"));
|
||||
PASS(e, aksl_atoi(width->data, &screenwidth));
|
||||
PASS(e, akgl_heap_release_string(width));
|
||||
```sh
|
||||
./tests/docs_examples.sh --root . \
|
||||
--cflags-file build/docs_cflags.txt \
|
||||
--ldflags-file build/docs_ldflags.txt \
|
||||
--checkjson build/akgl_docs_checkjson \
|
||||
docs/14-physics.md
|
||||
```
|
||||
|
||||
`docs/MAINTENANCE.md` is the reference for the info strings, the preludes and the fixtures.
|
||||
There is deliberately no docs-path filter in CI: documentation goes stale because the *code*
|
||||
moved, not because somebody edited a chapter.
|
||||
|
||||
## Git hooks
|
||||
|
||||
|
||||
126
docs/01-introduction.md
Normal file
126
docs/01-introduction.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 01. Introduction
|
||||
|
||||
libakgl is a C library for building 2D games on SDL3. This is version **0.7.0**. It ships
|
||||
156 functions declared across the twenty public headers in `include/akgl/`, plus
|
||||
`akgl_version()` from the generated `version.h` — 157 exported symbols in
|
||||
`libakgl.so.0.7`.
|
||||
|
||||
It gives you object pools instead of `malloc`, name-based registries instead of pointer
|
||||
plumbing, a per-frame tick that updates and draws every live actor, JSON asset formats for
|
||||
sprites and characters, a Tiled map loader, arcade physics, a controller/keyboard input
|
||||
layer, text, and a three-voice tone synthesizer.
|
||||
|
||||
## What it refuses to be
|
||||
|
||||
**It is not an engine.** There is no editor, no scene graph, no scripting layer, no asset
|
||||
pipeline that runs before the compiler, and no runtime that owns your `main`. You write a C
|
||||
program; libakgl is a library it calls.
|
||||
|
||||
The refusals are specific, and each one is a design decision documented in
|
||||
[Chapter 2](02-design-philosophy.md):
|
||||
|
||||
| Not here | Instead |
|
||||
|---|---|
|
||||
| Inheritance, RTTI, dynamic dispatch on a type tag | A record of function pointers you populate — `akgl_RenderBackend`, `akgl_PhysicsBackend` |
|
||||
| Runtime `malloc` | Five statically sized pools with reference counts; `AKGL_ERR_HEAP` when one is full |
|
||||
| An editor or a project format | Tiled TMJ maps and hand-writable JSON for sprites and characters |
|
||||
| A scripting layer | C. Behaviour attaches as function pointers on `akgl_Actor` |
|
||||
| Two worlds at once | Four swappable globals: `akgl_renderer`, `akgl_physics`, `akgl_camera`, `akgl_gamemap` |
|
||||
|
||||
The library also does not own your window. `akgl_render_2d_init` creates one for you, and
|
||||
`akgl_render_2d_bind` is the same job with that half removed, for a host that already has an
|
||||
`SDL_Renderer` — see [Chapter 8](08-rendering.md).
|
||||
|
||||
## What a frame costs
|
||||
|
||||
`PERFORMANCE.md` measures a 640x480 game with a full screen of 16-pixel tiles and 64 actors,
|
||||
software-rasterized. At 60 fps a frame is 16.67 ms:
|
||||
|
||||
| Part of the frame | Cost | Share of 16.67 ms |
|
||||
|---|---:|---:|
|
||||
| Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% |
|
||||
| All-pairs collision over 64 actors, if you do it | 0.115 ms | 0.7% |
|
||||
| Clear + present | 0.023 ms | 0.1% |
|
||||
| 64 actor renders (48x48 blits) | 0.191 ms | 1.1% |
|
||||
| 1200 tile blits | 16.26 ms | 97.6% |
|
||||
| Six lines of HUD text | 0.076 ms | 0.5% |
|
||||
|
||||
**libakgl's own per-tile overhead is about 0.03 ms per frame, under 0.2%.** That number is
|
||||
the gap between `akgl_tilemap_draw` at 16.26 ms and a raw `SDL_RenderTexture` loop issuing
|
||||
the same 1200 blits from the same scattered source tiles at 16.23 ms. It covers the bounds
|
||||
arithmetic, the tileset scan, the offset-table lookup, the backend indirection and the error
|
||||
macros.
|
||||
|
||||
It does not cover the pixels, and the pixels are the frame. It does not tell you what
|
||||
libakgl costs on a GPU backend, where the blits get cheap and libakgl's share of a much
|
||||
shorter frame rises — `PERFORMANCE.md` says so explicitly and is the reason the
|
||||
per-operation numbers there matter more than these totals. And it is one laptop, one build
|
||||
type, one afternoon: treat the absolute numbers as that machine's and the ratios as the
|
||||
library's.
|
||||
|
||||
## What is not implemented
|
||||
|
||||
Named here rather than discovered later. Each gets a callout in the chapter where you would
|
||||
hit it, and an entry in `TODO.md`.
|
||||
|
||||
| Gap | Behaviour today | Chapter |
|
||||
|---|---|---|
|
||||
| Collision response | `akgl_physics_arcade_collide` raises `AKERR_API`, and `akgl_physics_simulate` never calls `collide` at all. An actor walks through a wall | [14](14-physics.md) |
|
||||
| Terminal velocity | Gravity accumulates into `ey` unbounded. `physics.drag.y` is the only brake | [14](14-physics.md) |
|
||||
| Friction / deceleration | Releasing a direction zeroes thrust and stops the actor dead. Right for Zelda, wrong for Mario | [14](14-physics.md) |
|
||||
| Savegames | `akgl_game_save` writes the name tables but not the objects, so a file is not yet enough to restore a session | [07](07-the-game-and-the-frame.md) |
|
||||
| Mesh drawing | `akgl_render_2d_draw_mesh` raises `AKERR_API`; the hook is reserved for a 3D backend | [08](08-rendering.md) |
|
||||
| A text cache | Every `akgl_text_rendertextat` rasterizes, uploads, blits and destroys | [16](16-text-and-fonts.md) |
|
||||
|
||||
## Who owns which documentation
|
||||
|
||||
**This manual documents libakgl. It does not re-document its dependencies.** Every project
|
||||
below is documented by the people who own its code, and a paraphrase here would be wrong the
|
||||
day one of them changes without anything in this repository noticing. So each chapter
|
||||
answers two questions — what does libakgl add or constrain here, and what does a libakgl
|
||||
caller actually write — and links out for the rest.
|
||||
|
||||
| Topic | Owned by | What this manual owes you |
|
||||
|---|---|---|
|
||||
| `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH`, `PASS`, `CATCH`, `IGNORE` | libakerror (`deps/libakerror`) | Which statuses libakgl raises and what they mean here — [Chapter 4](04-errors.md) |
|
||||
| `aksl_strncpy`, `aksl_fclose`, `aksl_fgetc`, `aksl_snprintf` | libakstdlib (`deps/libakstdlib`) | Which ones libakgl requires you to use, and why |
|
||||
| `SDL_Renderer`, `SDL_Texture`, events, `SDL_PropertiesID` | [SDL3](https://wiki.libsdl.org/SDL3/) | The backend vtable, the frame contract, what libakgl does to the renderer's state |
|
||||
| Image decoding | [SDL3_image](https://wiki.libsdl.org/SDL3_image/) | Which formats reach a spritesheet, and when they are decoded |
|
||||
| Audio decoding, `MIX_Audio`, mixers and tracks | [SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) | `akgl_load_start_bgm` and the track table — [Chapter 17](17-audio.md) |
|
||||
| TTF rasterizing and metrics | [SDL3_ttf](https://wiki.libsdl.org/SDL3_ttf/) | The font registry, the teardown ordering trap, the per-call cost — [Chapter 16](16-text-and-fonts.md) |
|
||||
| `json_t`, `json_decref`, the parser | [jansson](https://jansson.readthedocs.io/) | `akgl_get_json_*` status semantics and the borrowed-reference rule — [Chapter 18](18-utilities.md) |
|
||||
| The TMJ map format, layers, tilesets, custom properties | [Tiled](https://doc.mapeditor.org/en/stable/reference/json-map-format/) | libakgl's extensions and limits — [Chapter 13](13-tilemaps.md) |
|
||||
|
||||
The three-voice synthesizer in [Chapter 17](17-audio.md) is the one audio subsystem that is
|
||||
libakgl's own, and it is documented here in full. It has nothing to do with SDL3_mixer.
|
||||
|
||||
## Where the per-function reference lives
|
||||
|
||||
This manual is narrative. It teaches a task and links to the generated Doxygen for
|
||||
signatures, parameters and per-function `@throws` lists:
|
||||
|
||||
```sh norun
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
Every header already carries a substantial `@file` block explaining its subsystem's design
|
||||
rationale, and `Doxyfile` sets `WARN_IF_UNDOCUMENTED = YES` with
|
||||
`WARN_AS_ERROR = FAIL_ON_WARNINGS`, so an undocumented symbol fails CI. The gap these
|
||||
chapters fill is navigation and worked examples, not reference text. **They deliberately do
|
||||
not restate the 156 signatures** — a hand-copied signature table is exactly the artifact
|
||||
that drifts, and it would compete with a reference that CI already keeps honest.
|
||||
|
||||
Where a chapter genuinely needs a declaration or a constant table in front of you, it uses
|
||||
an `excerpt=` block whose contents are checked against the header on every test run. The
|
||||
text you read *is* the header.
|
||||
|
||||
## Reading order
|
||||
|
||||
[Chapter 4](04-errors.md) comes before every subsystem chapter, because all 156 functions
|
||||
return `akerr_ErrorContext AKERR_NOIGNORE *` and you cannot read a single example until you
|
||||
can read that return value. After that, [Chapter 3](03-getting-started.md) gets a window on
|
||||
the screen, and the subsystem chapters can be read in any order.
|
||||
|
||||
If you would rather start by building something, the two tutorials —
|
||||
[Chapter 19](19-tutorial-sidescroller.md) and [Chapter 20](20-tutorial-jrpg.md) — are
|
||||
complete programs under `examples/`, built by default and smoke-run in CI.
|
||||
297
docs/02-design-philosophy.md
Normal file
297
docs/02-design-philosophy.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# 02. Design philosophy
|
||||
|
||||
Six decisions shape every API in this library. They are not preferences stated up front and
|
||||
then quietly abandoned in the implementation; each one is visible in the code, and this
|
||||
chapter shows the code that proves it.
|
||||
|
||||
None of this is an argument that you should build a game this way. It is an explanation of
|
||||
what libakgl will and will not do for you, so you can decide whether the tradeoffs are ones
|
||||
you want.
|
||||
|
||||
## Bounded, pre-declared resources — not dynamic ones
|
||||
|
||||
**libakgl does not call `malloc` at runtime.** Every runtime object comes out of a fixed,
|
||||
statically allocated array declared in `heap.h`. There are five such arrays — actors,
|
||||
sprites, spritesheets, characters and strings — and the ceilings are compile-time constants:
|
||||
|
||||
```c excerpt=include/akgl/heap.h
|
||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
||||
#define AKGL_MAX_HEAP_ACTOR 64
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_SPRITE
|
||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_SPRITESHEET
|
||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_CHARACTER
|
||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_STRING
|
||||
#define AKGL_MAX_HEAP_STRING 256
|
||||
#endif
|
||||
```
|
||||
|
||||
Allocation is a linear scan for a slot whose reference count is zero. That is the whole
|
||||
algorithm:
|
||||
|
||||
```c excerpt=src/heap.c
|
||||
akerr_ErrorContext *akgl_heap_next_actor(akgl_Actor **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &akgl_heap_actors[i];
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused actor on the heap");
|
||||
}
|
||||
```
|
||||
|
||||
Two consequences follow, and both matter more than the absence of `malloc`.
|
||||
|
||||
**Exhaustion is a normal error, not a catastrophe.** `AKGL_ERR_HEAP` means "this pool is
|
||||
full". It is not `ENOMEM`, the process is not in trouble, and the fix is almost never a
|
||||
bigger pool — **it is usually a missing release.** Raising `AKGL_MAX_HEAP_*` before you have
|
||||
checked for a leak converts a bug that fails on frame 300 into one that fails on frame 3000.
|
||||
|
||||
**The pools are compile-time sized, so the ceiling is an ABI constraint.** The arrays live
|
||||
in the library, so libakgl and everything linking it must agree on the numbers. Overriding
|
||||
one means rebuilding the whole tree, not just your game.
|
||||
|
||||
The idiom for a pool object is claim, use, release in `CLEANUP`, which runs on every path
|
||||
out of the `ATTEMPT` block:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/* Claim, use, release. The CLEANUP block runs on every path out of ATTEMPT. */
|
||||
akerr_ErrorContext *scratch_path(char *root, char *name)
|
||||
{
|
||||
akgl_String *buf = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_heap_next_string(&buf));
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_string_initialize(buf, name));
|
||||
CATCH(errctx, akgl_path_relative(root, buf->data, buf));
|
||||
SDL_Log("resolved to %s", buf->data);
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(buf));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**If the pool has no layer for your type, add one.** A new kind of runtime object gets a new
|
||||
array, a `next`, and a `release`, in `heap.h` and `src/heap.c`. It does not get an
|
||||
allocator. [Chapter 5](05-the-heap.md) covers the layers, the reference-count asymmetry
|
||||
between `akgl_heap_next_string` and the other four, and what the ceilings cost you.
|
||||
|
||||
## Variation lives in a backend, not in a branch
|
||||
|
||||
There are exactly two pluggable subsystems, and both have the same shape: **a struct of
|
||||
function pointers, plus an initializer that populates it.**
|
||||
|
||||
```text
|
||||
akgl_RenderBackend akgl_PhysicsBackend
|
||||
+--------------------+ +--------------------+
|
||||
| sdl_renderer | | simulate |
|
||||
| shutdown | | gravity |
|
||||
| frame_start | | collide |
|
||||
| frame_end | | move |
|
||||
| draw_texture | | drag_x/y/z |
|
||||
| draw_mesh | | gravity_x/y/z |
|
||||
| draw_world | | max_timestep |
|
||||
+--------------------+ +--------------------+
|
||||
^ ^
|
||||
| populated by | populated by
|
||||
akgl_render_2d_bind akgl_physics_init_null
|
||||
akgl_render_2d_init akgl_physics_init_arcade
|
||||
(chosen by akgl_physics_factory)
|
||||
```
|
||||
|
||||
The shipped physics initializers are five assignments and nothing else:
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
self->gravity = akgl_physics_null_gravity;
|
||||
self->collide = akgl_physics_null_collide;
|
||||
self->move = akgl_physics_null_move;
|
||||
self->simulate = akgl_physics_simulate;
|
||||
```
|
||||
|
||||
**To vary the behaviour, write an initializer and register it — do not add a conditional.**
|
||||
Nothing in `akgl_physics_simulate` knows which backend it is stepping, and nothing in the
|
||||
render path knows whether `draw_texture` came from the 2D backend or from yours. You can
|
||||
also mix: borrow the entry points that already do what you want and replace only the one
|
||||
that does not.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/actor.h>
|
||||
|
||||
/* One tick of "everything floats up at a constant rate", as a backend. */
|
||||
static akerr_ErrorContext *floaty_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
actor->ey = -40.0f * dt;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *floaty_init(akgl_PhysicsBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
/* Borrow the parts that already do the right thing. */
|
||||
self->simulate = akgl_physics_simulate;
|
||||
self->move = akgl_physics_arcade_move;
|
||||
self->collide = akgl_physics_null_collide;
|
||||
self->gravity = floaty_gravity;
|
||||
|
||||
self->gravity_time = SDL_GetTicksNS();
|
||||
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
There is no inheritance, no RTTI and no dispatch on a type tag anywhere in the library.
|
||||
Behaviour attaches as a function pointer on a struct — which is also how an actor's own six
|
||||
behaviour hooks work, in [Chapter 12](12-actors.md).
|
||||
|
||||
## Bit flags are the default representation for state
|
||||
|
||||
Where another library would give you an enum and a setter per property, libakgl gives you a
|
||||
32-bit mask and four macros. An actor's state is the clearest case: it is a *set*, not a
|
||||
value, because an actor is alive and facing left and moving left all at once.
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
||||
```
|
||||
|
||||
The iterator flags in `iterator.h` are the same idea for "what should this sweep do":
|
||||
|
||||
```c excerpt=include/akgl/iterator.h
|
||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
||||
```
|
||||
|
||||
Both tables are hand-aligned, with the decimal value and the bit pattern in a comment per
|
||||
row, and both are protected from the reindent script's `tabify` step precisely so that
|
||||
alignment survives. The tables in the headers are the reference; these excerpts are checked
|
||||
against them.
|
||||
|
||||
**Use the macros, not the operators.** `AKGL_BITMASK_HAS(x, y)` is "every bit of `y` is set
|
||||
in `x`", not "any of them". It is fully parenthesized so that `!AKGL_BITMASK_HAS(a, b)`
|
||||
means what it reads as — until 0.5.0 it did not, and the negation bound to the `&`. Nothing
|
||||
in the tree negated it, which is the only reason that was latent rather than live.
|
||||
|
||||
## Errors carry context, and callers cannot ignore them
|
||||
|
||||
**Every one of libakgl's 156 functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.**
|
||||
Results come back through pointer parameters; the return value is always the error. The
|
||||
`AKERR_NOIGNORE` attribute makes discarding it a compiler diagnostic.
|
||||
|
||||
That is a libakerror design and it is documented by libakerror.
|
||||
[Chapter 4](04-errors.md) covers the part that is genuinely libakgl's: which statuses these
|
||||
functions raise, what each one means *here*, and the traps libakgl's own structure creates.
|
||||
Read it before any subsystem chapter.
|
||||
|
||||
The reasoning behind the choice is operational rather than aesthetic. A game that fails on
|
||||
somebody else's machine leaves you a log file and nothing else, so an error has to say what
|
||||
failed, where, and why, at the point it happened — not three frames later at a `NULL`
|
||||
dereference with no history attached.
|
||||
|
||||
## Everything is referenced by name
|
||||
|
||||
Nothing in this library is passed around by pointer where a name will do. An actor names the
|
||||
character it instantiates; a character names the sprites it draws for each state; a sprite
|
||||
names the sheet it cuts frames from. All of it is resolved at load time through eight
|
||||
registries:
|
||||
|
||||
```c excerpt=include/akgl/registry.h
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_ACTOR;
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITE;
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITESHEET;
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_CHARACTER;
|
||||
```
|
||||
|
||||
The lookup itself is unremarkable, and that is the point:
|
||||
|
||||
```c excerpt=src/actor.c
|
||||
obj->basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, basecharname, NULL);
|
||||
```
|
||||
|
||||
**This is what lets the whole asset graph be described in JSON files that reference each
|
||||
other by name**, with no build step and no code generation. It also means a typo in an asset
|
||||
file is a runtime failure rather than a compile error — which is the tradeoff, stated
|
||||
plainly. The registries are SDL property sets rather than an akgl type, so you can enumerate
|
||||
one with `SDL_EnumerateProperties`; that is exactly what `akgl_registry_iterate_actor` does.
|
||||
|
||||
Two sharp edges, both in [Chapter 6](06-the-registry.md): an uninitialized registry id is 0,
|
||||
which SDL treats as "no such property set" and silently drops writes to; and
|
||||
`AKGL_REGISTRY_SPRITESHEET` is keyed on the *resolved* image path, which is what makes two
|
||||
sprites naming the same PNG share one texture.
|
||||
|
||||
## One world at a time
|
||||
|
||||
The library keeps exactly one of everything, reachable through four globals:
|
||||
|
||||
```c excerpt=include/akgl/game.h
|
||||
extern akgl_Tilemap *akgl_gamemap;
|
||||
extern akgl_RenderBackend *akgl_renderer;
|
||||
extern akgl_PhysicsBackend *akgl_physics;
|
||||
extern SDL_FRect *akgl_camera;
|
||||
```
|
||||
|
||||
`akgl_game_init` points each of them at default storage — `akgl_default_renderer`,
|
||||
`akgl_default_physics`, `akgl_default_camera`, `akgl_default_gamemap`. **They are pointers
|
||||
so that you can substitute your own instance by reassigning one**, and the rest of the
|
||||
library goes on drawing and simulating through it without knowing.
|
||||
|
||||
That is the entire extent of the indirection. **There is no notion of two worlds at once.**
|
||||
No context handle is threaded through the API, no `akgl_World *` is passed to every call,
|
||||
and two simultaneous games in one process is not a thing this library supports. If you want
|
||||
a paused world behind a menu, you save the state you care about and swap the pointers back.
|
||||
|
||||
The globals all carry the `akgl_` prefix, and that is not cosmetic. Until 0.5.0 the library
|
||||
exported a bare `renderer`, `tests/character.c` defined an `SDL_Renderer *renderer` of its
|
||||
own, both had external linkage with the same spelling, and the executable's definition
|
||||
preempted the library's. Every texture load in that suite failed and the suite reported
|
||||
success anyway. A consuming game with a variable called `renderer` would have hit exactly
|
||||
the same thing, with no test to notice.
|
||||
|
||||
## What the six add up to
|
||||
|
||||
You get a library whose worst-case memory is knowable at compile time, whose failure modes
|
||||
arrive as messages instead of crashes, whose behaviour you extend by writing a function
|
||||
rather than by editing a switch, and whose assets are text files you can edit by hand.
|
||||
|
||||
You give up dynamic sizing, more than one world, and any ability to describe a game without
|
||||
writing C. Those are the tradeoffs. [Chapter 3](03-getting-started.md) puts a window on the
|
||||
screen.
|
||||
288
docs/03-getting-started.md
Normal file
288
docs/03-getting-started.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 03. Getting started
|
||||
|
||||
This chapter gets libakgl into your build and a window on your screen. It assumes you have
|
||||
read [Chapter 4](04-errors.md), because the first program is written in the error protocol
|
||||
and will not make sense otherwise.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Floor | Why libakgl needs it |
|
||||
|---|---|---|
|
||||
| SDL3 | 3.4.x (3.4.8 is vendored) | Window, renderer, events, properties, timing, mutexes |
|
||||
| SDL3_image | vendored | Decoding spritesheet and tileset images |
|
||||
| SDL3_mixer | vendored | `akgl_load_start_bgm`, the mixer device and track table |
|
||||
| SDL3_ttf | vendored | Font loading, rasterizing and metrics |
|
||||
| jansson | vendored | Parsing every JSON asset format |
|
||||
| libakerror | **>= 2.0.1, hard floor** | The error protocol. Part of libakgl's *public* interface |
|
||||
| libakstdlib | >= 0.2 | Checked wrappers over libc — `aksl_strncpy`, `aksl_fclose`, `aksl_snprintf` |
|
||||
| semver | vendored, in-tree | Savegame and version comparison |
|
||||
|
||||
All eight are git submodules under `deps/`, so a recursive clone gives you a build with no
|
||||
system packages at all.
|
||||
|
||||
**The libakerror floor is enforced, not advisory.** Two `#error` feature tests in
|
||||
`include/akgl/error.h` fire on a stale header:
|
||||
|
||||
```c excerpt=include/akgl/error.h
|
||||
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
|
||||
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
|
||||
#endif
|
||||
```
|
||||
|
||||
It is a hard floor because libakerror 2.0.0 made `akerr_next_error()` return a context that
|
||||
already holds its reference and moved `__akerr_last_ignored` into thread-local storage —
|
||||
and both of those expand at **your** call sites, through `IGNORE` and the `FAIL_*` macros,
|
||||
because `akerror.h` is part of libakgl's public interface. A tree that mixes headers and
|
||||
libraries across that line double-counts every reference and never returns a pool slot. The
|
||||
soname moved to `libakerror.so.2` to stop the library half happening by accident; the
|
||||
`#error` catches the header half in an install tree.
|
||||
|
||||
## Building libakgl itself
|
||||
|
||||
```sh norun
|
||||
git clone --recursive https://source.starfort.tech/andrew/libakgl.git
|
||||
cd libakgl
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build --parallel
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
If a dependency is already installed system-wide, libakgl finds it and skips the vendored
|
||||
copy. The rule is `if(NOT TARGET ...)` per dependency, so mixing is fine: a system SDL3 with
|
||||
a vendored jansson works.
|
||||
|
||||
## Route 1 — `add_subdirectory()`
|
||||
|
||||
This is what `akbasic` does, and it is the route with the fewest ways to go wrong: one set
|
||||
of compiler flags, one set of headers, nothing to install.
|
||||
|
||||
```cmake
|
||||
add_subdirectory(third_party/libakgl)
|
||||
|
||||
add_executable(mygame src/main.c)
|
||||
target_link_libraries(mygame PRIVATE akgl::akgl)
|
||||
```
|
||||
|
||||
`akgl::akgl` carries its own usage requirements, so the SDL3, libakerror and jansson include
|
||||
paths reach your target without you naming them.
|
||||
|
||||
**`AKGL_WERROR` is `OFF` by default, and leaving it off is the right thing for a consumer.**
|
||||
It is turned on only by two CI jobs in this repository. The reason is exactly your build: a
|
||||
target-level `-Werror` turns every diagnostic a newer compiler invents into a broken build
|
||||
for somebody else's project, and the warning set is not even stable across optimization
|
||||
levels — an `-O2` build reports ten `-Wstringop-truncation` warnings that `-O0` and
|
||||
`-fsyntax-only` do not. `-Wall` is on for every target this project owns, and the tree builds
|
||||
clean under it at every optimization level.
|
||||
|
||||
If you have already vendored SDL3 yourself, declare it before the `add_subdirectory` and
|
||||
libakgl will use yours rather than adding a second copy:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(third_party/SDL) # declares SDL3::SDL3
|
||||
add_subdirectory(third_party/libakgl) # sees it and does not add its own
|
||||
```
|
||||
|
||||
## Route 2 — pkg-config
|
||||
|
||||
`make install` puts `akgl.pc` in `lib/pkgconfig/`. Here is what it contains, in full:
|
||||
|
||||
```text
|
||||
prefix=/usr/local
|
||||
exec_prefix=${prefix}
|
||||
libdir=${exec_prefix}/lib
|
||||
includedir=${exec_prefix}/include
|
||||
|
||||
Name: akgl
|
||||
Description: AKLabs Game library
|
||||
Version: 0.7.0
|
||||
Cflags: -I${includedir}/
|
||||
Libs: -L${libdir} -lakgl
|
||||
```
|
||||
|
||||
**`akgl.pc` names no dependencies at all.** There is no `Requires:` and no
|
||||
`Requires.private:` line, so `pkg-config --cflags --libs akgl` tells you nothing about
|
||||
libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer, SDL3_ttf or jansson. That has always
|
||||
been wrong, and it is sharper than it looks: `akerror.h` is part of libakgl's *public*
|
||||
interface, so a consumer that builds against whatever `akerror.h` is on its include path and
|
||||
links whatever `libakerror.so` the loader finds can get exactly the mismatch pkg-config had
|
||||
every opportunity to prevent.
|
||||
|
||||
Until that is fixed, name them yourself. These are the modules whose headers libakgl's own
|
||||
public headers include:
|
||||
|
||||
```sh norun
|
||||
pkg-config --cflags --libs akgl akerror akstdlib sdl3 sdl3-ttf sdl3-mixer jansson
|
||||
```
|
||||
|
||||
`sdl3-image` is needed at link time but appears in no public header, so it belongs in the
|
||||
private half.
|
||||
|
||||
Recorded in `TODO.md` under "akgl.pc names no dependencies", along with why the fix wants
|
||||
its own commit: adding a `Requires:` line changes what `pkg-config --libs akgl` emits for
|
||||
every existing consumer.
|
||||
|
||||
**There is no CMake package config.** `install()` ships the library, the headers and
|
||||
`akgl.pc`, and nothing generates an `akglConfig.cmake`, so `find_package(akgl)` does not
|
||||
work against an install tree. Use `add_subdirectory()` or pkg-config.
|
||||
|
||||
## The startup order that actually works
|
||||
|
||||
Verified against `src/game.c`, not against the header comment:
|
||||
|
||||
```text
|
||||
1. fill in akgl_game.name, .version and .uri
|
||||
| akgl_game_init refuses to run without all three, and there are no
|
||||
| defaults -- the window title, SDL's app metadata and the savegame
|
||||
| compatibility check are all built from them
|
||||
v
|
||||
2. akgl_game_init()
|
||||
| error codes, version stamp, frame clock, state mutex, pools, the eight
|
||||
| registries, SDL app metadata, control maps, SDL_Init(VIDEO|GAMEPAD|
|
||||
| AUDIO), controller DB, gamepads, SDL_mixer, SDL_ttf, and finally the
|
||||
| four globals pointed at their default storage.
|
||||
|
|
||||
| What it does NOT do: create the window, choose a physics backend, or
|
||||
| load any configuration. Those read properties, which you have not set
|
||||
| yet.
|
||||
v
|
||||
3. akgl_registry_load_properties() or akgl_set_property()
|
||||
| screen size, physics constants, anything else
|
||||
v
|
||||
4. akgl_render_2d_init(akgl_renderer) <- creates the window; reads
|
||||
| game.screenwidth/screenheight
|
||||
| akgl_physics_factory(akgl_physics, name) <- "null" or "arcade"
|
||||
v
|
||||
5. load assets, then loop on akgl_game_update()
|
||||
```
|
||||
|
||||
**`akgl_game_init` does not choose a physics backend for you.** `physics.h` says the
|
||||
`physics.engine` property drives `akgl_physics_factory`; it does not. That string appears
|
||||
nowhere in `src/`, and `akgl_game_init` never calls the factory. You call it yourself, with
|
||||
an `akgl_String` holding `"null"` or `"arcade"`. The header comment is wrong and is left
|
||||
alone here deliberately — this manual documents what the code does, and correcting header
|
||||
prose is a separate commit.
|
||||
|
||||
## The first window
|
||||
|
||||
Sixty frames of an empty world, then out. It compiles under
|
||||
`-std=gnu99 -Wall -Werror` and runs headless.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
akgl_String *engine = NULL;
|
||||
int frame = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
/* 1. akgl_game_init refuses to run without all three of these. */
|
||||
CATCH(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
|
||||
"firstwindow", sizeof(akgl_game.name) - 1));
|
||||
CATCH(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
|
||||
"0.1.0", sizeof(akgl_game.version) - 1));
|
||||
CATCH(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
|
||||
"net.example.firstwindow", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
/* 2. SDL, the pools, the registries, audio, fonts, gamepads. */
|
||||
CATCH(errctx, akgl_game_init());
|
||||
|
||||
/* 3. Configuration. akgl_render_2d_init reads these two. */
|
||||
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
|
||||
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
|
||||
|
||||
/* 4. The window and the renderer, then a physics backend. */
|
||||
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
CATCH(errctx, akgl_heap_next_string(&engine));
|
||||
CATCH(errctx, akgl_string_initialize(engine, "null"));
|
||||
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
|
||||
|
||||
/* 5. Load assets, then loop. */
|
||||
for ( frame = 0; frame < 60; frame++ ) {
|
||||
CATCH(errctx, akgl_game_update(NULL));
|
||||
}
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(engine));
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
SDL_Quit();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Five things in there are worth pointing at.
|
||||
|
||||
**`aksl_strncpy`, not `strncpy`.** Every name in this library is a registry key, handed to
|
||||
`strcmp`, `realpath` and SDL property calls, none of which stop at the end of the field.
|
||||
`strncpy(dest, src, sizeof(dest))` leaves `dest` unterminated whenever the source fills it.
|
||||
Bound `n` at `sizeof(dest) - 1` and let `aksl_strncpy` raise `AKERR_OUTOFBOUNDS` if the bytes
|
||||
do not fit.
|
||||
|
||||
**`FINISH_NORETURN`, not `FINISH`, in `main`.** `FINISH` ends by returning the error context
|
||||
from the enclosing function, and `main` returns `int`. With `FINISH_NORETURN` and no
|
||||
`HANDLE` block, an unhandled error goes to libakerror's default handler, which logs it and
|
||||
calls `akerr_exit()` — so `SDL_Quit()` and `return 0` are reached only on the success path.
|
||||
|
||||
**Never `exit(3)` an akerr status.** An exit status is one byte wide and libakgl's band
|
||||
starts at 256, so `exit(AKGL_ERR_SDL)` is a wait status of 0 and the shell sees a clean run.
|
||||
`akerr_exit()` maps 0 to 0, 1–255 to themselves, and anything else to
|
||||
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). See [Chapter 4](04-errors.md), Table 3.
|
||||
|
||||
**`akgl_text_unloadallfonts()` before `SDL_Quit()`.** `SDL_Quit` destroys the property
|
||||
registry the fonts live in and takes the last reference to every one of them with it. This
|
||||
program loads no fonts, and the call is in the shutdown anyway because that is where it
|
||||
belongs the moment one is loaded — [Chapter 16](16-text-and-fonts.md).
|
||||
|
||||
**Sixty `akgl_game_update` calls log `Low FPS! 0` sixty times.** `akgl_game.fps` is a
|
||||
completed-second average, so it reads 0 for the first second of the process, which is under
|
||||
the low-FPS threshold. `akgl_game.lowfpsfunc` is a hook for exactly this: point it at
|
||||
something that sheds work, or at nothing.
|
||||
|
||||
## Running headless
|
||||
|
||||
On a server, in CI, or under valgrind, force the dummy drivers:
|
||||
|
||||
```sh norun
|
||||
SDL_VIDEO_DRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIO_DRIVER=dummy ./mygame
|
||||
```
|
||||
|
||||
These are the SDL3 spellings, and the ones `scripts/memcheck.sh` exports. SDL3 also still
|
||||
honours SDL2's `SDL_VIDEODRIVER` and `SDL_AUDIODRIVER` as aliases — `SDL_RENDER_DRIVER` was
|
||||
never spelled any other way. Without them, `akgl_game_init` fails at `SDL_Init` with
|
||||
`AKGL_ERR_SDL` and the message `Couldn't initialize SDL: No available video device`.
|
||||
|
||||
A program can set the same thing from inside itself with `SDL_SetHint` before
|
||||
`akgl_game_init`, which is what most of the test suites in `tests/` do:
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
void go_headless(void)
|
||||
{
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
}
|
||||
```
|
||||
|
||||
The software renderer is a real rasterizer, not a stub — everything draws, nothing is
|
||||
displayed, and `SDL_RenderReadPixels` gives you the frame back. That is how the image
|
||||
assertions in `tests/` work.
|
||||
|
||||
## Where to go next
|
||||
|
||||
[Chapter 5](05-the-heap.md) for the pools you just claimed a string from,
|
||||
[Chapter 7](07-the-game-and-the-frame.md) for what `akgl_game_update` actually does, or
|
||||
[Chapter 19](19-tutorial-sidescroller.md) to start building a game.
|
||||
269
docs/04-errors.md
Normal file
269
docs/04-errors.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 04. Errors and status codes
|
||||
|
||||
Every one of libakgl's exported functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.
|
||||
You cannot read a single example in this manual until you can read that return value, which
|
||||
is why this chapter comes before any subsystem.
|
||||
|
||||
## The protocol is libakerror's
|
||||
|
||||
`ATTEMPT` / `CLEANUP` / `PROCESS` / `HANDLE` / `HANDLE_GROUP` / `FINISH`, and the `PASS`,
|
||||
`CATCH`, `IGNORE`, `FAIL_*` and `SUCCEED_RETURN` macros, all belong to **libakerror**.
|
||||
They are documented by the project that owns them, in `deps/libakerror/README.md` —
|
||||
"Library Architecture", "Lifecycle of an error in the AKError library", and "Exit status".
|
||||
This manual does not restate them, because a copy here would be wrong the day libakerror
|
||||
changes and nothing in this repository would notice.
|
||||
|
||||
What is genuinely libakgl's, and is written down nowhere else, is **which statuses these
|
||||
156 functions raise and what each one means here**. That is the three tables below.
|
||||
|
||||
The house rules for *writing* code against the protocol — never a `*_RETURN` inside an
|
||||
`ATTEMPT`, never a bare `return` out of a `HANDLE`, `CLEANUP` before `PROCESS`, an
|
||||
`ATTEMPT` inside the loop rather than a `CATCH` inside one — are in `AGENTS.md` under
|
||||
"Error-Handling Protocol", and two of them are enforced by the `error_protocol` test.
|
||||
|
||||
## Table 1 — libakgl's own status codes
|
||||
|
||||
libakerror reserves statuses 0–255 for the host's `errno` values and its own `AKERR_*`
|
||||
codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**.
|
||||
libakgl claims a band of five starting there, under the owner string `"libakgl"`:
|
||||
|
||||
```c excerpt=include/akgl/error.h
|
||||
#define AKGL_ERR_OWNER "libakgl"
|
||||
#define AKGL_ERR_BASE AKERR_FIRST_CONSUMER_STATUS
|
||||
|
||||
#define AKGL_ERR_SDL (AKGL_ERR_BASE + 0) /**< An SDL call failed; the message carries SDL_GetError() */
|
||||
#define AKGL_ERR_REGISTRY (AKGL_ERR_BASE + 1) /**< A registry property or lookup operation failed */
|
||||
#define AKGL_ERR_HEAP (AKGL_ERR_BASE + 2) /**< A heap pool has no free object left to hand out */
|
||||
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
|
||||
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
|
||||
```
|
||||
|
||||
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 5`, one past the last
|
||||
one, and exists only so `AKGL_ERR_COUNT` can be computed from it. Nothing in `src/` raises
|
||||
it and `akgl_error_init` does not name it. Do not write a `HANDLE(e, AKGL_ERR_LIMIT)`
|
||||
arm — it would catch nothing, and if a sixth real code is ever added it would silently
|
||||
start catching that instead.
|
||||
|
||||
| Code | Value | Means | Raised by | What the caller does |
|
||||
|---|---|---|---|---|
|
||||
| `AKGL_ERR_SDL` | 256 | An SDL call failed; the message carries `SDL_GetError()` | Around forty sites — anything touching a window, texture, renderer, mixer or the state mutex | Usually fatal at startup. Check the driver and the asset path |
|
||||
| `AKGL_ERR_REGISTRY` | 257 | A name lookup against a registry failed | **One site only**: `akgl_controller_default`, when `actorname` is not in `AKGL_REGISTRY_ACTOR` | Bind the control map *after* the actor is initialized |
|
||||
| `AKGL_ERR_HEAP` | 258 | A pool has no free slot | every `akgl_heap_next_*` | **Normally a missing release, not a small pool.** See [Chapter 5](05-the-heap.md) before raising `AKGL_MAX_HEAP_*` |
|
||||
| `AKGL_ERR_BEHAVIOR` | 259 | A component did not behave the way its contract requires | **Nothing in the library.** Only `tests/` raises it, through `testutil.h` | Available to you for the same purpose: asserting a contract in your own code |
|
||||
| `AKGL_ERR_LOGICINTERRUPT` | 260 | **Not a failure — a control signal.** "Skip the rest of this step for this actor" | your own `movementlogicfunc` | Raise it deliberately. See the note below |
|
||||
|
||||
`akgl_error_init` reserves the band all-or-nothing and registers a name for each of the
|
||||
five: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
|
||||
`"Logic Interrupt"`. Those names are what a stack trace prints.
|
||||
|
||||
Two rows deserve more than a cell.
|
||||
|
||||
**`AKGL_ERR_REGISTRY` is rarer than its name suggests.** The obvious candidates do not
|
||||
raise it. `akgl_actor_initialize` raises `AKERR_KEY` when the registry write fails, and
|
||||
`akgl_actor_set_character` raises `AKERR_NULLPOINTER` when the named character is not in
|
||||
`AKGL_REGISTRY_CHARACTER`. If you are writing a `HANDLE` arm for "that name was not
|
||||
registered", `AKERR_NULLPOINTER` and `AKERR_KEY` are the statuses you will actually see.
|
||||
|
||||
**`AKGL_ERR_LOGICINTERRUPT` only works from one place.** `akgl_physics_simulate` wraps each
|
||||
actor's step in an `ATTEMPT` and calls `actor->movementlogicfunc` through `CATCH`, with a
|
||||
`HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT)` arm that does nothing — so a raise from your
|
||||
movement logic skips that actor's gravity, drag and move for this step and the loop carries
|
||||
on to the next actor. The backend's own `gravity` and `move` are called through `PASS` in
|
||||
the same block, and `PASS` returns out of the function. **A backend hook that raises
|
||||
`AKGL_ERR_LOGICINTERRUPT` aborts the whole physics step**, leaving every remaining actor
|
||||
unsimulated and `gravity_time` unadvanced. Raise it from `movementlogicfunc` and nowhere
|
||||
else.
|
||||
|
||||
## Table 2 — libakerror statuses libakgl raises, and what they mean here
|
||||
|
||||
The statuses are libakerror's; their libakgl meaning is not. This table was built by
|
||||
grepping every `FAIL_*` and `HANDLE*` site in `src/`, not by reading header prose.
|
||||
|
||||
| Status | What it means when a libakgl function raises it |
|
||||
|---|---|
|
||||
| `AKERR_NULLPOINTER` | By a wide margin the most common — roughly six of every seven raise sites in `src/`. A required pointer argument was `NULL` — *or* a required field was empty (`akgl_game.name`/`.version`/`.uri`), *or* a name was looked up and not found. `akgl_actor_set_character` and `akgl_get_property` both use it for "absent", and `registry_create` uses it for "SDL could not allocate the property set" |
|
||||
| `AKERR_KEY` | **A key is absent, or a registry write was refused.** A missing JSON object member (every `akgl_get_json_*_value`), a character with no sprite for a state, a physics backend name that is neither `null` nor `arcade`, a failed `SDL_SetPointerProperty` in `akgl_actor_initialize`. Frequently a `HANDLE` arm rather than a failure |
|
||||
| `AKERR_TYPE` | **A JSON value was present but the wrong type.** All of `src/json_helpers.c` and `akgl_get_json_tilemap_property`. This is the status that separates "you did not write that key" (`AKERR_KEY`) from "you wrote it as a string and it wants a number" |
|
||||
| `AKERR_OUTOFBOUNDS` | A value did not fit its destination, or an index ran off the end. `aksl_strncpy` truncation, an array index past the end of a JSON array (`akgl_get_json_array_index_*`), a ninth child on an actor, a tile past `AKGL_TILEMAP_MAX_*`, a control map id out of range |
|
||||
| `AKERR_VALUE` | A value parsed but was not usable: a version string that is not semver (`akgl_game_load_versioncmp`), two surfaces that differ (`akgl_compare_sdl_surfaces`), a zero-sized destination or an overlapping copy from the `aksl_` wrappers |
|
||||
| `AKERR_RELATIONSHIP` | **One site**: `akgl_actor_add_child`, when the child already has a parent. Detach it first |
|
||||
| `AKERR_API` | Two distinct meanings, so read the message. (1) **The function is not implemented** — `akgl_physics_arcade_collide` and `akgl_render_2d_draw_mesh` both `FAIL_RETURN(..., AKERR_API, "Not implemented")`, and both are reached by ordinary-looking calls. (2) **A savegame does not match this build** — `akgl_game_load` and `akgl_game_load_versioncmp` |
|
||||
| `AKERR_IO` | A read or write failed, or a savegame had trailing data after its name tables. Distinct from `AKERR_EOF` — that separation is the whole reason `aksl_fgetc` exists |
|
||||
| `AKERR_EOF` | End of input. Sometimes the *desired* outcome: `require_at_eof` in `src/game.c` handles it and treats anything else as a failure |
|
||||
| `AKERR_INDEX` | **libakgl never raises this.** It appears once, as a `HANDLE_GROUP` arm in `akgl_get_json_with_default`, so that a caller whose *own* code raises it still gets a default. An out-of-range JSON array index arrives as `AKERR_OUTOFBOUNDS` |
|
||||
|
||||
Statuses raised by the libraries underneath also reach you unchanged. `aksl_fopen` reports
|
||||
`ENOENT` and `EACCES` as themselves; `aksl_fclose` reports `ENOSPC` and `EDQUOT`.
|
||||
`akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`,
|
||||
`AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`.
|
||||
|
||||
[Chapter 21](21-appendix-limits.md) has the per-function cross-reference.
|
||||
|
||||
## Table 3 — the exit status trap
|
||||
|
||||
This is arithmetic, and it is silent.
|
||||
|
||||
| You write | Wait status the shell sees | Why |
|
||||
|---|---|---|
|
||||
| `exit(AKGL_ERR_SDL)` | **0 — a clean run** | An exit status is one byte. `AKGL_ERR_SDL` is 256, and 256 & 0xFF is 0 |
|
||||
| `akerr_exit(status)` | 0 → 0, 1–255 → itself, anything else → 125 | 125 is `AKERR_EXIT_STATUS_UNREPRESENTABLE` |
|
||||
|
||||
Every suite in `tests/` once reported success on the single most common failure a library
|
||||
built on SDL can have — a renderer that would not come up. `tests/character.c` aborted at
|
||||
its second of four tests and was green until 0.5.0.
|
||||
|
||||
**You do not normally need to do anything about this.** libakerror's default unhandled-error
|
||||
handler already calls `akerr_exit()`, so a `main` that ends in `FINISH_NORETURN` exits
|
||||
correctly. The trap is only live if you replace libakerror's
|
||||
`akerr_handler_unhandled_error` pointer with your own handler, or call `exit(3)` on a
|
||||
status yourself. If you do either, call `akerr_exit()`.
|
||||
|
||||
## Four traps that are libakgl's, not libakerror's
|
||||
|
||||
### `akgl_error_init()` must run before anything that can raise
|
||||
|
||||
`akgl_error_init` claims the status band *and* registers the five names. A code raised
|
||||
before it runs has no name in the registry, so every stack trace carrying it prints
|
||||
**"Unknown Error"** instead of "Heap Error" — and the status number, 258, means nothing to
|
||||
a reader.
|
||||
|
||||
`akgl_game_init` calls it first, before anything else that can fail — that is why the
|
||||
mutex creation and the `akgl_game.name` checks sit *after* it. A host with its own startup
|
||||
path, such as one binding an existing renderer with `akgl_render_2d_bind` instead of
|
||||
calling `akgl_game_init`, must call `akgl_error_init` itself. Repeat calls are a no-op, so
|
||||
a program that cannot order its initialization precisely may simply call it more than once.
|
||||
|
||||
### The SDL callbacks end in `FINISH_NORETURN`, and that exits the process
|
||||
|
||||
`SDL_EnumerateProperties` takes a `void`-returning callback, so a failure inside one has
|
||||
nowhere to go. All six of libakgl's therefore end in `FINISH_NORETURN`, which logs the
|
||||
trace and hands the context to libakerror's unhandled-error handler — **and the default
|
||||
handler exits the process.** They are `akgl_registry_iterate_actor` (`src/actor.c`),
|
||||
`akgl_character_state_sprites_iterate` (`src/character.c`), and the four savegame name
|
||||
iterators in `src/game.c`.
|
||||
|
||||
A reader meets this the first time a sprite name is wrong: an actor's `renderfunc` raises
|
||||
`AKERR_KEY`, the callback cannot return it, and the game exits mid-frame with a stack trace
|
||||
instead of returning an error you could have handled.
|
||||
|
||||
Two consequences worth planning for:
|
||||
|
||||
- A write error part-way through `akgl_game_save` terminates the game rather than reaching
|
||||
`akgl_game_save`'s return value. `game.h` says so on `akgl_game_save_actors`.
|
||||
- If you want a different outcome, point libakerror's `akerr_handler_unhandled_error`
|
||||
function pointer at your own handler — and call `akerr_exit()` from it, per Table 3.
|
||||
|
||||
Note that `akgl_game_update` does **not** go through the callback. It walks
|
||||
`akgl_heap_actors` directly and propagates through `PASS`, so an error from an actor's
|
||||
`updatefunc` does reach you. The callback path is the render sweep and the savegame writer.
|
||||
|
||||
### `akgl_get_json_with_default` hands back the context you gave it
|
||||
|
||||
This is the one libakgl function whose ownership rule you have to know before you write
|
||||
your first loader. It takes an incoming `akerr_ErrorContext *`, and when it does not handle
|
||||
that status it returns **the same pointer**, because its `FINISH(e, true)` passes `e` up.
|
||||
|
||||
So the context has exactly one owner at a time, and it is never both yours and its. A
|
||||
`CLEANUP` block that releases your original pointer *as well as* releasing what the call
|
||||
returned releases the same slot twice, and a double-released context corrupts the failure
|
||||
instead of reporting it. That is not hypothetical: it is how the first draft of the
|
||||
`AKERR_OUTOFBOUNDS` test in `tests/json_helpers.c` passed against the unfixed library.
|
||||
`AGENTS.md` documents the shape under "Testing Guidelines"; `TODO.md` item 18 records the
|
||||
history.
|
||||
|
||||
The correct shape: take the result into a local, `NULL` your own pointer immediately, and
|
||||
decide from the local.
|
||||
|
||||
It defaults on three statuses — `AKERR_KEY`, `AKERR_OUTOFBOUNDS` and `AKERR_INDEX` — so
|
||||
both a missing object member and a short array give you the default. It does **not** default
|
||||
on `AKERR_TYPE`: a key that is present with the wrong type is a mistake in the document, not
|
||||
an absent value, and it propagates.
|
||||
|
||||
### libakerror 2.0.1 is a hard floor, checked at compile time
|
||||
|
||||
`include/akgl/error.h` carries two `#error` feature tests, because libakerror publishes no
|
||||
version macro and each probes the narrowest thing that a required release introduced:
|
||||
|
||||
- `AKERR_FIRST_CONSUMER_STATUS` — added in 1.0.0 with the private status registry. Without
|
||||
it, `AKGL_ERR_BASE` does not exist and the failure lands much later, inside `src/heap.c`.
|
||||
- `AKERR_EXIT_STATUS_UNREPRESENTABLE` — added in 2.0.1 with `akerr_exit()`.
|
||||
|
||||
Both failures are an *installed header* being stale, not a build-tree problem. 2.0.0 is an
|
||||
ABI break — `akerr_next_error()` returns a context that already holds its reference, and
|
||||
`__akerr_last_ignored` is thread-local — and both expand at libakgl's own call sites through
|
||||
`IGNORE` and the `FAIL_*` macros. The soname is `libakerror.so.2`, so the *libraries* cannot
|
||||
be mixed by accident; the header can. Rebuild and reinstall libakerror.
|
||||
|
||||
## One worked example
|
||||
|
||||
A loader reading an optional integer out of a JSON document. It is the smallest piece of
|
||||
real libakgl code that exercises both halves of the chapter: a status that means "absent
|
||||
and that is fine", and the ownership rule on the function that turns it into a default.
|
||||
|
||||
```c
|
||||
#include <jansson.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
|
||||
/* Read an optional integer out of a loaded document, defaulting to 4. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *read_speed(json_t *doc, int *dest)
|
||||
{
|
||||
akerr_ErrorContext *keyerr = NULL;
|
||||
akerr_ErrorContext *unhandled = NULL;
|
||||
int def = 4;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, doc, AKERR_NULLPOINTER, "NULL document");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
||||
|
||||
keyerr = akgl_get_json_integer_value(doc, "speed", dest);
|
||||
unhandled = akgl_get_json_with_default(keyerr, &def, dest, sizeof(int));
|
||||
keyerr = NULL;
|
||||
if ( unhandled != NULL ) {
|
||||
return unhandled;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
json_t *doc = NULL;
|
||||
int speed = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
doc = json_pack("{s:i}", "width", 32);
|
||||
CATCH(errctx, read_speed(doc, &speed));
|
||||
} CLEANUP {
|
||||
if ( doc != NULL ) {
|
||||
json_decref(doc);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Four things in there are libakgl-specific and worth naming:
|
||||
|
||||
1. **`akgl_error_init()` is the first call**, before anything that can raise.
|
||||
2. **`keyerr = NULL;` on the line after the call.** Ownership passed either way — handled
|
||||
or not — and the local is the only surviving reference. Nothing else in the function may
|
||||
touch `keyerr` again.
|
||||
3. **A `speed` key that is absent is not an error.** `akgl_get_json_integer_value` raises
|
||||
`AKERR_KEY`, `akgl_get_json_with_default` handles it and writes 4. A `speed` key that is
|
||||
present as a string *is* an error: `AKERR_TYPE` is not defaulted and comes back as
|
||||
`unhandled`.
|
||||
4. **`main` ends in `FINISH_NORETURN`.** That is what gets you `akerr_exit()` and a
|
||||
truthful exit status without writing the trap from Table 3.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 5](05-the-heap.md) — `AKGL_ERR_HEAP`, and why it is almost always a missing
|
||||
release.
|
||||
- [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and
|
||||
`AKERR_NULLPOINTER`.
|
||||
- [Chapter 21](21-appendix-limits.md) — which function raises what.
|
||||
- `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion.
|
||||
- The generated Doxygen reference — every function's own `@throws` list.
|
||||
347
docs/05-the-heap.md
Normal file
347
docs/05-the-heap.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# 05. The heap
|
||||
|
||||
libakgl does not call `malloc`. Every runtime object comes out of a fixed, statically
|
||||
allocated array declared in `include/akgl/heap.h`, and "allocation" is a linear scan for a
|
||||
slot whose `refcount` is zero.
|
||||
|
||||
**The consequence to design around is that exhaustion is an ordinary error, not an
|
||||
out-of-memory catastrophe.** `AKGL_ERR_HEAP` is a status you can handle. It is also,
|
||||
almost always, a missing release rather than a pool that is genuinely too small.
|
||||
|
||||
## The five pools
|
||||
|
||||
A "heap layer" is one array plus its `next`/`release` pair. There are five:
|
||||
|
||||
| Layer | Ceiling | Default | Element | Total |
|
||||
|---|---|---|---|---|
|
||||
| Actor | `AKGL_MAX_HEAP_ACTOR` | 64 | 400 B | 25 KiB |
|
||||
| Sprite | `AKGL_MAX_HEAP_SPRITE` | 1024 | 176 B | 176 KiB |
|
||||
| Spritesheet | `AKGL_MAX_HEAP_SPRITESHEET` | 1024 | 536 B | 536 KiB |
|
||||
| Character | `AKGL_MAX_HEAP_CHARACTER` | 256 | 184 B | 46 KiB |
|
||||
| String | `AKGL_MAX_HEAP_STRING` | 256 | 4100 B | 1.0 MiB |
|
||||
|
||||
The whole thing is about 1.8 MiB of BSS, and **the string pool is more than half of it**
|
||||
because every `akgl_String` is `PATH_MAX` bytes whether it holds a path or the four
|
||||
characters `"0.0"`.
|
||||
|
||||
The ceilings are all `#ifndef`-guarded:
|
||||
|
||||
```c excerpt=include/akgl/heap.h
|
||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
||||
#define AKGL_MAX_HEAP_ACTOR 64
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_SPRITE
|
||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_SPRITESHEET
|
||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_CHARACTER
|
||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_STRING
|
||||
#define AKGL_MAX_HEAP_STRING 256
|
||||
#endif
|
||||
```
|
||||
|
||||
The sprite pool is sixteen per actor, on the assumption of one sprite per state
|
||||
combination. The spritesheet pool is sized to match, though sharing by resolved path means
|
||||
far fewer are used in practice.
|
||||
|
||||
The five arrays are `extern` and public — `akgl_heap_actors`, `akgl_heap_sprites`,
|
||||
`akgl_heap_spritesheets`, `akgl_heap_characters`, `akgl_heap_strings` — so the render and
|
||||
physics sweeps can walk them directly instead of going through a registry.
|
||||
`akgl_game_update` does exactly that.
|
||||
|
||||
### The ceilings are a compile-time ABI constraint
|
||||
|
||||
This is the part that bites, and it does not announce itself.
|
||||
|
||||
The arrays are sized at compile time. Defining `AKGL_MAX_HEAP_ACTOR` to 256 before
|
||||
including `heap.h` changes the *declared* extent of `akgl_heap_actors` in your translation
|
||||
unit — but `src/heap.c` was compiled with whatever the library was built with, and that is
|
||||
where the storage actually lives. Mismatch the two and your code indexes past the end of an
|
||||
array that is still 64 entries long, in another object file, with no diagnostic anywhere.
|
||||
|
||||
```text
|
||||
your game.c libakgl.so
|
||||
------------------------ ------------------------
|
||||
#define AKGL_MAX_HEAP_ACTOR 256 (built with the default 64)
|
||||
#include <akgl/heap.h>
|
||||
akgl_Actor akgl_heap_actors[64];
|
||||
akgl_heap_actors[200] ------------------> off the end. No warning.
|
||||
```
|
||||
|
||||
**Raise a ceiling for the whole build or not at all.** With `add_subdirectory`, put it in a
|
||||
`target_compile_definitions` that reaches libakgl's own sources as well as yours. Against
|
||||
an installed `libakgl.so`, you cannot change it at all without rebuilding and reinstalling
|
||||
the library, and the soname does not encode it — so a rebuilt game against an old installed
|
||||
library is a silent overrun, not a link error.
|
||||
|
||||
## Claiming and releasing
|
||||
|
||||
Each layer has an acquire and a release:
|
||||
|
||||
| Layer | Acquire | Release |
|
||||
|---|---|---|
|
||||
| Actor | `akgl_heap_next_actor` | `akgl_heap_release_actor` |
|
||||
| Sprite | `akgl_heap_next_sprite` | `akgl_heap_release_sprite` |
|
||||
| Spritesheet | `akgl_heap_next_spritesheet` | `akgl_heap_release_spritesheet` |
|
||||
| Character | `akgl_heap_next_character` | `akgl_heap_release_character` |
|
||||
| String | `akgl_heap_next_string` | `akgl_heap_release_string` |
|
||||
|
||||
Every acquire raises `AKGL_ERR_HEAP` and nothing else. Every release raises
|
||||
`AKERR_NULLPOINTER` and nothing else. Full signatures are in the Doxygen reference; the
|
||||
behaviour worth knowing is below.
|
||||
|
||||
**None of the `next_*` functions checks `dest`.** A `NULL` there is a crash, not an error
|
||||
context. That is deliberate — the check would cost a branch on the allocation path — but it
|
||||
means these are the functions where you validate your own argument first.
|
||||
|
||||
**None of them zeroes the slot either.** What you get back is whatever the previous holder
|
||||
left. The `*_initialize` functions `memset` before they do anything else, which is why the
|
||||
normal shape is acquire-then-initialize and never acquire-then-use.
|
||||
|
||||
### The refcount asymmetry
|
||||
|
||||
This is the one thing about the heap that will surprise you, and it is a known defect
|
||||
rather than a design (`TODO.md`, "Known and still open" item 8; also the `@warning` on
|
||||
`heap.h` itself).
|
||||
|
||||
| Acquire | Takes the reference? | Who takes it |
|
||||
|---|---|---|
|
||||
| `akgl_heap_next_string` | **Yes** — `refcount` is 1 on return | the acquire itself |
|
||||
| `akgl_heap_next_actor` | No | `akgl_actor_initialize` |
|
||||
| `akgl_heap_next_sprite` | No | `akgl_sprite_initialize` |
|
||||
| `akgl_heap_next_spritesheet` | No | `akgl_spritesheet_initialize` |
|
||||
| `akgl_heap_next_character` | No | `akgl_character_initialize` |
|
||||
|
||||
**Until a reference is taken, the slot is still free and the next acquire hands out the
|
||||
same pointer.** Four of the five acquires therefore leave a window in which two callers can
|
||||
be holding the same object, and the window closes only when `*_initialize` runs. In
|
||||
practice you never see it, because the four are always immediately followed by their
|
||||
initializer — which is exactly why the asymmetry has survived. Write them adjacent and
|
||||
nothing can get in between:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
/* Claim an actor slot and take the reference on it. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *spawn_actor(akgl_Actor **dest, char *name)
|
||||
{
|
||||
akgl_Actor *obj = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL name");
|
||||
|
||||
PASS(errctx, akgl_heap_next_actor(&obj));
|
||||
/* obj->refcount is still 0. The slot is not yours yet. */
|
||||
PASS(errctx, akgl_actor_initialize(obj, name));
|
||||
/* obj->refcount is 1 now, and the actor is in AKGL_REGISTRY_ACTOR. */
|
||||
*dest = obj;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
You do not have to take that on trust. This program is compiled, linked against the library
|
||||
and run by the test suite, and the output below it is what it actually prints:
|
||||
|
||||
```c run=akglapp
|
||||
akgl_Actor *first = NULL;
|
||||
akgl_Actor *second = NULL;
|
||||
akgl_String *str = NULL;
|
||||
|
||||
/* A string acquire takes the reference for you. */
|
||||
PASS(errctx, akgl_heap_next_string(&str));
|
||||
printf("string refcount after acquire: %d\n", str->refcount);
|
||||
|
||||
/* An actor acquire does not. */
|
||||
PASS(errctx, akgl_heap_next_actor(&first));
|
||||
printf("actor refcount after acquire: %d\n", first->refcount);
|
||||
|
||||
/* So a second acquire, before the initializer runs, finds the same free slot. */
|
||||
PASS(errctx, akgl_heap_next_actor(&second));
|
||||
printf("same slot handed out twice: %s\n", (first == second) ? "yes" : "no");
|
||||
|
||||
/* Initializing is what takes the reference and closes the window. */
|
||||
PASS(errctx, akgl_actor_initialize(first, "player"));
|
||||
printf("actor refcount after init: %d\n", first->refcount);
|
||||
|
||||
PASS(errctx, akgl_heap_next_actor(&second));
|
||||
printf("now a distinct slot: %s\n", (first == second) ? "no" : "yes");
|
||||
|
||||
PASS(errctx, akgl_heap_release_actor(first));
|
||||
PASS(errctx, akgl_heap_release_string(str));
|
||||
```
|
||||
|
||||
```output
|
||||
string refcount after acquire: 1
|
||||
actor refcount after acquire: 0
|
||||
same slot handed out twice: yes
|
||||
actor refcount after init: 1
|
||||
now a distinct slot: yes
|
||||
```
|
||||
|
||||
Note also that a failure between the two — `akgl_actor_initialize` rejecting the name, say
|
||||
— needs no cleanup, precisely because nothing was claimed. That is the one thing the
|
||||
asymmetry is good for.
|
||||
|
||||
The string layer is the opposite and is safe on its own, which is what makes the
|
||||
scratch-buffer idiom below work.
|
||||
|
||||
### What each release actually does
|
||||
|
||||
They all decrement, and they all do the teardown when the count reaches zero. What the
|
||||
teardown *is* differs, and two of them reach outside the pool:
|
||||
|
||||
- **`akgl_heap_release_actor`** releases every non-`NULL` child recursively, clears the
|
||||
actor's entry from `AKGL_REGISTRY_ACTOR`, and zeroes the slot. The recursion has **no
|
||||
cycle check**: an actor reachable from its own child list recurses until the stack runs
|
||||
out.
|
||||
- **`akgl_heap_release_sprite`** clears the `AKGL_REGISTRY_SPRITE` entry and zeroes the
|
||||
slot. **It does not release the spritesheet** — the sprite only borrowed it.
|
||||
- **`akgl_heap_release_spritesheet`** clears the registry entry, **destroys the
|
||||
`SDL_Texture`**, and zeroes the slot. Any sprite still pointing at the sheet is left with
|
||||
a dangling pointer, since nothing takes a reference on a sheet on a sprite's behalf.
|
||||
Destroying a texture is a main-thread operation in SDL, so this must be called from the
|
||||
thread that owns the renderer.
|
||||
- **`akgl_heap_release_character`** walks the state-to-sprite map with
|
||||
`akgl_character_state_sprites_iterate` and `AKGL_ITERATOR_OP_RELEASE`, giving back every
|
||||
reference `akgl_character_sprite_add` took, then destroys the `SDL_PropertiesID` holding
|
||||
the map, clears the registry entry, and zeroes the slot.
|
||||
- **`akgl_heap_release_string`** zeroes the character data. Strings are not registered
|
||||
anywhere, so that is all there is to it.
|
||||
|
||||
One shared hazard: **a slot whose `refcount` is already 0 is re-torn-down rather than
|
||||
rejected.** Harmless on a zeroed slot, destructive on a live one that was never registered.
|
||||
|
||||
`akgl_heap_release_string` is the exception to the library's usual `NULL`-is-a-no-op
|
||||
convention: a `NULL` here is `AKERR_NULLPOINTER`, which is why every `CLEANUP` block in the
|
||||
library wraps it in `IGNORE()`.
|
||||
|
||||
## `akgl_String`
|
||||
|
||||
A pooled string is a `refcount` and a `PATH_MAX` buffer:
|
||||
|
||||
```c excerpt=include/akgl/staticstring.h
|
||||
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
|
||||
typedef struct
|
||||
{
|
||||
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
|
||||
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
|
||||
} akgl_String;
|
||||
```
|
||||
|
||||
Capacity is fixed. `akgl_string_initialize` and `akgl_string_copy` **truncate rather than
|
||||
grow, and the truncation is silent** — they both always NUL-terminate, but they do not tell
|
||||
you that something was lost. If you need to know, measure first, or go through
|
||||
`aksl_strncpy` with the full destination size and let it raise `AKERR_OUTOFBOUNDS`.
|
||||
|
||||
Two details that catch people:
|
||||
|
||||
- **`akgl_heap_next_string` does not clean the buffer.** The contents are whatever the last
|
||||
holder left. Call `akgl_string_initialize(str, NULL)` if you need it clean — and note
|
||||
that `akgl_get_property` only copies the value and its terminator, leaving the rest of
|
||||
the buffer alone, so read the result as a C string rather than as
|
||||
`AKGL_MAX_STRING_LENGTH` bytes.
|
||||
- **Several functions claim one for you.** `akgl_get_property`,
|
||||
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` all claim a slot when
|
||||
`*dest` is `NULL`, and write in place when it is not. Either way **the caller releases
|
||||
it.**
|
||||
|
||||
The scratch-buffer idiom is claim, use, release unconditionally in `CLEANUP`:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/* The scratch-string idiom: claim, use, release unconditionally in CLEANUP. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *screen_width(int *dest)
|
||||
{
|
||||
akgl_String *value = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&value));
|
||||
CATCH(errctx, akgl_get_property("game.screenwidth", &value, "800"));
|
||||
CATCH(errctx, aksl_atoi(value->data, dest));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(value));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The `CLEANUP` block runs on every path out of the `ATTEMPT`, which is the whole reason the
|
||||
release goes there and not after the last `CATCH`. **Never use a `*_RETURN` macro inside
|
||||
that `ATTEMPT`** — it returns past `CLEANUP` and leaks the slot. That mistake has shipped
|
||||
in this library before.
|
||||
|
||||
## Reading `AKGL_ERR_HEAP`
|
||||
|
||||
When a pool is exhausted the message names the layer:
|
||||
|
||||
```text
|
||||
Unable to find unused string on the heap
|
||||
```
|
||||
|
||||
Before you raise the ceiling, look for the missing release. The realistic causes, in the
|
||||
order they actually happen:
|
||||
|
||||
1. **A string claimed and never released.** By far the most common: the pool is 256 slots
|
||||
and every `akgl_get_property` call can claim one. A per-frame read that forgets the
|
||||
release exhausts it in four seconds at 60 Hz.
|
||||
2. **A `*_RETURN` macro inside an `ATTEMPT`**, skipping the `CLEANUP` that held the
|
||||
release.
|
||||
3. **A `return` out of a `HANDLE` block.** That leaks a libakerror context slot rather than
|
||||
an akgl one, and shows up as `"Unable to pull an error context from the array!"` on the
|
||||
129th call — different pool, same shape of bug.
|
||||
4. **Characters reloaded per level without releasing the previous ones**, which exhausts
|
||||
the *sprite* pool rather than the character pool, since each character holds a reference
|
||||
per bound state.
|
||||
|
||||
Only after all four are ruled out is the ceiling actually too small — and a game that
|
||||
genuinely needs 256 actors should say so once, for the whole build, per the ABI note above.
|
||||
|
||||
## Initialization and reset
|
||||
|
||||
`akgl_heap_init` zeroes all five pools. `akgl_game_init` calls it for you, before any
|
||||
registry exists.
|
||||
|
||||
**Calling it again is a reset, not a refresh.** It does not release textures, clear
|
||||
registries, or consult reference counts, so every live object becomes a dangling pointer
|
||||
and every registry entry points at a zeroed slot.
|
||||
|
||||
`akgl_heap_init_actor` zeroes the actor pool only, which is what makes it usable between
|
||||
levels: the sprites, sheets and characters are the expensive ones because they own
|
||||
textures. Pair it with `akgl_registry_init_actor`, the one registry initializer that
|
||||
destroys the old property set before creating the replacement. See
|
||||
[Chapter 6](06-the-registry.md).
|
||||
|
||||
Both return `NULL` and have no failure path today — they are a series of `memset`s. Check
|
||||
them anyway; the signature exists so a layer that needs real setup has somewhere to report
|
||||
from.
|
||||
|
||||
## If you need a new kind of object
|
||||
|
||||
Add a layer here. Do not reach for the allocator: `AGENTS.md` makes that a standing rule,
|
||||
and a `malloc` in the middle of a frame is the failure mode the pools exist to remove.
|
||||
A layer is an array, an `AKGL_MAX_HEAP_*` ceiling, a `next`, and a `release` — five things,
|
||||
all visible in `src/heap.c`.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
|
||||
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
|
||||
- [Chapter 21](21-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
||||
228
docs/06-the-registry.md
Normal file
228
docs/06-the-registry.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 06. The registry
|
||||
|
||||
Nothing in this library is passed around by pointer where a name will do. An actor names
|
||||
the character it instantiates, a character names the sprites it draws, a sprite names the
|
||||
sheet it cuts frames from. All of it is resolved at load time through eight registries,
|
||||
and that is what lets the whole asset graph be described in JSON files that reference each
|
||||
other by name.
|
||||
|
||||
**The registries are SDL property sets, not akgl types.** They are `SDL_PropertiesID`
|
||||
values with external linkage, so you can enumerate one with `SDL_EnumerateProperties`,
|
||||
which is exactly what `akgl_registry_iterate_actor` and
|
||||
`akgl_character_state_sprites_iterate` do.
|
||||
|
||||
## The eight registries
|
||||
|
||||
| Registry | Key | Value | Written by | Cleared by |
|
||||
|---|---|---|---|---|
|
||||
| `AKGL_REGISTRY_ACTOR` | actor name | `akgl_Actor *` | `akgl_actor_initialize` | `akgl_heap_release_actor` at refcount 0 |
|
||||
| `AKGL_REGISTRY_SPRITE` | sprite name | `akgl_Sprite *` | `akgl_sprite_initialize` | `akgl_heap_release_sprite` at refcount 0 |
|
||||
| `AKGL_REGISTRY_SPRITESHEET` | **resolved image path** | `akgl_SpriteSheet *` | `akgl_spritesheet_initialize` | `akgl_heap_release_spritesheet` at refcount 0 |
|
||||
| `AKGL_REGISTRY_CHARACTER` | character name | `akgl_Character *` | `akgl_character_initialize` | `akgl_heap_release_character` at refcount 0 |
|
||||
| `AKGL_REGISTRY_ACTOR_STATE_STRINGS` | state name | its bit value, as a number | `akgl_registry_init_actor_state_strings` | never |
|
||||
| `AKGL_REGISTRY_FONT` | font name (yours) | `TTF_Font *` | `akgl_text_loadfont` | `akgl_text_unloadfont` |
|
||||
| `AKGL_REGISTRY_MUSIC` | music name | audio handle | **nothing** — created but never populated | — |
|
||||
| `AKGL_REGISTRY_PROPERTIES` | configuration key | string value | `akgl_set_property`, `akgl_registry_load_properties` | never |
|
||||
|
||||
Three of those rows are worth expanding.
|
||||
|
||||
**The spritesheet registry is keyed by path, not by name.** That is the sharing mechanism:
|
||||
two sprites cutting frames out of the same image find the same `akgl_SpriteSheet` and the
|
||||
same `SDL_Texture`, and the texture is loaded once.
|
||||
|
||||
**The actor-state-string registry exists so JSON can name a bit.**
|
||||
`akgl_registry_init_actor_state_strings` walks `AKGL_ACTOR_STATE_STRING_NAMES` and maps
|
||||
entry `i` to `1 << i`, which is what lets a character definition write
|
||||
`"AKGL_ACTOR_STATE_FACE_LEFT"` instead of `2`. Two entries in that name table disagree with
|
||||
`actor.h`: bits 11 and 12 are `UNDEFINED_11` and `UNDEFINED_12` rather than `MOVING_IN` and
|
||||
`MOVING_OUT`, **so those two states cannot be named from JSON at all** (`TODO.md` items
|
||||
24–26). The individual name registrations are not checked, either.
|
||||
|
||||
**The music registry is empty.** `akgl_registry_init_music` creates it and nothing in the
|
||||
library ever writes to it. It is there for you.
|
||||
|
||||
## What actually runs at startup
|
||||
|
||||
`registry.h` documents `akgl_registry_init` as creating "the seven asset registries", with
|
||||
a `@warning` that it does not create `AKGL_REGISTRY_PROPERTIES`. **Both halves of that are
|
||||
now wrong**, and the correction matters because the warning is what a reader would design
|
||||
around.
|
||||
|
||||
Read against `src/registry.c` and `src/game.c`, this is what is true:
|
||||
|
||||
- `akgl_registry_init` creates **eight**, including `AKGL_REGISTRY_PROPERTIES`. The
|
||||
properties call was added in 0.5.0 (`TODO.md`, "Known and still open" item 3, marked
|
||||
fixed). Its order is spritesheet, sprite, character, actor, actor-state-strings, font,
|
||||
music, properties.
|
||||
- **`akgl_game_init` never calls `akgl_registry_init`.** It calls the eight individual
|
||||
initializers itself, in a different order: actor, sprite, spritesheet, character, font,
|
||||
music, properties, actor-state-strings.
|
||||
|
||||
So there are two supported startup paths and they do not share code. If you call
|
||||
`akgl_game_init`, you have all eight and need do nothing. If you are building your own
|
||||
startup — an embedder binding an existing renderer, say — call `akgl_registry_init` and you
|
||||
also have all eight. What you must not do is call some subset by hand and assume the rest
|
||||
followed.
|
||||
|
||||
`akgl_registry_init_actor` is the one initializer that behaves differently in a way you
|
||||
would notice: since 0.5.0 every initializer destroys the previous property set before
|
||||
creating the replacement, so none of them leaks on a second call, but the *actor* one is
|
||||
the one meant to be called again — between levels, paired with `akgl_heap_init_actor`. Note
|
||||
that it destroys the **registry**, not the actors; releasing those is
|
||||
`akgl_heap_release_actor`'s job.
|
||||
|
||||
## The id-0 trap
|
||||
|
||||
Every `SDL_PropertiesID` in `src/registry.c` starts at 0, and SDL treats 0 as "no such
|
||||
property set". **Reads return the default and writes are refused.** An uninitialized
|
||||
registry therefore does not fail the way you would want it to.
|
||||
|
||||
How loud that failure is depends entirely on whether libakgl checks SDL's return value, and
|
||||
it does not check everywhere:
|
||||
|
||||
| Call | Behaviour against an id of 0 |
|
||||
|---|---|
|
||||
| `akgl_actor_initialize`, `akgl_sprite_initialize`, `akgl_spritesheet_initialize`, `akgl_character_initialize` | **Loud.** The `SDL_SetPointerProperty` result is checked; you get `AKERR_KEY`, "Unable to add … to registry" |
|
||||
| `akgl_actor_set_character` | **Loud.** `SDL_GetPointerProperty` returns `NULL` and the result is checked; you get `AKERR_NULLPOINTER` |
|
||||
| `akgl_set_property` | **Silent.** The `SDL_SetStringProperty` result is not checked. The value is discarded and the call returns success |
|
||||
| `akgl_registry_load_properties` | **Silent, and worse.** Same unchecked write, and it logs `Set property x = y` for every entry it dropped, then logs `Properties loaded` |
|
||||
| `akgl_get_property` | **Silent.** You get the default you passed, every time |
|
||||
| `akgl_registry_init_actor_state_strings` | **Silent.** The `SDL_SetNumberProperty` results are not checked |
|
||||
|
||||
The consequence is the one `registry.h` warns about, and it is real even though the
|
||||
attribution has moved: **a startup path that leaves `AKGL_REGISTRY_PROPERTIES` at 0 gives
|
||||
you a no-op `akgl_set_property` and an `akgl_get_property` that always hands back your
|
||||
default** — which in turn means `akgl_render_2d_init` builds a window from its fallback
|
||||
size and `akgl_physics_init_arcade` runs with no gravity, both reporting success.
|
||||
|
||||
If you are debugging configuration that appears not to apply, check
|
||||
`AKGL_REGISTRY_PROPERTIES != 0` before you check anything else.
|
||||
|
||||
## Truncated keys
|
||||
|
||||
Every name is copied into a fixed-width field with `aksl_strncpy` bounded to
|
||||
`sizeof(field) - 1`, which is the "**truncated, not rejected**" contract those headers
|
||||
document. The fields:
|
||||
|
||||
| Object | Field | Bytes |
|
||||
|---|---|---|
|
||||
| `akgl_Actor::name` | `AKGL_ACTOR_MAX_NAME_LENGTH` | 128 |
|
||||
| `akgl_Sprite::name` | `AKGL_SPRITE_MAX_NAME_LENGTH` | 128 |
|
||||
| `akgl_Character::name` | `AKGL_CHARACTER_MAX_NAME_LENGTH` | 128 |
|
||||
| `akgl_SpriteSheet::name` | `AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` | 512 |
|
||||
|
||||
**Two distinct names that agree on their first 127 bytes truncate to the same key**, and
|
||||
the second `SDL_SetPointerProperty` silently replaces the first. The objects are different;
|
||||
the registry cannot tell. That is recorded in `TODO.md`, "Truncated registry keys can
|
||||
collide", and it is not being fixed because the fix is a contract change — refusing an
|
||||
over-long name with `AKERR_OUTOFBOUNDS` — and every one of those headers currently promises
|
||||
the opposite.
|
||||
|
||||
There is a second half that the `TODO.md` entry understates. It says the truncated name *is*
|
||||
the registry key. That is true for sprites and spritesheets, which register under their own
|
||||
copied field. It is **not** true for actors and characters:
|
||||
|
||||
- `akgl_actor_initialize` and `akgl_character_initialize` register under the **caller's**
|
||||
string, untruncated.
|
||||
- `akgl_heap_release_actor` and `akgl_heap_release_character` clear under the object's
|
||||
**truncated** field.
|
||||
|
||||
For any name of 127 bytes or fewer those are the same string and nothing happens. Over that,
|
||||
the entry is written under one key and cleared under another, so **releasing the object
|
||||
leaves a live registry entry pointing at a zeroed pool slot** — and the next lookup of that
|
||||
name hands out a pointer to freed storage. Keep asset names short; 127 bytes is generous for
|
||||
something a human types into a JSON file.
|
||||
|
||||
## Configuration properties
|
||||
|
||||
`AKGL_REGISTRY_PROPERTIES` is a string-to-string store. **Everything is a string, including
|
||||
numbers** — `game.screenwidth` is `"800"`, not `800` — and whoever reads a property parses
|
||||
it.
|
||||
|
||||
These are every property the library itself reads, with the default it falls back to and
|
||||
where it is read:
|
||||
|
||||
| Property | Default | Read by | Meaning |
|
||||
|---|---|---|---|
|
||||
| `game.screenwidth` | `"0"` | `akgl_render_2d_init` | Window and camera width, in pixels |
|
||||
| `game.screenheight` | `"0"` | `akgl_render_2d_init` | Window and camera height, in pixels |
|
||||
| `physics.gravity.x` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on x, px/s² |
|
||||
| `physics.gravity.y` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on y, px/s². Positive is down |
|
||||
| `physics.gravity.z` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on z, px/s² |
|
||||
| `physics.drag.x` | `"0.0"` | `akgl_physics_init_arcade` | Fraction of environmental velocity shed per second on x |
|
||||
| `physics.drag.y` | `"0.0"` | `akgl_physics_init_arcade` | Same for y. **This is the only brake on falling** — there is no terminal velocity |
|
||||
| `physics.drag.z` | `"0.0"` | `akgl_physics_init_arcade` | Same for z |
|
||||
| `physics.max_timestep` | `"0.05"` | `akgl_physics_init_arcade` | Upper bound on a single simulation `dt`, in seconds |
|
||||
|
||||
That is the whole list. Two things follow from it:
|
||||
|
||||
**The screen size defaults to 0×0.** `akgl_render_2d_init` passes whatever it parsed
|
||||
straight to `SDL_CreateWindowAndRenderer`, so a program that never sets those two asks SDL
|
||||
for a zero-sized window. Set them before calling it.
|
||||
|
||||
**`physics.engine` is not a property.** `physics.h` says in two places that `akgl_game_init`
|
||||
passes a `physics.engine` property to `akgl_physics_factory`. It does not — `akgl_game_init`
|
||||
never calls the factory at all, and the string `physics.engine` does not appear anywhere in
|
||||
`src/`. Choosing a backend is an explicit call you make yourself, with a name of `"null"` or
|
||||
`"arcade"`; see [Chapter 14](14-physics.md). The header comment is a known-false claim left
|
||||
alone deliberately, since correcting it is a source change rather than a documentation one.
|
||||
|
||||
### Setting properties
|
||||
|
||||
Two ways in, and both must happen **after** `akgl_game_init` (which creates the registry)
|
||||
and **before** `akgl_render_2d_init` or `akgl_physics_init_arcade` (which read it).
|
||||
|
||||
`akgl_set_property(name, value)` sets one. SDL copies the value, so your buffer can go away
|
||||
afterwards.
|
||||
|
||||
`akgl_registry_load_properties(fname)` loads a JSON document. It expects a top-level
|
||||
`properties` object whose members are **all strings**:
|
||||
|
||||
```json kind=properties
|
||||
{
|
||||
"properties": {
|
||||
"game.screenwidth": "800",
|
||||
"game.screenheight": "600",
|
||||
"physics.gravity.y": "400.0",
|
||||
"physics.drag.y": "0.9",
|
||||
"physics.max_timestep": "0.05"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A member that is not a string is `AKERR_TYPE`; a document with no `properties` member is
|
||||
`AKERR_KEY`; a file that will not open or will not parse is `AKERR_NULLPOINTER` with
|
||||
jansson's line number and text in the message. Note that a failure part-way through leaks
|
||||
one string-pool slot, because the per-property `CLEANUP` block is empty — a malformed
|
||||
configuration file is worth failing on rather than retrying in a loop.
|
||||
|
||||
### Reading properties
|
||||
|
||||
`akgl_get_property(name, dest, def)` reads one into a pooled string. **Absence is not an
|
||||
error**: an unset property yields `def`, which is how every caller inside the library gets a
|
||||
working default without checking first.
|
||||
|
||||
Three details, all of which have caused a defect here before:
|
||||
|
||||
- **`*dest` decides who allocates.** `NULL` claims a slot from the string pool for you;
|
||||
non-`NULL` is written in place. Either way **you** release it with
|
||||
`akgl_heap_release_string`. Initialize your local to `NULL` on the first call.
|
||||
- **`def` is effectively required.** A `NULL` default on an unset property is
|
||||
`AKERR_NULLPOINTER`, not an empty result.
|
||||
- **Only the value and its terminator are copied.** The rest of the destination keeps
|
||||
whatever the previous holder left, so read the result as a C string and never as
|
||||
`AKGL_MAX_STRING_LENGTH` bytes. A value of `AKGL_MAX_STRING_LENGTH` bytes or more is
|
||||
`AKERR_OUTOFBOUNDS`; it will not fit an `akgl_String` with its terminator.
|
||||
|
||||
The full worked example — claim, read, parse, release in `CLEANUP` — is in
|
||||
[Chapter 5](05-the-heap.md), because the ownership rule is the string pool's rather than the
|
||||
registry's.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 5](05-the-heap.md) — the pools the registries point into, and who releases what.
|
||||
- [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created,
|
||||
and where configuration has to be in place by.
|
||||
- [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do.
|
||||
- [Chapter 21](21-appendix-limits.md) — the configuration table again, alongside every
|
||||
`AKGL_MAX_*`.
|
||||
370
docs/07-the-game-and-the-frame.md
Normal file
370
docs/07-the-game-and-the-frame.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# 07. The game and the frame
|
||||
|
||||
`game.h` is the top of the library. `akgl_game_init` brings up SDL, the pools, the
|
||||
registries and the audio and font engines; `akgl_game_update` is one frame — update every
|
||||
actor, step the physics, draw the world.
|
||||
|
||||
**There is exactly one of everything.** `akgl_renderer`, `akgl_physics`, `akgl_camera` and
|
||||
`akgl_gamemap` are globals pointing at `akgl_default_*` storage, so a program can swap in
|
||||
its own instance by reassigning the pointer without the rest of the library knowing. That
|
||||
is the whole extent of the indirection: there is no notion of two worlds at once.
|
||||
|
||||
## The startup order
|
||||
|
||||
`game.h` documents a five-step sequence, and it holds up against `src/game.c`:
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/* The startup order, in the order it has to happen. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *startup(void)
|
||||
{
|
||||
akgl_String *engine = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
/* 1. The three required fields. akgl_game_init refuses to run without them. */
|
||||
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
|
||||
"manual-demo", sizeof(akgl_game.name) - 1));
|
||||
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
|
||||
"1.0.0", sizeof(akgl_game.version) - 1));
|
||||
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
|
||||
"tech.starfort.manual-demo", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
/* 2. Bring the library up: errors, pools, registries, SDL, audio, fonts. */
|
||||
PASS(errctx, akgl_game_init());
|
||||
|
||||
/* 3. Configure, before anything reads the configuration. */
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", "800"));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", "600"));
|
||||
PASS(errctx, akgl_set_property("physics.gravity.y", "400.0"));
|
||||
|
||||
/* 4. The two subsystems that read it. This is where the window appears. */
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&engine));
|
||||
CATCH(errctx, akgl_string_initialize(engine, "arcade"));
|
||||
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(engine));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
/* 5. Load assets, then loop on akgl_game_update. */
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**The three required fields have no defaults.** `akgl_game.name`, `.version` and `.uri` are
|
||||
each checked for a non-zero length and each raises `AKERR_NULLPOINTER` if empty. All three
|
||||
are load-bearing: `.uri` becomes the window title, all three become SDL's app metadata, and
|
||||
`.name`, `.uri` and `.version` are compared on savegame load. `.version` must be a valid
|
||||
semver string, or `akgl_game_load` fails later rather than here.
|
||||
|
||||
**Steps 3 and 4 cannot be swapped.** `akgl_render_2d_init` and `akgl_physics_init_arcade`
|
||||
each read their configuration once, at init, and never look again. Setting
|
||||
`physics.gravity.y` after the factory call changes nothing. See
|
||||
[Chapter 6](06-the-registry.md).
|
||||
|
||||
### What `akgl_game_init` does, in order
|
||||
|
||||
Verified against `src/game.c`. The order matters in three places, marked:
|
||||
|
||||
1. **`akgl_error_init()` — first, before anything that can raise.** Everything below reports
|
||||
through `AKGL_ERR_*` codes, and a code raised before its name is registered prints as
|
||||
"Unknown Error". See [Chapter 4](04-errors.md).
|
||||
2. Stamp `akgl_game.libversion` from `AKGL_VERSION`.
|
||||
3. Seed the frame clock: `gameStartTime`, `lastIterTime` and `lastFPSTime` all get
|
||||
`SDL_GetTicksNS()`.
|
||||
4. Install `akgl_game_lowfps` as `lowfpsfunc`.
|
||||
5. Create the state mutex. Failure is `AKGL_ERR_SDL`.
|
||||
6. **Take the state lock.** Everything from here to the end runs holding it.
|
||||
7. Check `name`, `version` and `uri` are non-empty.
|
||||
8. `akgl_heap_init()` — zero all five pools.
|
||||
9. The eight registry initializers: actor, sprite, spritesheet, character, font, music,
|
||||
properties, actor-state-strings. **Note that `akgl_registry_init` is not called** —
|
||||
`akgl_game_init` calls the eight individually. See [Chapter 6](06-the-registry.md).
|
||||
10. `SDL_SetAppMetadata` from the three fields.
|
||||
11. Zero all `AKGL_MAX_CONTROL_MAPS` control maps.
|
||||
12. `SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD | SDL_INIT_AUDIO)`.
|
||||
13. Feed the bundled controller database to `SDL_AddGamepadMapping`, one entry at a time.
|
||||
A rejected entry is `AKGL_ERR_SDL` and aborts startup.
|
||||
14. `akgl_controller_open_gamepads()`.
|
||||
15. `MIX_Init()` and `MIX_CreateMixerDevice()` on the default playback device.
|
||||
16. `TTF_Init()`.
|
||||
17. Point `akgl_camera`, `akgl_renderer`, `akgl_physics` and `akgl_gamemap` at their default
|
||||
storage.
|
||||
18. Release the state lock.
|
||||
|
||||
**What it does not do: create the window, choose a physics backend, or load any
|
||||
configuration.** Those three read properties, so they belong to the caller, after step 3
|
||||
above.
|
||||
|
||||
**A failure anywhere from step 6 onward returns with the state mutex held.** `game.h` says
|
||||
so. On a single-threaded program that is survivable — SDL mutexes are recursive, so the
|
||||
same thread can take it again — but the mutex is never made available to another thread, so
|
||||
anything else that calls `akgl_game_state_lock` will spend a second retrying and then fail.
|
||||
A failed `akgl_game_init` is not something to retry; report it and exit.
|
||||
|
||||
## One frame
|
||||
|
||||
`akgl_game_update(opflags)` is the whole frame, and it is short enough to state exactly:
|
||||
|
||||
```text
|
||||
akgl_game_state_lock() <- retries, does not block
|
||||
akgl_game_update_fps() <- counts the frame, maybe fires lowfpsfunc
|
||||
for each slot in akgl_heap_actors:
|
||||
skip if refcount == 0
|
||||
skip if OP_LAYERMASK and actor->layer != opflags->layerid
|
||||
OP_TILEMAPSCALE ? akgl_tilemap_scale_actor(...) : actor->scale = 1.0
|
||||
actor->updatefunc(actor) <- exactly once per actor
|
||||
akgl_physics->simulate(akgl_physics, NULL)
|
||||
akgl_renderer->draw_world(akgl_renderer, NULL)
|
||||
akgl_game_state_unlock()
|
||||
```
|
||||
|
||||
Five things follow from that, and four of them are not obvious.
|
||||
|
||||
**The sweep walks the pool, not the registry.** It reads `akgl_heap_actors` directly and
|
||||
skips slots whose `refcount` is 0. That is why an actor released mid-frame simply stops
|
||||
being visited, and why the actor registry's contents do not affect the update pass at all.
|
||||
|
||||
**Each actor's `updatefunc` runs exactly once.** Until 0.5.0 this loop sat inside a walk
|
||||
over `AKGL_TILEMAP_MAX_LAYERS` that never compared an actor's `layer` to the layer being
|
||||
walked, so every actor updated sixteen times a frame — 70 µs of work to do 4.4 µs of it.
|
||||
Updating an actor is not a per-layer operation. **Drawing is**, and
|
||||
`akgl_render_2d_draw_world` walks the layers itself.
|
||||
|
||||
**`opflags` is not forwarded.** `simulate` and `draw_world` are both passed `NULL`, so they
|
||||
use their own defaults regardless of what you asked for. Setting
|
||||
`AKGL_ITERATOR_OP_LAYERMASK` restricts the *update* sweep and nothing else; the whole world
|
||||
still draws.
|
||||
|
||||
**A `NULL` `opflags` means `AKGL_ITERATOR_OP_UPDATE` alone** — every live actor once, no
|
||||
layer mask, and every actor's `scale` forced to 1.0 because `AKGL_ITERATOR_OP_TILEMAPSCALE`
|
||||
is not set. If your sprites are rendering at the wrong size, that flag is the first thing to
|
||||
check.
|
||||
|
||||
**A live actor's `updatefunc` is called without a `NULL` check.** A hand-built `akgl_Actor`
|
||||
that never went through `akgl_actor_initialize` crashes here rather than raising.
|
||||
|
||||
### There is no frame pacing
|
||||
|
||||
`akgl_game_update` does not sleep, does not wait for vertical blank, and does not ask SDL
|
||||
to. `akgl_render_2d_init` calls `SDL_CreateWindowAndRenderer` with no flags and never calls
|
||||
`SDL_SetRenderVSync`, so **the loop runs as fast as the machine will let it** and burns a
|
||||
core doing it.
|
||||
|
||||
That is a deliberate omission rather than an oversight — pacing policy belongs to the game —
|
||||
but it means the pacing is yours to add. Either call `SDL_SetRenderVSync(renderer, 1)` on
|
||||
`akgl_renderer->sdl_renderer` after `akgl_render_2d_init`, or measure the frame and
|
||||
`SDL_DelayNS` the remainder yourself. Physics is unaffected either way:
|
||||
`akgl_physics_simulate` computes its own `dt` from `SDL_GetTicksNS` and bounds it at
|
||||
`physics.max_timestep`.
|
||||
|
||||
### `akgl_game.fps` reads 0 for the first second
|
||||
|
||||
`akgl_game_update_fps` recomputes `fps` only when a **full second** has elapsed since the
|
||||
last recomputation, so it is a completed-second average and not an instantaneous figure. For
|
||||
the first second of the process it is 0.
|
||||
|
||||
0 is below the low-FPS threshold of 30. **So `lowfpsfunc` fires on every single frame until
|
||||
the first second is up** — several hundred `Low FPS! 0` lines from the default handler
|
||||
before the game has drawn anything worth measuring. That is expected, not a symptom.
|
||||
|
||||
If you replace `lowfpsfunc` with something that sheds work, guard it on `fps > 0` or it will
|
||||
shed work during startup, which is the worst moment for it. The hook is also what an
|
||||
embedder has to know about: `akgl_game_update_fps` installs the default if it finds the
|
||||
pointer `NULL`, because a host binding its own renderer with `akgl_render_2d_bind` never ran
|
||||
`akgl_game_init` and used to crash here on frame one.
|
||||
|
||||
A caller running its own loop rather than `akgl_game_update` has to call
|
||||
`akgl_game_update_fps` itself; nothing else does.
|
||||
|
||||
## The state lock
|
||||
|
||||
`akgl_game.state` is a single `int32_t` of application-defined flags. **The library never
|
||||
reads it.** The mutex guarding it, `akgl_game.statelock`, is the only thread-safety
|
||||
libakgl offers, and it protects the state flags — not the pools, not the registries, not
|
||||
the renderer.
|
||||
|
||||
`akgl_game_state_lock` **polls; it does not block.** It calls `SDL_TryLockMutex` every
|
||||
`AKGL_GAME_STATE_LOCK_RETRY_MS` (100 ms) until `AKGL_GAME_STATE_LOCK_BUDGET_MS` (1000 ms) is
|
||||
spent, then raises `AKGL_ERR_SDL`. The point is that a deadlock reports an error you can act
|
||||
on instead of hanging the process. `SDL_GetError()` is appended for whatever it is worth,
|
||||
but after a failed `SDL_TryLockMutex` it is usually stale or empty — contention is reported
|
||||
by the return value, not by an error string. **The status is the signal, not the text.**
|
||||
|
||||
Two behaviours to plan around:
|
||||
|
||||
**Every failure path in `akgl_game_update` returns holding the lock.** Every step from
|
||||
`akgl_game_state_lock` onward propagates with `PASS`, which returns immediately, so the
|
||||
`akgl_game_state_unlock` at the bottom is never reached. Because SDL mutexes are recursive
|
||||
the next frame on the same thread still gets the lock, so a single-threaded game does not
|
||||
deadlock — it accumulates one unmatched lock per failed frame, and the mutex is never
|
||||
released to any other thread again. **Treat a failed `akgl_game_update` as terminal.** If
|
||||
you genuinely must continue, unlock once yourself before the next frame — but only when you
|
||||
know the failure came from *inside* the frame rather than from the lock acquisition itself,
|
||||
because unlocking a mutex this thread does not hold is undefined behaviour in SDL.
|
||||
|
||||
**`akgl_game_state_lock` succeeds trivially before `akgl_game_init`.** `SDL_TryLockMutex`
|
||||
returns true when passed `NULL`, and `akgl_game.statelock` is `NULL` until step 5 of
|
||||
startup. It locks nothing and reports success.
|
||||
|
||||
`akgl_game_state_unlock` has no failure path and returns `NULL`. That includes the case
|
||||
that matters — unlocking a mutex this thread does not hold — which is undefined behaviour in
|
||||
SDL rather than an error here.
|
||||
|
||||
## Iterators are a work order
|
||||
|
||||
There is no iterator object, and **there is no `next()`**. Traversal is SDL's —
|
||||
`SDL_EnumerateProperties` over a registry — and `akgl_Iterator` is the `userdata` carried
|
||||
into each callback, saying *which* entries to touch and *what* to do to each one.
|
||||
|
||||
```c excerpt=include/akgl/iterator.h
|
||||
/** @brief Selects operations and an optional layer for actor traversal. */
|
||||
typedef struct {
|
||||
uint32_t flags; /**< Bitwise OR of the `AKGL_ITERATOR_OP_*` values below. */
|
||||
uint8_t layerid; /**< Layer to restrict the sweep to. Read only when #AKGL_ITERATOR_OP_LAYERMASK is set. */
|
||||
} akgl_Iterator;
|
||||
|
||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
||||
```
|
||||
|
||||
The operations are **independent bits, not an enum**: one sweep can update, scale and
|
||||
render, and they run in that fixed order regardless of the order the bits were set in. Bits
|
||||
5 through 31 are declared `AKGL_ITERATOR_OP_UNDEFINED_*` and are unused.
|
||||
|
||||
Passing `NULL` where an `akgl_Iterator *` is expected is fine at the top-level entry points
|
||||
— `akgl_game_update`, `akgl_physics_simulate` and `akgl_render_2d_draw_world` each
|
||||
substitute their own defaults — but it is `AKERR_NULLPOINTER` once inside a callback.
|
||||
|
||||
**`akgl_game_update` reads only two of the five bits**: `AKGL_ITERATOR_OP_LAYERMASK` and
|
||||
`AKGL_ITERATOR_OP_TILEMAPSCALE`. It calls `updatefunc` unconditionally rather than checking
|
||||
`AKGL_ITERATOR_OP_UPDATE`, because updating every live actor once is the whole job. The
|
||||
callback `akgl_registry_iterate_actor` does check all of them, and that is the one the
|
||||
render sweep goes through.
|
||||
|
||||
## The main loop
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/iterator.h>
|
||||
|
||||
/* A frame loop. A failed akgl_game_update is terminal: it returns holding the
|
||||
* state lock, and FINISH_NORETURN is what turns that into a stack trace and a
|
||||
* truthful exit status instead of a second frame on a poisoned mutex. */
|
||||
int main(void)
|
||||
{
|
||||
akgl_Iterator work = {
|
||||
.flags = (AKGL_ITERATOR_OP_UPDATE | AKGL_ITERATOR_OP_TILEMAPSCALE),
|
||||
.layerid = 0
|
||||
};
|
||||
SDL_Event event;
|
||||
bool running = true;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
while ( running == true ) {
|
||||
while ( SDL_PollEvent(&event) == true ) {
|
||||
if ( event.type == SDL_EVENT_QUIT ) {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
CATCH(errctx, akgl_game_update(&work));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The `CATCH` inside the `while` is safe **only because the loop is the entire body of the
|
||||
`ATTEMPT`**. `CATCH` reports failure by `break`ing, and a `break` binds to the innermost
|
||||
enclosing loop, so it leaves the `while` and falls into `CLEANUP`/`PROCESS`/`FINISH` — which
|
||||
is what you want. Put anything after the loop and you have to account for reaching it after
|
||||
a failure. `AGENTS.md` spells the rule out, and `scripts/check_error_protocol.py` cannot see
|
||||
this one.
|
||||
|
||||
## There is no `akgl_game_shutdown`
|
||||
|
||||
**Teardown is entirely the caller's.** No function in this library undoes `akgl_game_init`.
|
||||
What there is, in the order it has to run:
|
||||
|
||||
| Step | Call | Why the order |
|
||||
|---|---|---|
|
||||
| 1 | `akgl_text_unloadfont` for each font you loaded | Must precede `TTF_Quit`. `text.h` documents the trap: `SDL_Quit` destroying the engine out from under a live `TTF_Font` is not a clean unload |
|
||||
| 2 | `akgl_heap_release_*` for anything you still hold | `akgl_heap_release_spritesheet` destroys `SDL_Texture`s and must run on the renderer's thread, before the renderer goes |
|
||||
| 3 | `akgl_audio_shutdown()` | Puts the synthesizer voices back |
|
||||
| 4 | `akgl_renderer->shutdown(akgl_renderer)` | `akgl_render_2d_shutdown` |
|
||||
| 5 | `TTF_Quit()`, `MIX_Quit()`, `SDL_Quit()` | SDL's own |
|
||||
|
||||
A process that is exiting anyway can skip most of it — the OS reclaims the pools, and SDL
|
||||
reclaims what it owns — but a program that tears a *level* down and builds another one has
|
||||
to get steps 1 and 2 right or it exhausts the pools. `akgl_heap_init_actor` plus
|
||||
`akgl_registry_init_actor` is the cheap version of that for actors only; see
|
||||
[Chapter 5](05-the-heap.md).
|
||||
|
||||
## Savegames are partial
|
||||
|
||||
`akgl_game_save` and `akgl_game_load` exist and work, and **they do not yet save your game.**
|
||||
Read this before building anything on them.
|
||||
|
||||
What `akgl_game_save` writes:
|
||||
|
||||
1. The `akgl_Game` struct verbatim — metadata, timing, FPS accounting and the state flags.
|
||||
2. Four name tables — actors, sprites, spritesheets, characters — each mapping a registered
|
||||
name to **the address the object had at save time**, each terminated by a zeroed name
|
||||
field and a zeroed pointer.
|
||||
|
||||
What it does not write: **the objects themselves.** Not one actor, sprite, sheet or
|
||||
character. The name tables are the mechanism that would let a loader reconnect pointers
|
||||
between objects that will sit at different addresses next run; the objects those pointers
|
||||
would point at are not in the file. `akgl_game_load` correspondingly rebuilds the four
|
||||
old-address-to-current-object maps and stops there. `game.h` says so on both functions.
|
||||
|
||||
Three further things to know:
|
||||
|
||||
- **`akgl_game_load` refuses a save that does not match this build**, comparing the library
|
||||
version, the game version, the game name and the game URI. Versions are compared by
|
||||
**exact** semver equality, not compatibility, because the file is a raw memory image and
|
||||
semver has no way to say "the layout did not move". A mismatch is `AKERR_API`.
|
||||
- **A save containing any registered spritesheet cannot be read back.** The writer uses
|
||||
`AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) for that table and the reader used
|
||||
`AKGL_ACTOR_MAX_NAME_LENGTH` (128) for all four. That is fixed as of 0.5.0 (`TODO.md`,
|
||||
"Known and still open" item 7) — and what catches it is the `require_at_eof` check, which
|
||||
turns a field-width disagreement into `AKERR_IO` instead of a silent load with wrong maps.
|
||||
A reader whose widths disagree finds a run of zeros mid-entry, stops early, and would
|
||||
otherwise report success.
|
||||
- **`akgl_game_load` leaks four `SDL_PropertiesID` per call.** The maps it builds are never
|
||||
destroyed.
|
||||
|
||||
A write error part-way through the name tables **terminates the process** rather than
|
||||
returning. The entries go through `SDL_EnumerateProperties`, whose callbacks cannot report
|
||||
failure upward and end in `FINISH_NORETURN`; only the terminators are written through the
|
||||
error-reporting path. See [Chapter 4](04-errors.md).
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 4](04-errors.md) — the statuses everything here raises.
|
||||
- [Chapter 6](06-the-registry.md) — the configuration step 3 above writes into.
|
||||
- [Chapter 8](08-rendering.md) — `akgl_render_2d_init` versus `akgl_render_2d_bind`, and
|
||||
what `draw_world` does with the layers.
|
||||
- [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not
|
||||
pace.
|
||||
- [Chapter 21](21-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
||||
332
docs/08-rendering.md
Normal file
332
docs/08-rendering.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 08. Rendering
|
||||
|
||||
SDL3 already owns the renderer. `SDL_Renderer`, `SDL_Texture`, `SDL_FRect`, blend
|
||||
modes and the batching behaviour behind `SDL_RenderPresent` are documented in the
|
||||
[SDL3 wiki](https://wiki.libsdl.org/SDL3/CategoryRender), and this chapter does not
|
||||
restate any of it.
|
||||
|
||||
What libakgl adds is three things, and only three:
|
||||
|
||||
1. **A backend struct** — a record of function pointers, so a renderer can be
|
||||
replaced without a branch anywhere in the library.
|
||||
2. **A frame contract** — `frame_start`, then any number of `draw_*` calls, then
|
||||
`frame_end`.
|
||||
3. **A scene walk** — `draw_world`, which interleaves tilemap layers with the
|
||||
actors standing on them.
|
||||
|
||||
Per-function reference is generated from the headers by Doxygen and is CI-gated;
|
||||
this chapter teaches the shape and links there for signatures.
|
||||
|
||||
## The backend is a record of function pointers
|
||||
|
||||
There is no renderer base class, no vtable pointer, and no dynamic dispatch. There
|
||||
is a struct with an `SDL_Renderer *` and six function pointers on it:
|
||||
|
||||
```c excerpt=include/akgl/renderer.h
|
||||
typedef struct akgl_RenderBackend {
|
||||
SDL_Renderer *sdl_renderer;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*shutdown)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_start)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_end)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_texture)(struct akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_mesh)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_world)(struct akgl_RenderBackend *self, akgl_Iterator *opflags);
|
||||
} akgl_RenderBackend;
|
||||
```
|
||||
|
||||
A *backend* is therefore an initializer that fills those six slots in. One is
|
||||
shipped — the 2D SDL one, whose entry points are the `akgl_render_2d_*` functions —
|
||||
and a second renderer would be a second initializer pointing the same six slots at
|
||||
different functions. Nothing in the library tests "which renderer is this".
|
||||
|
||||
**Call through the pointers, not the names.** `akgl_render_2d_frame_start(b)` and
|
||||
`b->frame_start(b)` do the same thing today and stop doing the same thing the
|
||||
moment anybody swaps a backend in. Every call site inside libakgl goes through the
|
||||
pointer, and so should yours:
|
||||
|
||||
```c
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
akerr_ErrorContext *draw_one_frame(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
PASS(errctx, akgl_renderer->draw_world(akgl_renderer, NULL));
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_renderer` is the global the rest of the library draws through. It is a
|
||||
pointer into `akgl_default_renderer`, set by `akgl_game_init`; see
|
||||
[Chapter 7](07-the-game-and-the-frame.md). Reassigning `akgl_renderer` is how a
|
||||
program installs its own backend — there is exactly one world, so there is exactly
|
||||
one of these.
|
||||
|
||||
Every entry point returns `akerr_ErrorContext AKERR_NOIGNORE *`. Statuses and what
|
||||
they mean are [Chapter 4](04-errors.md); the protocol itself is libakerror's and is
|
||||
documented in `deps/libakerror`.
|
||||
|
||||
## The frame contract
|
||||
|
||||
```text
|
||||
frame_start(self) clear the target to opaque black
|
||||
|
|
||||
+--> draw_world(self, opflags) the tilemap and its actors
|
||||
+--> draw_texture(self, ...) one blit
|
||||
+--> akgl_draw_* (chapter 09) lines, boxes, circles, fills
|
||||
|
|
||||
frame_end(self) present
|
||||
```
|
||||
|
||||
`frame_start` sets the draw colour to opaque black and calls `SDL_RenderClear`
|
||||
itself. **You do not need to clear again.** The FAQ half of the old `README.md`
|
||||
showed this:
|
||||
|
||||
```c norun
|
||||
PASS(e, akgl_renderer->frame_start(akgl_renderer));
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
||||
```
|
||||
|
||||
The second line is redundant — it clears an already-cleared target, unchecked, and
|
||||
at whatever draw colour was left behind. Drop it. (`norun` because it is quoted as
|
||||
an example of what *not* to write; it compiles, and that is the problem.)
|
||||
|
||||
Both check `self` and then `self->sdl_renderer` before touching either, so a `NULL`
|
||||
backend and a backend that was bound but never given a renderer both report
|
||||
`AKERR_NULLPOINTER` rather than crashing. `renderer.h` says the opposite — its
|
||||
`@param self` for `frame_start`, `frame_end` and `draw_texture` reads "dereferenced
|
||||
*before* it is checked -- a `NULL` @p self is a crash, not an error". Read
|
||||
`src/renderer.c`: the first statement in each is a `FAIL_ZERO_RETURN` on `self`.
|
||||
|
||||
## The camera
|
||||
|
||||
`akgl_camera` is an `SDL_FRect *` in **map coordinates**: `x`/`y` are the top-left
|
||||
corner of the view into the world, `w`/`h` its size. It is a plain pointer into
|
||||
`akgl_default_camera`, and everything that draws world content subtracts it.
|
||||
|
||||
`akgl_render_2d_init` points it at the full screen rectangle at startup. Scrolling
|
||||
is just writing to it:
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* Centre the view on an actor, and do not scroll past the top-left of the map. */
|
||||
akerr_ErrorContext *camera_follow(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
akgl_camera->x = obj->x - (akgl_camera->w / 2.0f);
|
||||
akgl_camera->y = obj->y - (akgl_camera->h / 2.0f);
|
||||
if ( akgl_camera->x < 0.0f ) {
|
||||
akgl_camera->x = 0.0f;
|
||||
}
|
||||
if ( akgl_camera->y < 0.0f ) {
|
||||
akgl_camera->y = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
There is no camera object and no camera API — a rectangle is the whole model. A
|
||||
game that wants two views draws twice with the pointer moved between them.
|
||||
|
||||
## draw_world walks sixteen layers
|
||||
|
||||
`draw_world` is the one call that draws a scene rather than a thing. For each
|
||||
layer index `i` from 0 to `AKGL_TILEMAP_MAX_LAYERS` (16), it:
|
||||
|
||||
1. draws layer `i` of the global `akgl_gamemap` through `akgl_camera`, **if the map
|
||||
has that many layers**; then
|
||||
2. sweeps all `AKGL_MAX_HEAP_ACTOR` (64) actor pool slots and calls `renderfunc` on
|
||||
every live actor whose `layer` equals `i`.
|
||||
|
||||
```text
|
||||
layer 0 tilemap layer 0 ....... actors with layer == 0
|
||||
layer 1 tilemap layer 1 ....... actors with layer == 1
|
||||
layer 2 (map has only 2) actors with layer == 2
|
||||
... ...
|
||||
layer 15 -- actors with layer == 15
|
||||
```
|
||||
|
||||
That interleaving is the point: it is what puts an actor in front of the ground it
|
||||
stands on and behind the archway it walks under. An actor's `layer` field is set
|
||||
from the object layer it was placed in — see [Chapter 12](12-actors.md) and
|
||||
[Chapter 13](13-tilemaps.md).
|
||||
|
||||
The map layer is skipped once `i` reaches `akgl_gamemap->numlayers`, but the actor
|
||||
sweep is not: **an actor on layer 9 of a two-layer map still draws.** It just draws
|
||||
over bare background.
|
||||
|
||||
`draw_world` reads `akgl_gamemap`, `akgl_camera` and `akgl_heap_actors` directly
|
||||
rather than taking them as arguments. There is one world.
|
||||
|
||||
### opflags is accepted and ignored
|
||||
|
||||
The `akgl_Iterator *opflags` argument is part of the backend signature and is
|
||||
currently **ignored entirely**. `NULL` substitutes a zeroed iterator, and neither
|
||||
that nor a set of flags you built yourself reaches anything: `draw_world` sweeps
|
||||
the actor pool itself instead of going through `akgl_registry_iterate_actor`, so no
|
||||
`AKGL_ITERATOR_OP_*` bit — including `AKGL_ITERATOR_OP_LAYERMASK` — has any effect
|
||||
here. Pass `NULL`. When layer masking is wired up, the parameter is where it will
|
||||
land.
|
||||
|
||||
### Two things draw_world does not check
|
||||
|
||||
- **`akgl_gamemap` is dereferenced unguarded.** Calling `draw_world` before
|
||||
`akgl_tilemap_load` is a crash, not an error context.
|
||||
- **A live actor's `renderfunc` is called unguarded.** A hand-built actor that was
|
||||
never run through `akgl_actor_initialize` has a `NULL` there. Always initialize
|
||||
actors through the library.
|
||||
|
||||
The first failure aborts the frame and propagates unchanged; there is no
|
||||
draw-what-you-can behaviour.
|
||||
|
||||
> **Known defect (`TODO.md`, "Performance", item 6).** The layer loop always runs
|
||||
> all 16 iterations and rescans all 64 actor slots on each — 1024 refcount checks
|
||||
> per frame for a one-layer map. Invisible at 60 fps under the software renderer,
|
||||
> measurable on a 2 ms GPU frame. Bounding the walk by `numlayers` and building
|
||||
> per-layer actor lists in one pool pass is the recorded fix.
|
||||
|
||||
## The embedding seam: akgl_render_2d_bind vs akgl_render_2d_init
|
||||
|
||||
This is a first-class use case, not a footnote. Creating a window and populating a
|
||||
vtable are two separable jobs, and libakgl separates them.
|
||||
|
||||
| | `akgl_render_2d_init` | `akgl_render_2d_bind` |
|
||||
|---|---|---|
|
||||
| Reads configuration properties | yes — `game.screenwidth`, `game.screenheight` | no |
|
||||
| Creates a window and `SDL_Renderer` | yes | **no** |
|
||||
| Touches `self->sdl_renderer` | overwrites it | **never touches it** |
|
||||
| Points `akgl_camera` at the screen | yes | no |
|
||||
| Installs the six function pointers | yes, by calling `_bind` | yes |
|
||||
| Needs the registry initialized first | yes | no |
|
||||
|
||||
`akgl_render_2d_init` is the ordinary path for a program that owns its whole
|
||||
process. It reads `game.screenwidth` and `game.screenheight` from the property
|
||||
registry — both defaulting to the string `"0"`, which asks SDL for a zero-sized
|
||||
window — creates the window and renderer, sets `akgl_camera` to the full screen
|
||||
rectangle, and then calls `akgl_render_2d_bind` to fill in the vtable. Because it
|
||||
reads properties, `akgl_registry_init_properties` and your property writes have to
|
||||
happen first; see [Chapter 6](06-the-registry.md).
|
||||
|
||||
**The window title is `akgl_game.uri`.** Not `akgl_game.name` — the reverse-DNS
|
||||
identifier is what `SDL_CreateWindowAndRenderer` is handed. If your title bar reads
|
||||
`tech.starfort.mygame`, that is why.
|
||||
|
||||
`akgl_render_2d_bind` is the other half on its own. It writes six pointers and
|
||||
returns. It deliberately does not touch `sdl_renderer`, which is exactly what makes
|
||||
it usable by a host that already has one — an embedded interpreter, a level editor,
|
||||
an application with its own window that wants libakgl to draw inside it. This is
|
||||
what the sibling `akbasic` consumer uses.
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
/*
|
||||
* A host that already owns its window and SDL_Renderer. It never calls
|
||||
* akgl_render_2d_init, so libakgl never creates a second window.
|
||||
*/
|
||||
akerr_ErrorContext *host_bind_renderer(SDL_Renderer *mine, int w, int h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, mine, AKERR_NULLPOINTER, "mine");
|
||||
|
||||
/* _bind never writes sdl_renderer, so put yours there first. */
|
||||
akgl_default_renderer.sdl_renderer = mine;
|
||||
PASS(errctx, akgl_render_2d_bind(&akgl_default_renderer));
|
||||
|
||||
/* Everything else in the library draws through the global. */
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
/* _bind does not set the camera; _init would have. */
|
||||
akgl_camera = &akgl_default_camera;
|
||||
akgl_camera->x = 0.0f;
|
||||
akgl_camera->y = 0.0f;
|
||||
akgl_camera->w = (float)w;
|
||||
akgl_camera->h = (float)h;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
### Bind the global, not a backend of your own
|
||||
|
||||
This is the trap in the seam, and it is worth stating flatly:
|
||||
|
||||
**`draw_world` hands `self` to the tilemap and to each actor's `renderfunc`, but
|
||||
neither of them uses it.** `akgl_actor_render` and `akgl_tilemap_draw` both reach
|
||||
straight for the global `akgl_renderer`. So does `akgl_spritesheet_initialize`, and
|
||||
so does every texture load in the tilemap loader.
|
||||
|
||||
A host that binds a *private* `akgl_RenderBackend` and calls
|
||||
`mine.draw_world(&mine, NULL)` gets its tilemap and its actors drawn through
|
||||
whatever `akgl_renderer` points at — which, if `akgl_game_init` ran, is
|
||||
`akgl_default_renderer` with a `NULL` `sdl_renderer`. The failure is an
|
||||
`AKERR_NULLPOINTER` from deep inside a texture load, and it reads like a missing
|
||||
asset.
|
||||
|
||||
Set `akgl_renderer` to the backend you bound. The example above does.
|
||||
|
||||
A backend that has been bound but never given an `SDL_Renderer` refuses cleanly
|
||||
rather than crashing: every entry point checks `self->sdl_renderer` and reports
|
||||
`AKERR_NULLPOINTER`. That is the designed-for state and is what makes `_bind` safe
|
||||
to call before you have a renderer.
|
||||
|
||||
> **A header claim that is no longer true.** `renderer.h` ends
|
||||
> `akgl_render_2d_init`'s documentation with a `@note` saying the two pooled
|
||||
> strings holding the dimensions "are only released on the success path, so each
|
||||
> failed initialization leaks two string slots". They are released in a `CLEANUP`
|
||||
> block that runs on both paths, and `src/renderer.c` carries a comment saying
|
||||
> that is exactly why it was moved there. **There is no leak on the current
|
||||
> code.** Correcting the header comment is a separate commit; this chapter
|
||||
> documents what the code does.
|
||||
|
||||
## What the 2D backend refuses
|
||||
|
||||
**`draw_mesh` always fails.** Every call raises `AKERR_API` with the message "Not
|
||||
implemented". The hook exists so a 3D backend has a slot to fill. It is not a stub
|
||||
that quietly does nothing — a caller that reaches it finds out on the first call,
|
||||
which is the intent:
|
||||
|
||||
```c
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
akerr_ErrorContext *try_the_mesh_hook(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_renderer->draw_mesh(akgl_renderer));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_API) {
|
||||
/* The 2D backend has no geometry path. Draw it as textures instead. */
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**`akgl_render_2d_shutdown` is a documented no-op.** It validates `self` and
|
||||
succeeds. The window and `SDL_Renderer` are still SDL's and are reclaimed by
|
||||
`SDL_Quit`, so there is nothing here to release yet. Call it anyway — a backend
|
||||
that acquires anything will grow a failure path, and the hook is where it will go.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 09](09-drawing.md) — the immediate-mode primitives that draw through
|
||||
the same backend.
|
||||
- [Chapter 12](12-actors.md) — `renderfunc`, `layer`, and what `draw_world`
|
||||
actually calls.
|
||||
- [Chapter 13](13-tilemaps.md) — layers, and what `akgl_tilemap_draw` does with
|
||||
the camera.
|
||||
- [Chapter 07](07-the-game-and-the-frame.md) — where `frame_start` and `frame_end`
|
||||
sit inside `akgl_game_update`.
|
||||
332
docs/09-drawing.md
Normal file
332
docs/09-drawing.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 09. Drawing
|
||||
|
||||
`draw.h` is immediate-mode plotting: put this on the screen *now*, as opposed to
|
||||
add this to the scene. It sits alongside the actor and tilemap rendering in
|
||||
[Chapter 08](08-rendering.md) rather than replacing it — a game can draw a world
|
||||
through `draw_world` and then scribble a debug rectangle over it in the same frame.
|
||||
|
||||
The shape comes from a BASIC-style graphics vocabulary. `DRAW`, `BOX`, `CIRCLE`,
|
||||
`PAINT`, `SSHAPE` and `GSHAPE` all map onto one of these, which is why the set is
|
||||
what it is and why there is a flood fill in a game library at all.
|
||||
|
||||
| Function | Draws | Notable |
|
||||
|---|---|---|
|
||||
| `akgl_draw_point` | one pixel | clipping is SDL's, not reported |
|
||||
| `akgl_draw_line` | a line, endpoints included | coincident endpoints draw one pixel |
|
||||
| `akgl_draw_rect` | an outline | zero or negative w/h draws nothing, silently |
|
||||
| `akgl_draw_filled_rect` | a solid box, outline included | same |
|
||||
| `akgl_draw_circle` | an outline | midpoint algorithm, integer, no AA |
|
||||
| `akgl_draw_flood_fill` | a connected region | CPU-side, bounded, **not reentrant** |
|
||||
| `akgl_draw_copy_region` | *reads* a rectangle into a surface | allocates when you ask it to |
|
||||
| `akgl_draw_paste_region` | a saved surface back onto the target | replaces, does not blend |
|
||||
| `akgl_draw_background` | an 8x8 grey checkerboard | diagnostic, not a general primitive |
|
||||
|
||||
Signatures are in the generated Doxygen. Statuses are [Chapter 04](04-errors.md).
|
||||
|
||||
**These are ordinary functions, not backend methods.** Unlike `frame_start` and
|
||||
`draw_world`, they are not slots on `akgl_RenderBackend` — they take an
|
||||
`akgl_RenderBackend *` as their first argument and reach through its
|
||||
`sdl_renderer`. Pass `akgl_renderer` unless you are deliberately drawing somewhere
|
||||
else. Every one of them validates both `self` and `self->sdl_renderer` before
|
||||
touching either.
|
||||
|
||||
## Colour is an argument, never a global
|
||||
|
||||
**Every entry point takes its colour as a parameter, and none of them reads or
|
||||
writes a current-colour global.**
|
||||
|
||||
The reasoning is worth stating because it looks like extra typing. A caller that
|
||||
has a notion of a current colour — a BASIC `COLOR` statement, a UI theme, a palette
|
||||
index — *already owns that state*. Giving the library a second copy creates two
|
||||
places for the answer to live and one opportunity for them to disagree. So there is
|
||||
no `akgl_draw_set_color`, and there never will be.
|
||||
|
||||
The other half of the rule is the one that matters at a call site: **the renderer's
|
||||
own draw colour is saved before each call and restored after it.** Drawing a red
|
||||
line does not leave the renderer red. In particular, it does not change what the
|
||||
next `SDL_RenderClear` — or the next `frame_start`, which clears — paints.
|
||||
|
||||
```c
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* Two rectangles, two colours, no state carried between them. */
|
||||
akerr_ErrorContext *draw_health_bar(float32_t x, float32_t y, float32_t frac)
|
||||
{
|
||||
SDL_Color back = { 0x20, 0x20, 0x20, SDL_ALPHA_OPAQUE };
|
||||
SDL_Color fill = { 0xd0, 0x30, 0x30, SDL_ALPHA_OPAQUE };
|
||||
SDL_FRect rect;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
rect.x = x;
|
||||
rect.y = y;
|
||||
rect.w = 100.0f;
|
||||
rect.h = 8.0f;
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, back));
|
||||
|
||||
rect.w = 100.0f * frac;
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, fill));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Alpha is in the colour and blending follows the renderer's **current blend mode**,
|
||||
which these functions do not change. If you want alpha to blend rather than
|
||||
overwrite, set the blend mode yourself with
|
||||
[`SDL_SetRenderDrawBlendMode`](https://wiki.libsdl.org/SDL3/SDL_SetRenderDrawBlendMode).
|
||||
That one *is* renderer state libakgl leaves alone.
|
||||
|
||||
Restoring the colour happens in a `CLEANUP` block under `IGNORE()`. A failure to
|
||||
restore is logged rather than propagated, on the grounds that it must not mask the
|
||||
failure the cleanup is unwinding from.
|
||||
|
||||
## What the primitives look like
|
||||
|
||||
Every primitive in one frame, on a 320x240 surface. The picture below is generated by
|
||||
running exactly this listing — see `MAINTENANCE.md` if you are editing it.
|
||||
|
||||
```c screenshot=primitives
|
||||
SDL_Color red = { 0xd0, 0x30, 0x30, 0xff };
|
||||
SDL_Color green = { 0x30, 0xc0, 0x50, 0xff };
|
||||
SDL_Color blue = { 0x40, 0x70, 0xe0, 0xff };
|
||||
SDL_Color yellow = { 0xe0, 0xc0, 0x40, 0xff };
|
||||
SDL_FRect filled = { 20.0f, 20.0f, 80.0f, 50.0f };
|
||||
SDL_FRect framed = { 120.0f, 20.0f, 80.0f, 50.0f };
|
||||
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &filled, blue));
|
||||
PASS(errctx, akgl_draw_rect(akgl_renderer, &framed, green));
|
||||
PASS(errctx, akgl_draw_circle(akgl_renderer, 260.0f, 45.0f, 25.0f, yellow));
|
||||
PASS(errctx, akgl_draw_line(akgl_renderer, 20.0f, 100.0f, 300.0f, 100.0f, red));
|
||||
|
||||
/*
|
||||
* A row of points, to show that a point is a pixel and not a dot of some
|
||||
* nominal size. At this scale they read as a dashed line.
|
||||
*/
|
||||
for ( float32_t x = 20.0f; x < 300.0f; x += 8.0f ) {
|
||||
PASS(errctx, akgl_draw_point(akgl_renderer, x, 115.0f, yellow));
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
The filled rectangle is `akgl_draw_filled_rect`; the outlined one beside it is
|
||||
`akgl_draw_rect`. Neither left the renderer blue or green — the line drawn afterwards is
|
||||
red because it asked to be, not because it inherited anything.
|
||||
|
||||
## Circles are integer midpoint circles
|
||||
|
||||
SDL3 has no circle primitive, so `akgl_draw_circle` plots one: the midpoint circle
|
||||
algorithm, integer arithmetic throughout, eight-way symmetry — one computed point
|
||||
in the second octant gives the other seven by reflection.
|
||||
|
||||
Three consequences you will see on screen:
|
||||
|
||||
- **The centre and the radius are rounded to whole pixels** (`SDL_lroundf`). A
|
||||
circle at `x = 10.5` is a circle at `x = 11`. There is no sub-pixel placement.
|
||||
- **The outline is one pixel wide and is not anti-aliased.** There is no
|
||||
thickness parameter and no smoothing. If you want a thick or smooth circle, draw
|
||||
concentric ones or blit a texture.
|
||||
- **There is no filled circle.** Only the outline. A filled disc is a flood fill of
|
||||
the interior, or a texture.
|
||||
|
||||
A radius of exactly 0 draws the centre pixel and nothing else. A negative radius is
|
||||
`AKERR_OUTOFBOUNDS` and the message reports it.
|
||||
|
||||
A failure inside the plotting loop is recorded in a flag and reported once at the
|
||||
end rather than aborting mid-circle — a `CATCH` there would `break` the loop rather
|
||||
than leave the function, the hazard `AGENTS.md` describes. **So an
|
||||
`AKGL_ERR_SDL` from `akgl_draw_circle` may leave a partial arc already on the
|
||||
target.** The same is true of `akgl_draw_background`.
|
||||
|
||||
## Flood fill
|
||||
|
||||
`akgl_draw_flood_fill` is the odd one out, and it is worth understanding why before
|
||||
using it. Every other primitive here is a command queued to the GPU. This one
|
||||
cannot be: **the region it fills is defined by what is already on the screen**, so
|
||||
it has to read the render target back, walk the region on the CPU, and blit the
|
||||
result over the area it touched.
|
||||
|
||||
```text
|
||||
SDL_RenderReadPixels(target) the whole target comes back
|
||||
|
|
||||
SDL_ConvertSurface(RGBA32) fixed 32-bit layout, so the walk
|
||||
| can compare and write whole words
|
||||
flood_region() scanline fill over a bounded span
|
||||
| stack; records a dirty bounding box
|
||||
SDL_RenderTexture(dirty, dirty) only what changed goes back
|
||||
```
|
||||
|
||||
### What counts as the region
|
||||
|
||||
**Four-connected**: the fill spreads up, down, left and right, never diagonally. A
|
||||
region joined only at a corner is two regions.
|
||||
|
||||
**The boundary is an exact colour comparison.** Any pixel whose packed RGBA value
|
||||
differs from the seed's, by any amount, is a boundary. There is no tolerance
|
||||
parameter.
|
||||
|
||||
That second one is the practical trap. **An anti-aliased edge stops the fill at its
|
||||
first blended pixel and leaves a fringe** — you get the flat interior and a halo of
|
||||
untouched blend pixels around it. Text rendered through SDL3_ttf, a scaled sprite,
|
||||
and anything drawn with smoothing all have such edges. Flood fill wants hard-edged
|
||||
art.
|
||||
|
||||
Filling a region that is already the requested colour is a **no-op that succeeds**,
|
||||
not an error: walking it would compare filled pixels against themselves and find
|
||||
nothing, so the function says so up front. A seed outside the render target is
|
||||
`AKERR_OUTOFBOUNDS`, and the message reports both the seed and the target size.
|
||||
|
||||
### The span stack, and what it costs you
|
||||
|
||||
The fill keeps a fixed stack of horizontal runs still to be examined rather than
|
||||
recursing per pixel:
|
||||
|
||||
```c excerpt=include/akgl/draw.h
|
||||
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
|
||||
```
|
||||
|
||||
At 4096 spans that array is 48 KB, which does not belong on the stack of a function
|
||||
a game may call every frame — so it is **file scope**. Two consequences, and both
|
||||
are hard rules rather than cautions:
|
||||
|
||||
- **`akgl_draw_flood_fill` is not reentrant.** Do not call it from inside a
|
||||
callback that a flood fill can reach. There is one span stack for the process.
|
||||
- **It is not thread-safe.** Nothing that touches an `SDL_Renderer` is, so this is
|
||||
not a new restriction — but the span stack means it stays unsafe even against a
|
||||
second renderer on a second thread.
|
||||
|
||||
An ordinary convex or moderately concave shape needs a few dozen pending spans. A
|
||||
region complicated enough to need more than 4096 at once reports
|
||||
`AKERR_OUTOFBOUNDS` — and **leaves the region partially filled**. There is no way
|
||||
to unwind a partial fill short of keeping a copy of the whole surface, and you
|
||||
asked for a bounded operation. Treat that status as "redraw the area", not as
|
||||
"retry".
|
||||
|
||||
```c
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
akerr_ErrorContext *paint_region(int x, int y, SDL_Color color)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_draw_flood_fill(akgl_renderer, x, y, color));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_OUTOFBOUNDS) {
|
||||
/*
|
||||
* Either the seed was off-target, or the region needed more than
|
||||
* AKGL_DRAW_MAX_FLOOD_SPANS pending spans -- in which case it is
|
||||
* already PARTIALLY filled. Redraw the area; do not retry the fill.
|
||||
*/
|
||||
LOG_ERROR(errctx);
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The colour is **written over** the region rather than blended, since the pixels
|
||||
going back are the ones just read out of it.
|
||||
|
||||
## Saving and restoring a region
|
||||
|
||||
`akgl_draw_copy_region` and `akgl_draw_paste_region` are `SSHAPE` and `GSHAPE`: read
|
||||
a rectangle of the render target into an `SDL_Surface`, and draw one back.
|
||||
|
||||
### copy_region allocates only when you ask it to
|
||||
|
||||
`dest` is an `SDL_Surface **`, and **`*dest` must be initialized before the call**.
|
||||
Its value selects between two behaviours:
|
||||
|
||||
| `*dest` on entry | What happens | Who owns the surface afterwards |
|
||||
|---|---|---|
|
||||
| `NULL` | a surface is allocated and written back through `dest` | **you do** — release it with `SDL_DestroySurface` |
|
||||
| a surface of *exactly* `src`'s dimensions | the pixels are copied into it | you already did |
|
||||
| a surface of any other size | `AKERR_OUTOFBOUNDS`, both sets of dimensions in the message | unchanged |
|
||||
|
||||
The reuse path exists so a caller saving the same region every frame does not churn
|
||||
allocations. Note that `akgl_draw_copy_region` is one of the few places in libakgl
|
||||
that hands you memory you have to free yourself — everything else comes from the
|
||||
pools in [Chapter 05](05-the-heap.md).
|
||||
|
||||
**`src` must fit entirely inside the render target.** This is checked and refused
|
||||
with `AKERR_OUTOFBOUNDS` rather than clipped, deliberately: SDL would clip the read
|
||||
and hand back a *smaller* surface than was asked for, and a caller pasting it back
|
||||
would not notice until the art was visibly wrong. A rectangle with no area is
|
||||
refused too.
|
||||
|
||||
### paste_region replaces, it does not blend
|
||||
|
||||
`akgl_draw_paste_region` sets `SDL_BLENDMODE_NONE` on the texture it uploads, so
|
||||
the saved pixels **overwrite** what is on the target. That is `GSHAPE`'s default
|
||||
behaviour and it is the one that makes a save/restore pair actually restore: a
|
||||
saved region's transparent pixels come back as transparent, rather than letting
|
||||
whatever is underneath show through them.
|
||||
|
||||
There is no scaling — the surface is drawn at its own size — and the surface is not
|
||||
consumed, so it may be pasted as many times as you like. Parts falling outside the
|
||||
target are clipped by SDL and not reported.
|
||||
|
||||
```c
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* SSHAPE then GSHAPE: save a 32x32 patch, draw over it, put it back. */
|
||||
akerr_ErrorContext *blink_cursor(int x, int y)
|
||||
{
|
||||
SDL_Surface *saved = NULL;
|
||||
SDL_Rect box;
|
||||
SDL_FRect cursor;
|
||||
SDL_Color white = { 0xff, 0xff, 0xff, SDL_ALPHA_OPAQUE };
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
box.x = x;
|
||||
box.y = y;
|
||||
box.w = 32;
|
||||
box.h = 32;
|
||||
cursor.x = (float32_t)x;
|
||||
cursor.y = (float32_t)y;
|
||||
cursor.w = 32.0f;
|
||||
cursor.h = 32.0f;
|
||||
|
||||
ATTEMPT {
|
||||
/* saved is NULL, so copy_region allocates it and we own the result. */
|
||||
CATCH(errctx, akgl_draw_copy_region(akgl_renderer, &box, &saved));
|
||||
CATCH(errctx, akgl_draw_filled_rect(akgl_renderer, &cursor, white));
|
||||
CATCH(errctx, akgl_draw_paste_region(akgl_renderer, saved, cursor.x, cursor.y));
|
||||
} CLEANUP {
|
||||
if ( saved != NULL ) {
|
||||
SDL_DestroySurface(saved);
|
||||
saved = NULL;
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## The checkerboard backdrop
|
||||
|
||||
`akgl_draw_background` paints an 8x8 grey checkerboard — the pattern an image
|
||||
editor uses to show transparency. It is a **diagnostic backdrop rather than a
|
||||
general primitive**: `charviewer` uses it so a sprite's transparent pixels are
|
||||
visible instead of blending into black.
|
||||
|
||||
It always starts at the origin. `w` and `h` are the extent to cover, not a
|
||||
rectangle, and both round *up* to whole 8-pixel cells — a height of 12 paints 16. A
|
||||
zero or negative extent paints nothing and is not reported.
|
||||
|
||||
Until 0.5.0 this was the one function in the library outside the error protocol: it
|
||||
returned `void`, drew through the global `akgl_renderer` without checking it, and
|
||||
left the renderer's draw colour changed. It takes a backend now and restores the
|
||||
colour it found, which is also what makes it testable without a world.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 08](08-rendering.md) — the backend these all take, and the frame the
|
||||
calls sit inside.
|
||||
- [Chapter 16](16-text-and-fonts.md) — text, which is textures rather than
|
||||
primitives.
|
||||
- [Chapter 05](05-the-heap.md) — the pools, and why `copy_region` returning owned
|
||||
memory is the exception rather than the rule.
|
||||
313
docs/10-spritesheets-and-sprites.md
Normal file
313
docs/10-spritesheets-and-sprites.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# 10. Spritesheets and sprites
|
||||
|
||||
Two objects, and the split between them is the whole design:
|
||||
|
||||
- An **`akgl_SpriteSheet`** owns one `SDL_Texture` — an image file uploaded to the
|
||||
GPU — and nothing else.
|
||||
- An **`akgl_Sprite`** is one animation: a frame size, a playlist of frame numbers,
|
||||
a dwell time, two loop flags, and a **borrowed** pointer to the sheet those
|
||||
frames are cut from.
|
||||
|
||||
So a walk cycle, an idle pose and a death animation drawn on the same PNG are three
|
||||
`akgl_Sprite` objects pointing at one `akgl_SpriteSheet`, and the image is uploaded
|
||||
once. That is not an optimization you have to ask for; it is what the loader does.
|
||||
|
||||
Both are pool objects — `akgl_heap_next_sprite`, `akgl_heap_next_spritesheet`, see
|
||||
[Chapter 05](05-the-heap.md) — and both publish themselves in a registry under
|
||||
their name, which is how [Chapter 11](11-characters.md) and the tilemap loader find
|
||||
them. See [Chapter 06](06-the-registry.md).
|
||||
|
||||
**The renderer has to exist before any of this.** Loading a sheet uploads a
|
||||
texture, so `akgl_render_2d_init` (or `akgl_render_2d_bind` with a live
|
||||
`SDL_Renderer` on it — see [Chapter 08](08-rendering.md)) comes first.
|
||||
|
||||
## One image, one texture: how sharing actually works
|
||||
|
||||
`akgl_sprite_load_json` does not load an image and hope. It:
|
||||
|
||||
1. resolves the sheet's `filename` to an **absolute, canonical path**;
|
||||
2. looks that path up in `AKGL_REGISTRY_SPRITESHEET`;
|
||||
3. reuses the sheet it finds, or claims one from the pool and loads it.
|
||||
|
||||
**The resolved path is the registry key.** That is what makes ten sprites cut from
|
||||
one image cost one texture, and it is why the resolution rule below matters more
|
||||
than it looks.
|
||||
|
||||
On the reuse path the sheet's reference count is **not** incremented — the sprite
|
||||
borrows it. `akgl_sprite_initialize` does not take a reference either. So:
|
||||
|
||||
> **Releasing a spritesheet out from under a live sprite leaves a dangling
|
||||
> pointer.** Nothing detects it. In practice, release sprites and sheets together
|
||||
> at the same lifecycle boundary (a level change), not individually.
|
||||
|
||||
### Where a sheet filename resolves from
|
||||
|
||||
This is a two-step rule and the first step surprises people:
|
||||
|
||||
```text
|
||||
"spritesheet": { "filename": "hero.png" } in docs/assets/hero_walk.json
|
||||
|
||||
step 1 realpath("hero.png")
|
||||
-> relative to the PROCESS'S CURRENT WORKING DIRECTORY
|
||||
-> if that file exists, that is the sheet. Done.
|
||||
|
||||
step 2 only if step 1 raised ENOENT:
|
||||
realpath(dirname("docs/assets/hero_walk.json") + "/" + "hero.png")
|
||||
-> relative to the SPRITE JSON'S OWN DIRECTORY
|
||||
```
|
||||
|
||||
**A sprite definition can sit next to its image and move with it** — that is step 2,
|
||||
and it is the behaviour the format is designed around. But step 1 runs first, so a
|
||||
file of the same name in the working directory shadows the one beside the JSON. If
|
||||
a sprite loads the wrong art, check the working directory before checking the path.
|
||||
|
||||
Both steps end in `realpath(3)`, so symlinks and `..` are folded out and the key is
|
||||
canonical. Two different spellings of the same file through `akgl_sprite_load_json`
|
||||
therefore land on **one** sheet. (Calling `akgl_spritesheet_initialize` directly
|
||||
skips all of this — it uses whatever string you hand it as the key verbatim, so
|
||||
there two spellings really are two sheets.)
|
||||
|
||||
`AKERR_KEY` from a sheet load usually means the registry is not up. `ENOENT` means
|
||||
neither step found the file.
|
||||
|
||||
## Frames are counted left to right, wrapping down
|
||||
|
||||
A sheet has no grid metadata that the library uses. The grid comes from the
|
||||
*sprite*: `width` and `height` are both the drawn frame size **and** the stride
|
||||
used to find a frame on the sheet.
|
||||
|
||||
```text
|
||||
A 192x96 sheet, with a sprite whose width and height are 48:
|
||||
|
||||
+----+----+----+----+
|
||||
| 0 | 1 | 2 | 3 | frame numbers count left to right from the
|
||||
+----+----+----+----+ top-left, then wrap to the next row
|
||||
| 4 | 5 | 6 | 7 |
|
||||
+----+----+----+----+
|
||||
|
||||
"frames": [ 4, 5, 6, 5 ] an animation is a playlist of frame numbers.
|
||||
index: 0 1 2 3 A frame may repeat; the order is yours.
|
||||
```
|
||||
|
||||
Two indices, and confusing them is the most common mistake here:
|
||||
|
||||
| Name | What it indexes | Where you see it |
|
||||
|---|---|---|
|
||||
| **frame number** | a cell on the sheet | the values inside `"frames"` |
|
||||
| **frame id** | a slot in the `"frames"` playlist | `akgl_Actor::curSpriteFrameId`, and the `frameid` argument to `akgl_spritesheet_coords_for_frame` |
|
||||
|
||||
`akgl_spritesheet_coords_for_frame` takes a **frame id**, looks up
|
||||
`frameids[frameid]`, multiplies by the sprite width, and wraps whole rows off the
|
||||
right-hand edge of the texture, stepping down one sprite height per row. The result
|
||||
is the source rectangle for the blit.
|
||||
|
||||
> **It does not bounds-check `frameid`.** An index past `frames` — or past
|
||||
> `AKGL_SPRITE_MAX_FRAMES` — reads a neighbouring struct member and yields a
|
||||
> nonsense rectangle rather than an error. It also dereferences `sheet->texture`
|
||||
> without checking it, so a registered-but-unloaded sheet is a crash.
|
||||
|
||||
`akgl_SpriteSheet` carries `sprite_w` and `sprite_h` fields. **They are vestigial.**
|
||||
Nothing in the library writes or reads them, and the two arguments
|
||||
`akgl_spritesheet_initialize` takes for them are accepted and discarded. The grid
|
||||
that is actually used is the sprite's `width`/`height`. They are still in the
|
||||
struct and in the signature because removing them is an ABI change.
|
||||
|
||||
## The sprite JSON format
|
||||
|
||||
```json kind=sprite setup=spritesheet
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "hero walking left",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 100,
|
||||
"loop": true,
|
||||
"loopReverse": true,
|
||||
"frames": [
|
||||
12,
|
||||
13,
|
||||
14
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `spritesheet.filename` | string | Image path. Resolved as above. Required. |
|
||||
| `spritesheet.frame_width` | integer | Read **only when the sheet is not already loaded**, then discarded. |
|
||||
| `spritesheet.frame_height` | integer | Same. |
|
||||
| `name` | string | Registry key in `AKGL_REGISTRY_SPRITE`. Required. |
|
||||
| `width` | integer | Frame width in pixels, and the horizontal stride. Must be >= 1. |
|
||||
| `height` | integer | Frame height in pixels, and the vertical stride. Must be >= 1. |
|
||||
| `speed` | integer | **Milliseconds** one frame is held. 0 to 4294. |
|
||||
| `loop` | boolean | Restart at the end rather than holding the last frame. |
|
||||
| `loopReverse` | boolean | With `loop`, walk back down instead of jumping to 0 — a ping-pong. |
|
||||
| `frames` | array of integers | Frame numbers, in playback order. At most 16, each 0..255. |
|
||||
|
||||
**Every one of those keys is required.** These are not optional-with-a-default
|
||||
lookups: an absent key is `AKERR_KEY` naming it, and a key of the wrong JSON type is
|
||||
`AKERR_TYPE`. `speed` as a string fails; `frames` as an object fails.
|
||||
|
||||
### speed is milliseconds in the file and nanoseconds in the struct
|
||||
|
||||
`akgl_Sprite::speed` is compared against `SDL_GetCurrentTime`, which counts
|
||||
nanoseconds. Nobody wants to type nanoseconds into an asset file, so the loader
|
||||
reads milliseconds and multiplies by `AKGL_TIME_ONEMS_NS` (1000000).
|
||||
|
||||
**If you set `speed` by hand rather than through the loader, scale it yourself.**
|
||||
Writing `spr->speed = 100` means 100 nanoseconds, and the animation advances every
|
||||
frame.
|
||||
|
||||
The field is `uint32_t`, so the multiply is what bounds the range: values above
|
||||
`UINT32_MAX / AKGL_TIME_ONEMS_NS` — **4294 ms** — would overflow, and are refused
|
||||
with `AKERR_OUTOFBOUNDS` rather than wrapping. A negative `speed` is refused too.
|
||||
|
||||
### What the loader actually validates
|
||||
|
||||
Read against `src/sprite.c`, not against the header prose:
|
||||
|
||||
| Check | Status | Message says |
|
||||
|---|---|---|
|
||||
| `strlen(filename) >= AKGL_MAX_STRING_LENGTH` | `AKERR_OUTOFBOUNDS` | the JSON path is too long for a pooled string |
|
||||
| `width <= 0` | `AKERR_VALUE` | "a sprite must be at least one pixel wide" |
|
||||
| `height <= 0` | `AKERR_VALUE` | "a sprite must be at least one pixel high" |
|
||||
| `speed < 0` or `speed > 4294` | `AKERR_OUTOFBOUNDS` | the value and the permitted range |
|
||||
| `frames` longer than `AKGL_SPRITE_MAX_FRAMES` (16) | `AKERR_OUTOFBOUNDS` | the declared count and the maximum |
|
||||
| a frame number outside 0..255 | `AKERR_OUTOFBOUNDS` | which index, its value, and the range |
|
||||
|
||||
The last two are recent and are worth knowing about because `sprite.h` still says
|
||||
otherwise. Its `@note` on `akgl_sprite_load_json` reads *"The `frames` array is not
|
||||
bounded against `AKGL_SPRITE_MAX_FRAMES`. A definition with more than 16 frames
|
||||
writes past `frameids` into the rest of the struct."* **That is no longer true** —
|
||||
the count is bounded before anything is written, and `tests/assets/` carries
|
||||
fixtures for both the boundary and the overflow. The same `@brief` describes
|
||||
`speed` as *"seconds, scaled to milliseconds"*, which is wrong in both units.
|
||||
Correcting those comments is a separate commit; this chapter documents the code.
|
||||
|
||||
On any failure the pooled sprite **and any sheet loaded for it** are released
|
||||
again, so a bad definition does not strand pool slots.
|
||||
|
||||
## Loading, and building one by hand
|
||||
|
||||
The ordinary path is one call per definition file:
|
||||
|
||||
```c
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
akerr_ErrorContext *load_hero_sprites(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* Both cut from one image: the second call reuses the first's texture. */
|
||||
PASS(errctx, akgl_sprite_load_json("assets/hero_walk_left.json"));
|
||||
PASS(errctx, akgl_sprite_load_json("assets/hero_walk_right.json"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
You can also assemble one from the pools directly, which is what the loader does
|
||||
underneath. `akgl_sprite_initialize` sets the name and the sheet and takes the
|
||||
first reference; **everything else is left at zero for you to fill in**:
|
||||
|
||||
```c
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
/*
|
||||
* Two animations cut from one image, built without a JSON file. One sheet,
|
||||
* one texture, two sprites borrowing it.
|
||||
*/
|
||||
akerr_ErrorContext *build_hero_sprites(char *imagepath)
|
||||
{
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
akgl_Sprite *walk = NULL;
|
||||
akgl_Sprite *idle = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, imagepath, AKERR_NULLPOINTER, "imagepath");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(&sheet));
|
||||
/* The two size arguments are discarded; the sprite's width/height win. */
|
||||
CATCH(errctx, akgl_spritesheet_initialize(sheet, 48, 48, imagepath));
|
||||
|
||||
CATCH(errctx, akgl_heap_next_sprite(&walk));
|
||||
CATCH(errctx, akgl_sprite_initialize(walk, "hero walking left", sheet));
|
||||
walk->width = 48;
|
||||
walk->height = 48;
|
||||
walk->frames = 3;
|
||||
walk->frameids[0] = 12;
|
||||
walk->frameids[1] = 13;
|
||||
walk->frameids[2] = 14;
|
||||
walk->speed = 100 * AKGL_TIME_ONEMS_NS; /* 100 ms, in nanoseconds */
|
||||
walk->loop = true;
|
||||
walk->loopReverse = true;
|
||||
|
||||
CATCH(errctx, akgl_heap_next_sprite(&idle));
|
||||
CATCH(errctx, akgl_sprite_initialize(idle, "hero standing left", sheet));
|
||||
idle->width = 48;
|
||||
idle->height = 48;
|
||||
idle->frames = 1;
|
||||
idle->frameids[0] = 13;
|
||||
idle->speed = 1000 * AKGL_TIME_ONEMS_NS;
|
||||
idle->loop = false;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
A string literal is a safe `name` argument. `sprite.h` warns that the name is
|
||||
*"copied at a fixed `AKGL_SPRITE_MAX_NAME_LENGTH` bytes, so a shorter string reads
|
||||
past its end"* — that was a `memcpy` of the full field width and it is gone. The
|
||||
copy is `aksl_strncpy` now, which reads only what is there and always terminates.
|
||||
|
||||
## Animation is the actor's job, not the sprite's
|
||||
|
||||
An `akgl_Sprite` is a description. Nothing in it moves. The advancing is done by
|
||||
the actor holding it — `curSpriteFrameId`, `curSpriteFrameTimer` and
|
||||
`curSpriteReversing` are actor fields, and `changeframefunc` is an actor hook. See
|
||||
[Chapter 12](12-actors.md).
|
||||
|
||||
That is why the same sprite can be shared by a hundred actors that are all on
|
||||
different frames.
|
||||
|
||||
The three flag combinations, as the default `changeframefunc` reads them:
|
||||
|
||||
| `loop` | `loopReverse` | At the last frame |
|
||||
|---|---|---|
|
||||
| true | false | wrap to frame id 0 |
|
||||
| true | true | turn round and walk back down, turning again at 0 |
|
||||
| false | either | **wrap to frame id 0 anyway** |
|
||||
|
||||
The last row is not a typo. A sprite with `loop` clear still wraps rather than
|
||||
holding the final frame; there is no play-once behaviour in the default hook. A
|
||||
game that wants one binds its own `changeframefunc`.
|
||||
|
||||
## Known defects worth knowing here
|
||||
|
||||
- **Truncated names are registry keys.** `akgl_Sprite::name` is 128 bytes and
|
||||
`akgl_SpriteSheet::name` is 512. A longer name truncates *silently*, and two names
|
||||
that truncate the same collide — the second registration replaces the first with no
|
||||
error. Recorded in `TODO.md`, "Truncated registry keys can collide"; the fix is a
|
||||
contract change, since the headers currently promise truncation.
|
||||
- **The heap acquire functions are asymmetric.** `akgl_heap_next_string` increments
|
||||
`refcount`; `next_sprite` and `next_spritesheet` do not. `TODO.md` item 8. See
|
||||
[Chapter 05](05-the-heap.md).
|
||||
- **No test asserts a clean sprite or spritesheet load/release cycle.** The tilemap
|
||||
cycle is asserted over 64 iterations; the sprite, spritesheet and character ones
|
||||
are not. `TODO.md`, "Targets", row 16.
|
||||
- **`akgl_spritesheet_coords_for_frame` bounds-checks nothing.** See above.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 11](11-characters.md) — binding sprites to actor states.
|
||||
- [Chapter 12](12-actors.md) — what actually advances a frame.
|
||||
- [Chapter 05](05-the-heap.md) — the pools these come out of.
|
||||
- [Chapter 06](06-the-registry.md) — how a name becomes a lookup.
|
||||
328
docs/11-characters.md
Normal file
328
docs/11-characters.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 11. Characters
|
||||
|
||||
**A character is the reusable template. An actor is the instance.**
|
||||
|
||||
Everything that is the same for every goblin on the map — top speed, acceleration,
|
||||
how long an animation frame is held, and which sprite to draw for which combination
|
||||
of state bits — lives in one `akgl_Character` and is shared by pointer. A hundred
|
||||
goblins cost one character. Everything that is unique to a particular goblin — where
|
||||
it is, which way it is going, which frame it is on — lives in its `akgl_Actor`. See
|
||||
[Chapter 12](12-actors.md).
|
||||
|
||||
Characters are pool objects (`akgl_heap_next_character`, 256 slots) published in
|
||||
`AKGL_REGISTRY_CHARACTER` under their name, which is how `akgl_actor_set_character`
|
||||
finds them. `akgl_registry_init` has to have run first.
|
||||
|
||||
```text
|
||||
akgl_Character "hero" akgl_Actor "player"
|
||||
+--------------------------------+ +---------------------+
|
||||
| sx, sy, sz top speeds |<---------| basechar |
|
||||
| ax, ay, az accelerations | borrowed| x, y, z |
|
||||
| speedtime frame dwell (ns) | | state (bitmask) |
|
||||
| state_sprites -----------------+---+ | curSpriteFrameId |
|
||||
+--------------------------------+ | +---------------------+
|
||||
|
|
||||
SDL_PropertiesID keyed by the DECIMAL state bitmask
|
||||
"18" -> akgl_Sprite *hero standing left
|
||||
"146" -> akgl_Sprite *hero walking left
|
||||
```
|
||||
|
||||
## The state-to-sprite map is an exact-match lookup
|
||||
|
||||
`state_sprites` is an `SDL_PropertiesID`. The key is **the decimal spelling of the
|
||||
whole state value** — `SDL_itoa(state, buf, 10)` — and the value is an
|
||||
`akgl_Sprite *`.
|
||||
|
||||
Two things follow, and both of them shape how you build a character:
|
||||
|
||||
**1. The key is the entire state word, not a set of bits to match against.** A
|
||||
sprite added for `FACE_LEFT | MOVING_LEFT` is *not* found by a lookup for
|
||||
`FACE_LEFT`. There is no subset fallback, no wildcard, no priority order, and no
|
||||
default sprite. **You need a mapping for every state combination the character can
|
||||
actually be in.**
|
||||
|
||||
**2. A missing mapping is an error, not a quiet miss.** `akgl_character_sprite_get`
|
||||
raises `AKERR_KEY` — this is the one lookup in the library that treats absence as a
|
||||
failure rather than as a successful answer of "nothing here", on the grounds that an
|
||||
actor with no sprite for its current state cannot be drawn. `*dest` is set to `NULL`
|
||||
alongside the error.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
|
||||
/*
|
||||
* sprite_get treats "no mapping" as AKERR_KEY. Handle the status; do not test
|
||||
* the return value for NULL and hope.
|
||||
*/
|
||||
akerr_ErrorContext *sprite_or_none(akgl_Character *basechar, int state, akgl_Sprite **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
*dest = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, basechar->sprite_get(basechar, state, dest));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_KEY) {
|
||||
/* No sprite for this exact combination. *dest is already NULL. */
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_actor_update` and `akgl_actor_render` both swallow that `AKERR_KEY` themselves
|
||||
— states change faster than art gets drawn, and a missing sprite should not stop the
|
||||
frame — so an under-mapped character shows up as an actor that silently does not
|
||||
draw. That is the symptom to recognize.
|
||||
|
||||
### The combinations you will actually need
|
||||
|
||||
Four facings, moving and standing, alive: eight mappings for the simplest walking
|
||||
character. Written out, with the decimal key each one lands on:
|
||||
|
||||
| Combination | Decimal key | What it draws |
|
||||
|---|---|---|
|
||||
| `ALIVE` | 16 | standing still, **no facing** — see below |
|
||||
| `ALIVE\|FACE_DOWN` | 17 | standing, facing the camera |
|
||||
| `ALIVE\|FACE_LEFT` | 18 | standing, facing left |
|
||||
| `ALIVE\|FACE_RIGHT` | 20 | standing, facing right |
|
||||
| `ALIVE\|FACE_UP` | 24 | standing, facing away |
|
||||
| `ALIVE\|FACE_DOWN\|MOVING_DOWN` | 1041 | walking towards the camera |
|
||||
| `ALIVE\|FACE_LEFT\|MOVING_LEFT` | 146 | walking left |
|
||||
| `ALIVE\|FACE_RIGHT\|MOVING_RIGHT` | 276 | walking right |
|
||||
| `ALIVE\|FACE_UP\|MOVING_UP` | 536 | walking away |
|
||||
|
||||
**Do not skip the first row.** The default `facefunc`
|
||||
(`akgl_actor_automatic_face`) clears every facing bit and then sets one only if a
|
||||
movement bit is set — so an actor that stops moving is left with *no* facing bit at
|
||||
all, and its state drops to bare `ALIVE`. Without a mapping for 16 it vanishes the
|
||||
moment it stops. This is a known rough edge; the implementation carries a `TODO`
|
||||
saying as much, and [Chapter 12](12-actors.md) covers the workaround.
|
||||
|
||||
The decimals are shown to make the mechanism concrete. **Write the constants**, or
|
||||
the state-name strings in JSON — never the numbers.
|
||||
|
||||
## Adding a mapping
|
||||
|
||||
`akgl_character_sprite_add` takes a reference on the sprite, so the character keeps
|
||||
it alive. The reference is taken *before* the property write, so a failed write
|
||||
cannot leave the sprite bound with no reference behind it.
|
||||
|
||||
Rebinding a state that is already mapped releases the sprite it displaced. **The
|
||||
header says otherwise** — `character.h`'s `@note` on `akgl_character_sprite_add`
|
||||
claims the displaced sprite's reference "is never given back". That was true and is
|
||||
not; `src/character.c` reads the old binding out first and releases it, with a
|
||||
comment explaining that without it a character rebinding a state while alive leaks
|
||||
one sprite slot per rebind.
|
||||
|
||||
> **A defect the release path does have.** The release is guarded by
|
||||
> `(displaced != NULL) && (displaced != ref)`. Re-adding the *same* sprite for the
|
||||
> *same* state therefore increments its reference count and releases nothing — one
|
||||
> leaked reference per redundant call. It is idempotent-looking and is not.
|
||||
> Harmless in a loader that runs once; not harmless in a loop.
|
||||
|
||||
State `0` is accepted and is a usable key.
|
||||
|
||||
## Building a character in code
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
akerr_ErrorContext *build_hero(akgl_Sprite *idle_left, akgl_Sprite *walk_left)
|
||||
{
|
||||
akgl_Character *hero = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, idle_left, AKERR_NULLPOINTER, "idle_left");
|
||||
FAIL_ZERO_RETURN(errctx, walk_left, AKERR_NULLPOINTER, "walk_left");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&hero));
|
||||
/* Zeroes the struct, creates the map, binds the two hooks, registers. */
|
||||
CATCH(errctx, akgl_character_initialize(hero, "hero"));
|
||||
|
||||
/* Everything numeric is left at zero for us. */
|
||||
hero->speedtime = 100 * AKGL_TIME_ONEMS_NS; /* 100 ms, in nanoseconds */
|
||||
hero->sx = 0.20f;
|
||||
hero->sy = 0.20f;
|
||||
hero->ax = 0.20f;
|
||||
hero->ay = 0.20f;
|
||||
|
||||
/* One mapping per exact combination we intend to draw. */
|
||||
CATCH(errctx,
|
||||
hero->sprite_add(
|
||||
hero,
|
||||
idle_left,
|
||||
AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT)
|
||||
);
|
||||
CATCH(errctx,
|
||||
hero->sprite_add(
|
||||
hero,
|
||||
walk_left,
|
||||
AKGL_ACTOR_STATE_ALIVE
|
||||
| AKGL_ACTOR_STATE_FACE_LEFT
|
||||
| AKGL_ACTOR_STATE_MOVING_LEFT)
|
||||
);
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`sprite_add` and `sprite_get` are function pointers on the struct, bound to
|
||||
`akgl_character_sprite_add` and `akgl_character_sprite_get` by
|
||||
`akgl_character_initialize`. Call through the pointers, as with the render backend
|
||||
in [Chapter 08](08-rendering.md) — that is what lets a game substitute its own
|
||||
lookup, a state machine with fallbacks for instance, on one character.
|
||||
|
||||
`az` and `sz` are **not** read from JSON. They stay 0 unless you set them by hand.
|
||||
|
||||
## The character JSON format
|
||||
|
||||
```json kind=character setup=character
|
||||
{
|
||||
"name": "hero",
|
||||
"speedtime": 100,
|
||||
"speed_x": 0.20,
|
||||
"speed_y": 0.20,
|
||||
"acceleration_x": 0.20,
|
||||
"acceleration_y": 0.20,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
||||
],
|
||||
"sprite": "hero standing left"
|
||||
},
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
||||
],
|
||||
"sprite": "hero walking left"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | string | Registry key in `AKGL_REGISTRY_CHARACTER`. |
|
||||
| `speedtime` | integer | **Milliseconds** a frame is held, scaled to nanoseconds by `AKGL_TIME_ONEMS_NS`. |
|
||||
| `speed_x`, `speed_y` | number | Top speed per axis. Copied onto an actor as `sx`/`sy`. |
|
||||
| `acceleration_x`, `acceleration_y` | number | Acceleration per axis, signed by direction each step. |
|
||||
| `sprite_mappings` | array of objects | One entry per state combination. |
|
||||
| `…[].sprite` | string | A name already in `AKGL_REGISTRY_SPRITE`. |
|
||||
| `…[].state` | array of strings | State names, **OR-ed together** into one key. |
|
||||
|
||||
All of them are required. An absent key is `AKERR_KEY` naming it; a wrong type is
|
||||
`AKERR_TYPE`.
|
||||
|
||||
`character.h`'s `@brief` describes `speedtime` as "in seconds". It is milliseconds
|
||||
— the struct field's own documentation says so and `src/character.c` multiplies by
|
||||
`AKGL_TIME_ONEMS_NS`.
|
||||
|
||||
**Sprites must be loaded first.** This loads characters, not sprites: every name in
|
||||
a `sprite` field has to already be in `AKGL_REGISTRY_SPRITE`, so
|
||||
`akgl_sprite_load_json` runs before `akgl_character_load_json`. A mapping naming an
|
||||
unregistered sprite fails with `AKERR_NULLPOINTER` (not `AKERR_KEY` — it is a
|
||||
lookup miss reported with a pointer status), and the message names the character,
|
||||
the state and the sprite.
|
||||
|
||||
Two keys you may see in existing files and should not copy: `movementspeed` and
|
||||
`velocity_x`. **Neither is read by anything.** They are ignored, silently.
|
||||
|
||||
### State names are prefixed
|
||||
|
||||
The strings come from `AKGL_ACTOR_STATE_STRING_NAMES` in
|
||||
`src/actor_state_string_names.c`, which `akgl_registry_init_actor_state_strings`
|
||||
turns into a registry. Entry `i` names the bit `1 << i`, and **every name carries
|
||||
the `AKGL_ACTOR_STATE_` prefix**:
|
||||
|
||||
```text
|
||||
AKGL_ACTOR_STATE_FACE_DOWN AKGL_ACTOR_STATE_MOVING_LEFT
|
||||
AKGL_ACTOR_STATE_FACE_LEFT AKGL_ACTOR_STATE_MOVING_RIGHT
|
||||
AKGL_ACTOR_STATE_FACE_RIGHT AKGL_ACTOR_STATE_MOVING_UP
|
||||
AKGL_ACTOR_STATE_FACE_UP AKGL_ACTOR_STATE_MOVING_DOWN
|
||||
AKGL_ACTOR_STATE_ALIVE AKGL_ACTOR_STATE_MOVING_IN
|
||||
AKGL_ACTOR_STATE_DYING AKGL_ACTOR_STATE_MOVING_OUT
|
||||
AKGL_ACTOR_STATE_DEAD AKGL_ACTOR_STATE_UNDEFINED_13 .. _31
|
||||
```
|
||||
|
||||
A name that is not in that list is `AKERR_KEY`, "Unknown actor state", quoting it —
|
||||
a typo is refused rather than skipped, because a skipped typo would bind a sprite to
|
||||
the wrong combination and show up as art that never appears. An empty `state` array
|
||||
is legal and gives you the key `0`.
|
||||
|
||||
Adding a new state means adding a bit in `include/akgl/actor.h` **and** its name at
|
||||
the matching index in `src/actor_state_string_names.c`. A bit whose name is missing
|
||||
cannot be referred to from JSON at all — which is exactly what happened to
|
||||
`MOVING_IN` and `MOVING_OUT` until 0.5.0.
|
||||
|
||||
> ### `util/assets/littleguy.json` is invalid against the current loader
|
||||
>
|
||||
> The sample data for the one shipped demo does not load, and it is worth naming
|
||||
> the three separate reasons because each is a different class of drift:
|
||||
>
|
||||
> 1. **The state names are unprefixed** — `"ACTOR_STATE_ALIVE"` rather than
|
||||
> `"AKGL_ACTOR_STATE_ALIVE"`. Every one fails with `AKERR_KEY`, "Unknown actor
|
||||
> state".
|
||||
> 2. **It has `velocity_x` and `velocity_y`**, which nothing reads, and lacks
|
||||
> `speed_x` and `speed_y`, which are required.
|
||||
> 3. **It has no `speedtime`, `acceleration_x` or `acceleration_y`** either — all
|
||||
> three required, all three `AKERR_KEY`.
|
||||
>
|
||||
> `tests/assets/testcharacter.json` is the file to copy from. Fixing the sample is
|
||||
> out of scope here (`plan.md`, "Out of scope, deliberately"); the finding is
|
||||
> recorded so nobody quotes it forward.
|
||||
|
||||
## Teardown
|
||||
|
||||
`akgl_heap_release_character` decrements the reference count, and at zero it walks
|
||||
`state_sprites` with `akgl_character_state_sprites_iterate`, releasing every sprite
|
||||
reference `sprite_add` took, destroys the property set, clears the registry entry
|
||||
and zeroes the slot.
|
||||
|
||||
That walk is an `SDL_EnumerateProperties` callback, so it returns `void` and ends in
|
||||
`FINISH_NORETURN`. **An error inside it exits the process** via libakerror's default
|
||||
unhandled-error handler. The same hazard, in its more common form, is in
|
||||
[Chapter 12](12-actors.md) under `akgl_registry_iterate_actor`; install your own
|
||||
`akerr_handler_unhandled_error` if a game should survive it.
|
||||
|
||||
An actor **borrows** its character and takes no reference on it. Releasing a
|
||||
character out from under a live actor leaves a dangling `basechar`.
|
||||
|
||||
## Known defects worth knowing here
|
||||
|
||||
- **Re-adding the same sprite for the same state leaks a reference.** See above.
|
||||
- **Long names register and unregister under different keys.**
|
||||
`akgl_character_initialize` writes the registry entry under the *caller's* `name`
|
||||
pointer, while `akgl_heap_release_character` clears it under the character's own
|
||||
truncated 128-byte copy. For a name over 127 bytes those differ, and the registry
|
||||
entry survives the release — pointing at a zeroed slot. Latent for ordinary names;
|
||||
related to `TODO.md`, "Truncated registry keys can collide", which covers the
|
||||
collision half.
|
||||
- **`speedtime` is written through an `int *` cast of a `uint64_t` field.**
|
||||
`src/character.c` passes `(int *)&obj->speedtime` to
|
||||
`akgl_get_json_integer_value`. It works because the struct was zeroed and the
|
||||
platform is little-endian; it is the same class of cast-over-a-signature-mismatch
|
||||
that `AGENTS.md` argues against under "Never silence a warning with a cast".
|
||||
- **`akgl_character_initialize` silently replaces a same-named character**, and the
|
||||
one it displaced becomes unreachable rather than being released.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 10](10-spritesheets-and-sprites.md) — the sprites these map to.
|
||||
- [Chapter 12](12-actors.md) — the state bitmask itself, and what sets it.
|
||||
- [Chapter 14](14-physics.md) — what `sx`/`sy` and `ax`/`ay` mean once an actor is
|
||||
simulated.
|
||||
447
docs/12-actors.md
Normal file
447
docs/12-actors.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# 12. Actors
|
||||
|
||||
An actor is a live thing in the world. It carries only what is unique to it — where
|
||||
it is, which way it is moving, which animation frame it is on — and borrows speed,
|
||||
acceleration and the whole state-to-sprite map from the `akgl_Character` it points
|
||||
at. That is what makes a hundred of one kind of thing cheap. See
|
||||
[Chapter 11](11-characters.md) for the other half.
|
||||
|
||||
Actors are pool objects (`akgl_heap_next_actor`, 64 slots by default) published in
|
||||
`AKGL_REGISTRY_ACTOR` under their name.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
akerr_ErrorContext *spawn_player(void)
|
||||
{
|
||||
akgl_Actor *player = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&player));
|
||||
/* Zeroes it, sets scale 1.0 and movement_controls_face, installs the
|
||||
six default hooks, registers it, takes the first reference. */
|
||||
CATCH(errctx, akgl_actor_initialize(player, "player"));
|
||||
/* Until this runs the actor cannot update, render or simulate. */
|
||||
CATCH(errctx, akgl_actor_set_character(player, "hero"));
|
||||
|
||||
player->state = AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN;
|
||||
player->visible = true;
|
||||
player->layer = 1;
|
||||
player->x = 64.0f;
|
||||
player->y = 64.0f;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_actor_initialize` does **not** set a character. `akgl_actor_set_character`
|
||||
looks one up by name, copies its `sx`/`sy` onto the actor and zeroes `ax`/`ay` so it
|
||||
starts from rest. The character is borrowed, not referenced. A name that is not
|
||||
registered comes back as `AKERR_NULLPOINTER` — a lookup miss reported with a pointer
|
||||
status rather than `AKERR_KEY`.
|
||||
|
||||
> `sz` is not copied and `az` is not zeroed, so an actor's depth speed keeps
|
||||
> whatever it had. The default movement logic re-copies all three every step, which
|
||||
> papers over it for anything that simulates.
|
||||
|
||||
## State is a 32-bit bitmask
|
||||
|
||||
An actor is facing left *and* moving left *and* alive at the same time, and **the
|
||||
whole combination is the key that selects a sprite**. It is not an enum and there is
|
||||
no current-state field.
|
||||
|
||||
Thirteen bits are defined:
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
||||
#define AKGL_ACTOR_STATE_DYING (1 << 5) // 32 0000 0000 0010 0000
|
||||
#define AKGL_ACTOR_STATE_DEAD (1 << 6) // 64 0000 0000 0100 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_LEFT (1 << 7) // 128 0000 0000 1000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_RIGHT (1 << 8) // 256 0000 0001 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_UP (1 << 9) // 512 0000 0010 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_DOWN (1 << 10) // 1024 0000 0100 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_IN (1 << 11) // 2048 0000 1000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_OUT (1 << 12) // 4096 0001 0000 0000 0000
|
||||
```
|
||||
|
||||
The trailing bit pattern in that table is the position **within its own 16-bit
|
||||
half**, not the full 32-bit value. `actor.h` says so at the top of the block; read
|
||||
as a whole word it looks like bit 16 restarts at bit 0, which it does not.
|
||||
|
||||
**Bits 13 through 31 are undefined and are yours.** They are declared as
|
||||
`AKGL_ACTOR_STATE_UNDEFINED_13` .. `_31` and — this is the useful part — they are
|
||||
*already named* at the matching indices in `src/actor_state_string_names.c`, so a
|
||||
character JSON can bind a sprite to one without any code change. Renaming a bit means
|
||||
editing both files at the same index; see [Chapter 11](11-characters.md).
|
||||
|
||||
`state` is an `int32_t`, so bit 31 makes it negative and its decimal property key is
|
||||
`-2147483648`. It works. Prefer the low undefined bits.
|
||||
|
||||
Two composite masks are provided:
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
#define AKGL_ACTOR_STATE_FACE_ALL (AKGL_ACTOR_STATE_FACE_DOWN | AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_FACE_UP)
|
||||
#define AKGL_ACTOR_STATE_MOVING_ALL (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP | AKGL_ACTOR_STATE_MOVING_DOWN)
|
||||
```
|
||||
|
||||
**`MOVING_ALL` does not include `MOVING_IN` or `MOVING_OUT`.** It is the four
|
||||
planar directions only. Clearing it leaves the two depth bits set.
|
||||
|
||||
### AKGL_BITMASK_HAS means all of them, not any of them
|
||||
|
||||
The four manipulation macros live in `game.h`:
|
||||
|
||||
| Macro | Means |
|
||||
|---|---|
|
||||
| `AKGL_BITMASK_HAS(x, y)` | **every** bit of `y` is set in `x` — `((x) & (y)) == (y)` |
|
||||
| `AKGL_BITMASK_HASNOT(x, y)` | at least one bit of `y` is missing from `x` |
|
||||
| `AKGL_BITMASK_ADD(x, y)` | set every bit of `y` in `x`; modifies `x` |
|
||||
| `AKGL_BITMASK_DEL(x, y)` | clear every bit of `y` in `x`; modifies `x` |
|
||||
|
||||
The "all, not any" reading is the one that catches people. `AKGL_BITMASK_HAS(state,
|
||||
AKGL_ACTOR_STATE_FACE_ALL)` asks whether the actor is facing **all four ways at
|
||||
once**, which is never. To ask "is it facing at all", test
|
||||
`(state & AKGL_ACTOR_STATE_FACE_ALL)` directly, or `AKGL_BITMASK_HASNOT` against a
|
||||
single bit.
|
||||
|
||||
All five macros are fully parenthesized, so `!AKGL_BITMASK_HAS(a, b)` means what it
|
||||
reads as. Until 0.5.0 it expanded to a bare `(x & y) == y` and a negation bound to
|
||||
the `&`, parsing as `!(a & b) == b`. Nothing in the tree negated it, which is the
|
||||
only reason it was latent rather than live — and the test that distinguishes the
|
||||
two parses is a bit that is *not* set whose value is *not* 1, not the obvious one.
|
||||
|
||||
## The six behaviour hooks
|
||||
|
||||
**Behaviour attaches as function pointers, not by inheritance.** There is no actor
|
||||
subclass. `akgl_actor_initialize` installs the library's defaults on every actor,
|
||||
and a game that wants a different flavour of anything replaces the pointer **on that
|
||||
one actor**.
|
||||
|
||||
| Hook | Default | Called by | Job |
|
||||
|---|---|---|---|
|
||||
| `updatefunc` | `akgl_actor_update` | `akgl_game_update`'s sweep | per-frame logic: `facefunc`, then advance the animation if due |
|
||||
| `renderfunc` | `akgl_actor_render` | `akgl_render_2d_draw_world` | per-frame draw |
|
||||
| `facefunc` | `akgl_actor_automatic_face` | `akgl_actor_update` | choose the facing bits |
|
||||
| `movementlogicfunc` | `akgl_actor_logic_movement` | `akgl_physics_simulate`, before gravity | turn movement bits into signed acceleration |
|
||||
| `changeframefunc` | `akgl_actor_logic_changeframe` | `akgl_actor_update`, when the frame is due | step to the next animation frame |
|
||||
| `addchild` | `akgl_actor_add_child` | you | attach a child actor |
|
||||
|
||||
Replace them after `akgl_actor_initialize`, never before — it overwrites all six.
|
||||
|
||||
The `movementlogicfunc` slot is the interesting one, because it is where
|
||||
`AKGL_ERR_LOGICINTERRUPT` stops being a table row in [Chapter 04](04-errors.md) and
|
||||
becomes something you write. It is **not a failure**; it is a control signal meaning
|
||||
"skip the rest of this tick for this actor", and `akgl_physics_simulate` swallows it:
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/*
|
||||
* A movementlogicfunc that freezes a dying actor. AKGL_ERR_LOGICINTERRUPT is a
|
||||
* control signal, not a failure -- akgl_physics_simulate swallows it and skips
|
||||
* the rest of this actor's step. Only a movementlogicfunc may raise it; from a
|
||||
* backend's gravity or move it aborts the whole step.
|
||||
*/
|
||||
static akerr_ErrorContext *frozen_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
if ( AKGL_BITMASK_HAS(obj->state, AKGL_ACTOR_STATE_DYING) ) {
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKGL_ERR_LOGICINTERRUPT,
|
||||
"%s is dying; skip its step",
|
||||
(char *)obj->name);
|
||||
}
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *freeze_the_dying(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
obj->movementlogicfunc = &frozen_movement;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The default `movementlogicfunc` re-copies the character's speed limits onto the
|
||||
actor each step — so editing a character takes effect next step — and sets `ax`/`ay`
|
||||
to plus or minus the character's acceleration according to which movement bits are
|
||||
set. It integrates nothing; the physics backend does that
|
||||
([Chapter 14](14-physics.md)). Opposing bits do not cancel: `MOVING_LEFT` wins over
|
||||
`MOVING_RIGHT` because it is tested first. **An actor with no movement bits keeps
|
||||
its previous acceleration** rather than being zeroed, which is why the input
|
||||
handlers clear it themselves on release.
|
||||
|
||||
The default `facefunc` clears every facing bit and sets the one matching the first
|
||||
movement bit it finds, in the order left, right, up, down — so a diagonal faces its
|
||||
horizontal component. An actor with `movement_controls_face` clear is left alone
|
||||
entirely, which is the hook for something that aims independently of the way it
|
||||
walks.
|
||||
|
||||
> **An actor that stops moving is left with no facing bit at all**, rather than
|
||||
> keeping the way it was last facing. Its state drops to bare `ALIVE` and no longer
|
||||
> matches a "standing still facing left" sprite. The implementation carries a `TODO`
|
||||
> saying as much. Two workarounds: map bare `ALIVE` to something, or clear
|
||||
> `movement_controls_face` and set the facing bits yourself.
|
||||
|
||||
## layer
|
||||
|
||||
`layer` is which tilemap layer the actor is drawn on, and it is set from the object
|
||||
layer it was placed in. `akgl_render_2d_draw_world` interleaves the map's layers
|
||||
with the actors standing on them, in ascending order — see
|
||||
[Chapter 08](08-rendering.md).
|
||||
|
||||
**Only layers 0 through 15 are drawn.** The field is a `uint32_t` and nothing
|
||||
range-checks it, but `draw_world`'s loop stops at `AKGL_TILEMAP_MAX_LAYERS` (16). An
|
||||
actor on layer 16 is never drawn and never reports why.
|
||||
|
||||
## Parents and children
|
||||
|
||||
`addchild` attaches one actor to another so it moves with it — a carried lantern, a
|
||||
turret on a tank, a party member walking behind the hero. Up to
|
||||
`AKGL_ACTOR_MAX_CHILDREN` (8) per parent.
|
||||
|
||||
**A child is not simulated.** The physics step reaches it, sees a parent, snaps it
|
||||
to the parent's position plus its own offset, and moves on: no thrust, no gravity,
|
||||
no drag, no acceleration. That is the whole behaviour, and games that use it depend
|
||||
on exactly that.
|
||||
|
||||
The parent takes a reference on the child, and **releasing a parent releases every
|
||||
child with it, recursively**. There is no detach function; `AKERR_RELATIONSHIP` is
|
||||
what you get for attaching an actor that already has a parent, and the only way out
|
||||
is to release and rebuild. Running out of slots is `AKERR_OUTOFBOUNDS`.
|
||||
|
||||
> **Nothing checks for a cycle.** Making an actor its own ancestor makes
|
||||
> `akgl_heap_release_actor` recurse until the stack runs out. This builds a tree,
|
||||
> not a graph, and the tree-ness is your responsibility.
|
||||
|
||||
### The offset lives in two places, and they disagree
|
||||
|
||||
This is a real defect and it will bite anyone using children, so it gets stated
|
||||
plainly rather than buried:
|
||||
|
||||
```text
|
||||
akgl_physics_simulate: actor->x = parent->x + actor->vx <- offset is vx
|
||||
akgl_actor_render: dest.x = parent->x + actor->x <- offset is x
|
||||
```
|
||||
|
||||
`src/physics.c` treats `vx`/`vy`/`vz` as the offset and writes the result into
|
||||
`x`/`y`/`z`, turning the child's `x` into an absolute world position.
|
||||
`src/actor.c` then treats `x`/`y` as an offset and adds the parent's position **a
|
||||
second time**. In `akgl_game_update` the physics step runs before the draw, so a
|
||||
child is drawn one full parent-offset away from where it should be.
|
||||
|
||||
`actor.h` documents both readings, in two different places, without noticing they
|
||||
contradict — `akgl_Actor::x` says "For a child, an offset from the parent" and
|
||||
`akgl_Actor::vy` says "On a child actor this is read as an offset from the parent
|
||||
instead."
|
||||
|
||||
It is invisible when the parent sits at the origin, which is why it survived. Until
|
||||
it is fixed, the guard is: **do not call `akgl_actor_render` for a child through
|
||||
`draw_world` if you also run the physics step.** Bind a `renderfunc` on the child
|
||||
that draws at `obj->x`/`obj->y` without re-adding the parent, or keep children
|
||||
purely decorative and position them yourself.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
/* A lantern carried 8 px right of and 8 px above the player. */
|
||||
akerr_ErrorContext *attach_lantern(akgl_Actor *player)
|
||||
{
|
||||
akgl_Actor *lantern = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&lantern));
|
||||
CATCH(errctx, akgl_actor_initialize(lantern, "player lantern"));
|
||||
CATCH(errctx, akgl_actor_set_character(lantern, "lantern"));
|
||||
lantern->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
lantern->visible = true;
|
||||
lantern->layer = player->layer;
|
||||
/* The physics step reads the offset out of vx/vy/vz. */
|
||||
lantern->vx = 8.0f;
|
||||
lantern->vy = -8.0f;
|
||||
CATCH(errctx, player->addchild(player, lantern));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## The eight control handlers
|
||||
|
||||
The `akgl_actor_cmhf_*` functions ("control map handler function") are what
|
||||
`akgl_controller_default` binds the arrow keys and the D-pad to, and what a game's
|
||||
own `akgl_Control` bindings can point at. They take the control map's **target
|
||||
actor**, not a global "player", so they work for any number of locally controlled
|
||||
actors. See [Chapter 15](15-input.md).
|
||||
|
||||
| Handler | Sets | Clears first | Writes |
|
||||
|---|---|---|---|
|
||||
| `akgl_actor_cmhf_left_on` | `MOVING_LEFT \| FACE_LEFT` | `FACE_ALL \| MOVING_ALL` | `ax = -basechar->ax` |
|
||||
| `akgl_actor_cmhf_left_off` | — | `MOVING_LEFT` | `ax = 0`, `tx = 0` |
|
||||
| `akgl_actor_cmhf_right_on` | `MOVING_RIGHT \| FACE_RIGHT` | `FACE_ALL \| MOVING_ALL` | `ax = basechar->ax` |
|
||||
| `akgl_actor_cmhf_right_off` | — | `MOVING_RIGHT` | `ax = 0`, `tx = 0` |
|
||||
| `akgl_actor_cmhf_up_on` | `MOVING_UP \| FACE_UP` | `FACE_ALL \| MOVING_ALL` | `ay = -basechar->ay` |
|
||||
| `akgl_actor_cmhf_up_off` | — | `MOVING_UP` | `ay = 0`, `ty = 0` |
|
||||
| `akgl_actor_cmhf_down_on` | `MOVING_DOWN \| FACE_DOWN` | `FACE_ALL \| MOVING_ALL` | `ay = basechar->ay` |
|
||||
| `akgl_actor_cmhf_down_off` | — | `MOVING_DOWN` | `ay = 0`, `ty = 0` |
|
||||
|
||||
`ay` is negated for *up* because y grows downward.
|
||||
|
||||
**Two directions is not a diagonal.** Every `_on` handler clears the whole of
|
||||
`FACE_ALL | MOVING_ALL` before setting its own two bits, so holding left and up at
|
||||
once moves in whichever was pressed **last**. That is deliberate — it keeps the
|
||||
state word to one facing and one direction, which keeps the sprite mapping in
|
||||
[Chapter 11](11-characters.md) from needing a mapping per diagonal. A game that
|
||||
wants diagonals binds its own handlers that `AKGL_BITMASK_ADD` without clearing, and
|
||||
maps the extra combinations.
|
||||
|
||||
**The `_off` handlers clear exactly two fields.** `ax` and `tx` for the horizontal
|
||||
pair, `ay` and `ty` for the vertical. They do **not** touch `ex`/`ey` and they do
|
||||
not touch `vx`/`vy`.
|
||||
|
||||
`actor.h` still says otherwise — the block comment above them describes the `_off`
|
||||
handlers as "zeroing acceleration, thrust, environmental velocity and velocity on
|
||||
that axis", and `akgl_actor_cmhf_up_off` and `_down_off` each carry a `@note`
|
||||
saying they zero `ey`. **They did, and that was a defect**: `ey` is where the arcade
|
||||
backend accumulates gravity, so tapping down mid-jump stopped the character in the
|
||||
air. Velocity was never theirs to clear either — `simulate` recomputes `v` as
|
||||
`e + t` every step. Those notes predate the fix.
|
||||
|
||||
> **Known defect (`TODO.md`, "Arcade physics feel").** There is no friction and no
|
||||
> deceleration: zeroing `tx` on release **stops the actor dead**. Correct for Zelda,
|
||||
> wrong for Mario. Bind your own `_off` handler that decays `tx` over time if you
|
||||
> want momentum.
|
||||
|
||||
Releasing left while holding right also stops the rightward movement, since
|
||||
`left_off` clears the whole x axis whichever way the actor was going.
|
||||
|
||||
## Warning: an error inside `akgl_registry_iterate_actor` exits the process
|
||||
|
||||
`akgl_registry_iterate_actor` is an `SDL_EnumerateProperties` callback. SDL
|
||||
callbacks return `void`, so it has nowhere to propagate an error to — it ends in
|
||||
`FINISH_NORETURN`, which logs the stack trace and then calls libakerror's
|
||||
unhandled-error handler, **whose default implementation calls `akerr_exit()` and
|
||||
terminates the process**.
|
||||
|
||||
Everything it can go wrong on is ordinary content:
|
||||
|
||||
- a registry key with no pointer behind it — `AKERR_KEY`;
|
||||
- an actor whose `updatefunc` fails;
|
||||
- `akgl_tilemap_scale_actor` failing;
|
||||
- an actor whose `renderfunc` fails;
|
||||
- a `NULL` `userdata` — the iterator is required despite the `void *`.
|
||||
|
||||
**A wrong name in an asset file therefore kills the game rather than skipping an
|
||||
actor.** This is not theoretical: it is the first thing a reader hits when a sprite
|
||||
or character name is misspelled.
|
||||
|
||||
`akgl_game_update` does not use this callback — it sweeps the actor pool directly —
|
||||
so you reach it by driving a registry sweep yourself, which is what
|
||||
`util/charviewer.c` does:
|
||||
|
||||
```c norun
|
||||
SDL_EnumerateProperties(AKGL_REGISTRY_ACTOR, &akgl_registry_iterate_actor, (void *)&opflags);
|
||||
```
|
||||
|
||||
(`norun` because it needs a live registry and a populated `opflags`; it is quoted to
|
||||
show the shape, and `util/charviewer.c` is the compiled instance of it.)
|
||||
|
||||
The same hazard is in `akgl_character_state_sprites_iterate`, which
|
||||
**`akgl_heap_release_character` calls on every character release** — so that one is
|
||||
on an ordinary path, not an unusual one. See [Chapter 11](11-characters.md).
|
||||
|
||||
If a game should survive these, install your own handler:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
static akerr_ErrorContext *last_callback_error = NULL;
|
||||
|
||||
/*
|
||||
* The default akerr_handler_unhandled_error calls akerr_exit(). Replacing it
|
||||
* is the only way an error raised inside an SDL callback does not take the
|
||||
* process with it. Take a reference: the context is released after this
|
||||
* returns.
|
||||
*/
|
||||
static void survive_unhandled_error(akerr_ErrorContext *errctx)
|
||||
{
|
||||
if ( errctx == NULL ) {
|
||||
return;
|
||||
}
|
||||
LOG_ERROR(errctx);
|
||||
errctx->refcount += 1;
|
||||
last_callback_error = errctx;
|
||||
}
|
||||
|
||||
void install_survivable_handler(void)
|
||||
{
|
||||
akerr_handler_unhandled_error = &survive_unhandled_error;
|
||||
}
|
||||
```
|
||||
|
||||
Note what surviving costs you: the sweep carries on with the *next* entry, and the
|
||||
actor that failed simply did not update or draw this frame. Check
|
||||
`last_callback_error` somewhere, or you have traded a loud crash for a silent one.
|
||||
|
||||
## The rest of the struct
|
||||
|
||||
A few fields worth calling out; the full annotated list is in the generated Doxygen
|
||||
for `akgl_Actor`.
|
||||
|
||||
| Field | Note |
|
||||
|---|---|
|
||||
| `visible` | Deliberate hiding. Off-camera actors are skipped separately. |
|
||||
| `scale` | Draw scale. Written by `akgl_tilemap_scale_actor`, or forced to 1.0 when tilemap scaling is off — **it is overwritten every frame**, so setting it by hand does not stick. |
|
||||
| `actorData` | Your per-actor data. The library never reads or frees it. |
|
||||
| `curSpriteFrameId` | Index into the sprite's `frameids` playlist, **not** a frame number on the sheet. See [Chapter 10](10-spritesheets-and-sprites.md). |
|
||||
| `movetimer` | Unused. Nothing in the library reads or writes it. |
|
||||
| `vx`/`vy`/`vz` | Recomputed each step as `e + t`. Writing them directly is overwritten — except on a child, where they are the offset. |
|
||||
|
||||
## Known defects worth knowing here
|
||||
|
||||
- **The parent/child offset is double-counted at draw time.** See above.
|
||||
- **`akgl_heap_release_actor` recurses with no cycle check.**
|
||||
- **Long names register and unregister under different keys.**
|
||||
`akgl_actor_initialize` writes the registry entry under the *caller's* `name`
|
||||
pointer while `akgl_heap_release_actor` clears it under the actor's truncated
|
||||
128-byte copy. Identical for ordinary names; divergent past 127 bytes, and then
|
||||
the registry keeps an entry pointing at a zeroed slot. Related to `TODO.md`,
|
||||
"Truncated registry keys can collide".
|
||||
- **`akgl_actor_initialize` silently replaces a same-named actor**, and the one it
|
||||
displaced becomes unreachable rather than being released.
|
||||
- **No friction on release**, and **no terminal velocity** under gravity —
|
||||
`TODO.md`, "Arcade physics feel". [Chapter 14](14-physics.md) covers both.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 11](11-characters.md) — the template, and the state-to-sprite map.
|
||||
- [Chapter 14](14-physics.md) — what happens to `t`, `e`, `v` and `a` each step.
|
||||
- [Chapter 15](15-input.md) — control maps, and binding the handlers above.
|
||||
- [Chapter 08](08-rendering.md) — where `renderfunc` gets called from.
|
||||
- [Chapter 04](04-errors.md) — `AKGL_ERR_LOGICINTERRUPT` and the rest of the
|
||||
status tables.
|
||||
406
docs/13-tilemaps.md
Normal file
406
docs/13-tilemaps.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# 13. Tilemaps
|
||||
|
||||
libakgl does not have a map format. It has a *loader* for somebody else's, and the
|
||||
somebody else is the [Tiled map editor](https://www.mapeditor.org/). Levels are authored in
|
||||
Tiled, saved as JSON (`.tmj`), and read by `akgl_tilemap_load`.
|
||||
|
||||
That division is deliberate, and it means this chapter owes you almost nothing about the
|
||||
format itself. What a layer is, what a tileset is, how `firstgid` works, how custom
|
||||
properties are stored — all of that is
|
||||
[the Tiled JSON map format reference](https://doc.mapeditor.org/en/stable/reference/json-map-format/),
|
||||
maintained by the people who write the editor. Read it there.
|
||||
|
||||
What this chapter owes you is the part Tiled cannot tell you: **which subset of the format
|
||||
libakgl actually reads, what it refuses, the three things it adds on top, and how big the
|
||||
resulting object is.** All four have bitten somebody.
|
||||
|
||||
## The size of an `akgl_Tilemap`
|
||||
|
||||
Start here, because it is the one that produces a crash rather than an error message.
|
||||
|
||||
```text
|
||||
sizeof(akgl_Tilemap) = 26,388,008 bytes = 25.2 MiB
|
||||
|
||||
layers[16] 16 x 1,120,296 = 17.1 MiB 512x512 ints of tile data, plus
|
||||
128 x 560-byte objects, per layer
|
||||
tilesets[16] 16 x 528,944 = 8.1 MiB 65,536 x 2 ints of tile offsets,
|
||||
plus a PATH_MAX image path, each
|
||||
everything else = ~0.0 MiB geometry, perspective, physics
|
||||
```
|
||||
|
||||
**An `akgl_Tilemap` belongs in static storage and nowhere else.** A default thread stack is
|
||||
8 MiB on Linux; this object is three times that, so a local one blows the stack before the
|
||||
loader writes its first byte, and it does so as a segfault with no error context. That is
|
||||
why the library's own map, `akgl_default_gamemap`, is a file-scope object in
|
||||
`src/game.c`, and why `akgl_gamemap` is a pointer to it rather than a copy.
|
||||
|
||||
The bounds are fixed at compile time, so the size is fixed too. Every field is present
|
||||
whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same
|
||||
25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and
|
||||
changes the ABI — see [Chapter 21](21-appendix-limits.md).
|
||||
|
||||
## Limits
|
||||
|
||||
```c excerpt=include/akgl/tilemap.h
|
||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
||||
/** @brief Tallest map, in tiles. */
|
||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
||||
/** @brief Tilesets per map. */
|
||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
||||
/** @brief Longest tileset name. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
||||
/** @brief Longest resolved tileset image path. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
||||
```
|
||||
|
||||
Two of these carry a wrinkle worth knowing before you lay out a level.
|
||||
|
||||
**The width and height bound is on the product, and it is exclusive.** `akgl_tilemap_load`
|
||||
refuses when `width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, so
|
||||
262,144 cells is one too many: **a literal 512x512 map is rejected with
|
||||
`AKERR_OUTOFBOUNDS`**, and 512x511 is the largest rectangle that loads. The same
|
||||
off-by-one applies per tile layer in `akgl_tilemap_load_layer_tile`. Nothing is wrong with
|
||||
either check other than the `>=`; it is recorded here because "the maximum is 512x512" is
|
||||
what the constant names say and is not what the code does.
|
||||
|
||||
**The doc comment on `AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` is stale.** It says the loader
|
||||
does not enforce it. The loader does — `akgl_tilemap_load_layer_objects` checks `j` at the
|
||||
top of the loop body and raises `AKERR_OUTOFBOUNDS` naming the layer, and
|
||||
`akgl_tilemap_load_tilesets` does the same against `AKGL_TILEMAP_MAX_TILESETS`. Both were
|
||||
genuinely unbounded and both were fixed in 0.5.0 (`TODO.md`, "Found while rewriting the
|
||||
Doxygen comments" item 17), with fixtures in `tests/tilemap.c` at exactly 128, 129 and 17.
|
||||
Trust the code.
|
||||
|
||||
## What the loader accepts
|
||||
|
||||
The loader reads a strict subset of the TMJ format. Everything in this section is a
|
||||
requirement, not a suggestion: a key that is absent raises `AKERR_KEY` and a key with the
|
||||
wrong JSON type raises `AKERR_TYPE`, both naming what was wanted. See
|
||||
[Chapter 4](04-errors.md) for what those statuses mean in libakgl.
|
||||
|
||||
| Level | Members `akgl_tilemap_load` requires | Notes |
|
||||
|---|---|---|
|
||||
| Map root | `tilewidth`, `tileheight`, `width`, `height`, `layers`, `tilesets` | `orientation` is read from nowhere and forced to 0; isometric is not implemented |
|
||||
| Every layer | `id`, `opacity`, `visible`, `x`, `y`, `type` | A `type` other than the three below keeps these fields and is otherwise skipped |
|
||||
| `tilelayer` | `width`, `height`, `data` | `data` must be a plain array of integers |
|
||||
| `imagelayer` | `image` | Layer `width`/`height` are taken from the loaded texture, not the JSON |
|
||||
| `objectgroup` | `objects` | See below — every object has requirements of its own |
|
||||
| Every object | `name`, `x`, `y`, `visible`, `type` | **Including plain rectangles.** An object with no `type` member fails the whole load |
|
||||
| Every tileset | `columns`, `firstgid`, `imagewidth`, `imageheight`, `margin`, `spacing`, `tilecount`, `tilewidth`, `tileheight`, `name`, `image` | The **embedded** form; see below |
|
||||
|
||||
Three of these deserve their own paragraph.
|
||||
|
||||
**Tilesets must be embedded, not external.** Tiled writes a tileset into the map's
|
||||
`tilesets` array in one of two shapes: the whole tileset inline, or a two-member stub
|
||||
`{"firstgid": N, "source": "tiles.tsj"}` pointing at a separate file.
|
||||
`akgl_tilemap_load_tilesets_each` reads `columns`, `image` and the rest directly out of the
|
||||
array element and there is no code anywhere in `src/` that opens a `source` file, so **an
|
||||
external tileset reference fails with `AKERR_KEY` on the missing `columns`.** Turn
|
||||
*Embed tileset* on when you save, or use Tiled's *Map → Embed Tilesets* command. The
|
||||
shipped fixture `tests/assets/testmap.tmj` is embedded, which is what makes it load.
|
||||
|
||||
**Every object needs a `type` string, and `type` is where the extensions live.** The loader
|
||||
reads it on every object in an object group and compares it against `"actor"` and
|
||||
`"perspective"`; anything else is recorded in the layer's object array and otherwise
|
||||
ignored. An object with the member missing entirely does not reach that comparison — it
|
||||
fails the load. If your editor writes the object's class under a different member name,
|
||||
that is the drift to check first; Tiled's own field naming has changed across releases, and
|
||||
the fixture in this repository is a Tiled 1.8.2 export.
|
||||
|
||||
**The map file's directory is the root every path resolves against**, so a map and its art
|
||||
move together. The two resolvers are not the same, though, and the difference matters:
|
||||
|
||||
| Path | Resolved by | Consequence |
|
||||
|---|---|---|
|
||||
| A tileset's `image` | `akgl_path_relative` | Canonicalized against the map's directory; an absolute path works |
|
||||
| An image layer's `image` | `aksl_snprintf("%s/%s", ...)` | A plain join. **Not** canonicalized, so an absolute path becomes `/maps//art/sky.png` and does not open |
|
||||
|
||||
Both are bounded at `AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE` (`PATH_MAX`). The image-layer
|
||||
join raises `AKERR_OUTOFBOUNDS` naming both lengths when it does not fit; it used to
|
||||
truncate silently, and an over-long path reported itself as a missing file with a name the
|
||||
caller never wrote.
|
||||
|
||||
## libakgl's three extensions to Tiled
|
||||
|
||||
None of these are part of the TMJ format. They are conventions the loader imposes on
|
||||
custom properties and object types, and Tiled will happily author all three without
|
||||
knowing what they mean.
|
||||
|
||||
### 1. Object type `actor` spawns a real actor
|
||||
|
||||
An object whose `type` is the string `"actor"` does not stay a rectangle. The loader
|
||||
claims an actor from the pool, initializes it, binds a character, publishes it in
|
||||
`AKGL_REGISTRY_ACTOR`, and copies the object's position, visibility and layer index onto
|
||||
it. The object's `name` is the registry key. See [Chapter 12](12-actors.md) for what an
|
||||
actor is and [Chapter 11](11-characters.md) for what a character supplies.
|
||||
|
||||
Two custom properties are required on the object:
|
||||
|
||||
| Property | Tiled type | Meaning |
|
||||
|---|---|---|
|
||||
| `character` | `string` | Name of a character already in `AKGL_REGISTRY_CHARACTER` |
|
||||
| `state` | `int` | The actor's initial `AKGL_ACTOR_STATE_*` bitmask, **as a number** |
|
||||
|
||||
**`state` is an integer here, and only here.** Character JSON accepts the string-array form
|
||||
— `["AKGL_ACTOR_STATE_ALIVE", "AKGL_ACTOR_STATE_FACE_DOWN"]` — because
|
||||
`AKGL_ACTOR_STATE_STRING_NAMES` exists to decode it. `akgl_tilemap_load_layer_object_actor`
|
||||
calls `akgl_get_json_properties_integer`, which demands a property Tiled declared as
|
||||
`"int"`, so a map that spells the state as names raises `AKERR_TYPE`. Add up the bit values
|
||||
from [Chapter 12](12-actors.md) and write the sum.
|
||||
|
||||
Two consequences of the registry being the identity:
|
||||
|
||||
- **The characters have to be loaded before the map.** An unregistered `character` name
|
||||
comes back as `AKERR_KEY` from `akgl_actor_set_character`, in the middle of a partly
|
||||
loaded map.
|
||||
- **Two objects naming one actor do not make two actors.** The second placement takes
|
||||
another reference on the first and overwrites its position, so the actor ends up wherever
|
||||
the last object put it. That is the mechanism for placing one actor from two layers; it
|
||||
is not a way to spawn a crowd.
|
||||
|
||||
An `actor` object with an empty `name` is refused with `AKERR_KEY` — an actor has to be
|
||||
addressable.
|
||||
|
||||
### 2. A `physics.model` property gives the map its own physics
|
||||
|
||||
A custom property named `physics.model` on the **map root** hands the map's name to
|
||||
`akgl_physics_factory` and stores the resulting backend in `map->physics`, then sets
|
||||
`map->use_own_physics`. Six more properties configure it, each defaulting to `0.0` when
|
||||
absent:
|
||||
|
||||
| Property | Tiled type | Sets |
|
||||
|---|---|---|
|
||||
| `physics.model` | `string` | Which backend — `null` or `arcade`. See [Chapter 14](14-physics.md) |
|
||||
| `physics.gravity.x` / `.y` / `.z` | `float` | The backend's gravity constants |
|
||||
| `physics.drag.x` / `.y` / `.z` | `float` | The backend's drag coefficients |
|
||||
|
||||
A swimming level and a walking level then differ by data rather than by code.
|
||||
|
||||
**Nothing in the library acts on `use_own_physics`.** `akgl_game_update` steps the global
|
||||
`akgl_physics` and never looks at the map's. The flag is a signal to *you*: read it after
|
||||
loading and point `akgl_physics` at `map->physics` if you want it honoured. The example
|
||||
below does exactly that.
|
||||
|
||||
A map with no `properties` array at all, or with properties but no `physics.model`, is the
|
||||
normal case: `akgl_tilemap_load_physics` returns success and the map uses the game's global
|
||||
backend. The `AKERR_KEY` handlers in that function are the "map did not ask" path, not
|
||||
error paths. A `physics.model` naming a backend that does not exist *is* an error.
|
||||
|
||||
### 3. `p_foreground` and `p_vanishing` define the pseudo-3D band
|
||||
|
||||
Two objects in an object layer, each with `"type": "perspective"` and a `scale` custom
|
||||
property declared `float`, define a band down the screen over which actors are scaled. That
|
||||
is what makes a character look further away as they walk up a top-down map.
|
||||
|
||||
```text
|
||||
y = 0 ----------------------------------------------------- top of the map
|
||||
.
|
||||
. actors above p_vanishing_y stay at
|
||||
. p_vanishing_scale
|
||||
p_vanishing_y ----------- [tiny actor] scale = p_vanishing_scale
|
||||
/ \
|
||||
/ \ linear interpolation between the
|
||||
/ \ two rows, at p_rate per pixel of y
|
||||
/ \
|
||||
p_foreground_y --------- [ big actor ] scale = p_foreground_scale
|
||||
actors below here stay at
|
||||
p_foreground_scale
|
||||
```
|
||||
|
||||
The names are matched literally: `p_foreground` sets the row where actors are at full size,
|
||||
`p_vanishing` the row where they are smallest. **Both the type and the name have to be
|
||||
right.** The loader checks `type == "perspective"` first and only then compares the name,
|
||||
so an object named `p_vanishing` with type `"object"` is silently just an object — no error,
|
||||
no perspective, and a very confusing afternoon.
|
||||
|
||||
Each marker contributes its `y` and its `scale`; `p_rate` is derived at load as the scale
|
||||
change per pixel between the two. **A marker at `y = 0` disables perspective**, because the
|
||||
rate is only computed when both `p_foreground_y` and `p_vanishing_y` are non-zero — and a
|
||||
map with no markers has both scales at 1.0 and a rate of 0, so the whole feature is a no-op
|
||||
rather than a special case. Perspective markers are forced invisible: they are geometry,
|
||||
not scenery.
|
||||
|
||||
`akgl_tilemap_scale_actor` applies the band, writing only the actor's `scale`. It is called
|
||||
per actor by `akgl_game_update` against the **global** `akgl_gamemap` when the frame's
|
||||
iterator carries `AKGL_ITERATOR_OP_TILEMAPSCALE`; see [Chapter 7](07-the-game-and-the-frame.md).
|
||||
The marker heights (`p_foreground_h`, `p_vanishing_h`) are recorded and not used by the
|
||||
current rate calculation.
|
||||
|
||||
## A minimal map
|
||||
|
||||
Two 16-pixel tiles square, one tile layer, one embedded tileset, and one actor object. The
|
||||
state value `17` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN` — 16 plus 1, from
|
||||
the bit table in [Chapter 12](12-actors.md). Add the values you want; there is no symbolic
|
||||
form on this side.
|
||||
|
||||
```json kind=tilemap setup=tilemap
|
||||
{
|
||||
"compressionlevel": -1,
|
||||
"height": 2,
|
||||
"infinite": false,
|
||||
"nextlayerid": 3,
|
||||
"nextobjectid": 2,
|
||||
"orientation": "orthogonal",
|
||||
"renderorder": "right-down",
|
||||
"tiledversion": "1.8.2",
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16,
|
||||
"type": "map",
|
||||
"version": "1.8",
|
||||
"width": 2,
|
||||
"layers": [
|
||||
{
|
||||
"data": [1, 2, 3, 4],
|
||||
"height": 2,
|
||||
"id": 1,
|
||||
"name": "Tile Layer 1",
|
||||
"opacity": 1,
|
||||
"type": "tilelayer",
|
||||
"visible": true,
|
||||
"width": 2,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"draworder": "topdown",
|
||||
"id": 2,
|
||||
"name": "Object Layer 1",
|
||||
"opacity": 1,
|
||||
"type": "objectgroup",
|
||||
"visible": true,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"objects": [
|
||||
{
|
||||
"gid": 195,
|
||||
"height": 16,
|
||||
"id": 1,
|
||||
"name": "testactor",
|
||||
"rotation": 0,
|
||||
"type": "actor",
|
||||
"visible": true,
|
||||
"width": 16,
|
||||
"x": 16,
|
||||
"y": 16,
|
||||
"properties": [
|
||||
{ "name": "character", "type": "string", "value": "testcharacter" },
|
||||
{ "name": "state", "type": "int", "value": 17 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tilesets": [
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 1,
|
||||
"image": "assets/tileset.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Loading one
|
||||
|
||||
```c
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/*
|
||||
* 25 MiB of tilemap. Static storage, never a local: a stack frame this size
|
||||
* overruns the default 8 MiB thread stack before the loader writes a byte.
|
||||
*/
|
||||
static akgl_Tilemap townmap;
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *load_town(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* Reusing a map struct leaks the previous map's textures unless this runs
|
||||
* first -- akgl_tilemap_load zeroes rather than releases. */
|
||||
PASS(errctx, akgl_tilemap_release(&townmap));
|
||||
PASS(errctx, akgl_tilemap_load("assets/town.tmj", &townmap));
|
||||
|
||||
/* The map may have brought its own physics with it. */
|
||||
if ( townmap.use_own_physics == true ) {
|
||||
akgl_physics = &townmap.physics;
|
||||
}
|
||||
akgl_gamemap = &townmap;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Loading a map has side effects well beyond the destination struct: textures are uploaded,
|
||||
actors are created in the pool, and names are published in the registry. So **the renderer,
|
||||
the pools, the registries and every character the map names have to be in place first.**
|
||||
The startup order that produces that is in [Chapter 7](07-the-game-and-the-frame.md).
|
||||
|
||||
Drawing is per layer. `akgl_tilemap_draw(map, viewport, layeridx)` draws one layer clipped
|
||||
to a viewport in map pixels — usually `akgl_camera` — and draws the tiles at the viewport's
|
||||
edges partially, so scrolling is smooth rather than snapping to the grid. An image layer
|
||||
ignores the viewport and is drawn once at the origin. `akgl_render_2d_draw_world` makes one
|
||||
pass per layer; see [Chapter 8](08-rendering.md).
|
||||
|
||||
## Known defects
|
||||
|
||||
Documented here because this is where a reader hits them. Each is cross-referenced to
|
||||
`TODO.md`.
|
||||
|
||||
**A failed load releases nothing.** `akgl_tilemap_load` zeroes the destination up front and
|
||||
then loads physics, geometry, layers and tilesets in order. If any of that fails, the
|
||||
textures already uploaded and the actors already created stay exactly where they are, and
|
||||
the destination holds a half-built map. `CLEANUP` gives back the JSON document and one pool
|
||||
string and nothing else. **Treat a failed load as needing `akgl_tilemap_release` and a
|
||||
fresh actor registry** before you try again — otherwise the retry inherits the corpse, and
|
||||
`akgl_tilemap_load` will zero the struct over the previous attempt's texture pointers and
|
||||
leak them.
|
||||
|
||||
**`akgl_tilemap_release` does not release the actors an object layer created.** It destroys
|
||||
tileset and image-layer textures and clears each pointer as it goes — that half is correct
|
||||
as of 0.5.0, and a second release is safe rather than a use-after-free (`TODO.md`, "Known
|
||||
and still open" item 2). The actors, and their references on characters and sprites, are
|
||||
yours to release. The header's `@warning` describing a tileset double-free is stale; the
|
||||
loop it describes was fixed and `tests/tilemap.c` releases the fixture three times and
|
||||
asserts every texture pointer is `NULL`.
|
||||
|
||||
**`akgl_tilemap_draw` does not bounds-check `layeridx`.** An index at or past `numlayers`
|
||||
reads a neighbouring layer, and one past 16 reads past the array. `akgl_tilemap_draw_tileset`
|
||||
checks its upper bound but not for a negative index.
|
||||
|
||||
**A tileset's `margin` is recorded and never applied.** `akgl_tilemap_compute_tileset_offsets`
|
||||
steps by tile size plus `spacing` and ignores `margin` entirely, so a tileset image with a
|
||||
border produces offsets shifted by it — every tile drawn from slightly the wrong place. The
|
||||
FIXME is at `src/tilemap.c:187`. Author tilesets with `margin: 0` until it is fixed.
|
||||
|
||||
**A second tileset wastes its whole offset table.** The offset table is indexed by local
|
||||
tile id from 0, so tileset B's entries sit at the front of B's own 65,536-entry table while
|
||||
`akgl_tilemap_draw` indexes it by `tilenum - firstgid` — correct, but it means every
|
||||
tileset pays 512 KiB regardless of how many tiles it has. The FIXME on
|
||||
`akgl_Tileset::tile_offsets` explains it. Consequence for you: prefer one large tileset per
|
||||
map to five small ones.
|
||||
|
||||
**Tileset images do not go through the spritesheet registry**, so an image shared between
|
||||
two maps is loaded twice, into two textures.
|
||||
|
||||
**`numlayers` is set before the layer bound is checked.** `akgl_tilemap_load_layers` assigns
|
||||
the JSON array's length and *then* refuses a seventeenth layer, so on that failure path the
|
||||
struct briefly claims more layers than it has. It is only reachable on a path that has
|
||||
already failed, but do not read `numlayers` off a map whose load raised.
|
||||
366
docs/14-physics.md
Normal file
366
docs/14-physics.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 14. Physics
|
||||
|
||||
The physics subsystem has the same shape as the renderer: a record of function pointers
|
||||
plus an initializer that fills it in. Two backends ship. `null` accepts every call and
|
||||
changes nothing, which is what a menu screen or a test harness wants. `arcade` applies
|
||||
gravity, drag, thrust and a speed cap, which is what a 2D game wants.
|
||||
|
||||
**There is no "physics body" type.** The body *is* the actor plus the character it
|
||||
instantiates. An actor carries the position and the three velocity terms; its character
|
||||
supplies the accelerations and the top speeds. Nothing else participates. If you have read
|
||||
[Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) you have already met every
|
||||
field this chapter uses.
|
||||
|
||||
## The model
|
||||
|
||||
```text
|
||||
thrust (tx, ty, tz) the actor's own effort, while it is moving
|
||||
+
|
||||
environ (ex, ey, ez) gravity, less drag -- accumulates across steps
|
||||
=
|
||||
velocity(vx, vy, vz) recomputed every step; writing it directly is overwritten
|
||||
(a CHILD actor is the exception -- see below)
|
||||
|
||||
caps (sx, sy, sz) from the character. Caps the THRUST VECTOR, not velocity
|
||||
accel (ax, ay, az) from the character, signed by the direction of travel
|
||||
```
|
||||
|
||||
Two properties of that arrangement carry all the consequences.
|
||||
|
||||
**The cap is on thrust, not on velocity.** `sx`/`sy`/`sz` are the character's own top
|
||||
speed, not a speed limit on the world, and `akgl_physics_arcade_gravity` touches only the
|
||||
environmental term. So a falling actor accelerates straight past its stated top speed —
|
||||
which is the point, for a falling one. It also means there is no terminal velocity; see
|
||||
below.
|
||||
|
||||
**The cap is on the thrust *vector*, scaled to an ellipse, not on each axis separately.**
|
||||
|
||||
```text
|
||||
ty
|
||||
| Capping each axis against its own limit
|
||||
......|...... admits the corner of the box: an actor
|
||||
..' | '.. holding two directions got both caps
|
||||
.' +---+---+ '. at once and travelled their
|
||||
: | | | : diagonal -- 41% faster than either
|
||||
: ----+---+---+---- : alone. Scaling the vector to the
|
||||
----:------+---+---+------:---- tx ellipse keeps a character
|
||||
: | | | : whose horizontal and vertical
|
||||
:. +---+---+ .: speeds differ moving at the ratio
|
||||
'.. | sx ..' it asked for, and makes them equal
|
||||
''|.......''' where the speeds are.
|
||||
|
|
||||
dotted ellipse: the cap solid box: the old per-axis clamp
|
||||
```
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
overshoot = 0.0f;
|
||||
if ( actor->sx != 0.0f ) {
|
||||
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
|
||||
} else {
|
||||
actor->tx = 0.0f;
|
||||
}
|
||||
if ( actor->sy != 0.0f ) {
|
||||
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
||||
} else {
|
||||
actor->ty = 0.0f;
|
||||
}
|
||||
if ( actor->sz != 0.0f ) {
|
||||
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
|
||||
} else {
|
||||
actor->tz = 0.0f;
|
||||
}
|
||||
if ( overshoot > 1.0f ) {
|
||||
thrustscale = 1.0f / sqrtf(overshoot);
|
||||
actor->tx *= thrustscale;
|
||||
actor->ty *= thrustscale;
|
||||
actor->tz *= thrustscale;
|
||||
}
|
||||
```
|
||||
|
||||
An axis whose top speed is zero cannot be thrust along at all — that is what the old
|
||||
per-axis clamp did with it — and it stays out of the magnitude entirely, because dividing
|
||||
by it would not end well.
|
||||
|
||||
**Only x and y ever accumulate thrust.** `akgl_physics_simulate` reads the
|
||||
`AKGL_ACTOR_STATE_MOVING_LEFT`/`RIGHT` bits into `tx` and `MOVING_UP`/`DOWN` into `ty`.
|
||||
`AKGL_ACTOR_STATE_MOVING_IN` and `MOVING_OUT` exist and nothing reads them, so `tz` is only
|
||||
ever zeroed or scaled — the z axis has gravity, drag and movement but **no thrust an actor
|
||||
can apply**. Set `tz` yourself from a `movementlogicfunc` if you need depth motion.
|
||||
|
||||
## One step, in order
|
||||
|
||||
`akgl_physics_simulate` is shared by both backends; what differs is the `gravity` and
|
||||
`move` they point at. It walks the whole actor pool in index order, and per actor:
|
||||
|
||||
```text
|
||||
refcount == 0 ? ------------------------------> skip (free pool slot)
|
||||
parent != NULL ? -> x,y,z = parent's + own v, -> skip (never simulates)
|
||||
basechar == NULL ? ------------------------------> skip (no speeds)
|
||||
LAYERMASK set and actor->layer != layerid ? -------> skip
|
||||
|
||||
tx += ax * dt if MOVING_LEFT or MOVING_RIGHT
|
||||
ty += ay * dt if MOVING_UP or MOVING_DOWN
|
||||
scale (tx,ty,tz) to the (sx,sy,sz) ellipse if it is outside
|
||||
|
||||
+- ATTEMPT ----------------------------------------------------------+
|
||||
| actor->movementlogicfunc(actor, dt) |
|
||||
| AKGL_ERR_LOGICINTERRUPT raised here is swallowed: the actor |
|
||||
| is skipped for the rest of the tick and the loop continues |
|
||||
| self->gravity(self, actor, dt) |
|
||||
| ex -= ex * drag_x * dt (and y, z, each only if drag is nonzero) |
|
||||
| vx = ex + tx (and y, z) |
|
||||
| self->move(self, actor, dt) |
|
||||
+--------------------------------------------------------------------+
|
||||
|
||||
after the loop: self->gravity_time = curtime
|
||||
```
|
||||
|
||||
**A child actor is snapped and skipped.** An actor with a `parent` has its position set to
|
||||
the parent's plus its own `vx`/`vy`/`vz`, read as a fixed offset rather than as a velocity,
|
||||
and then it is skipped entirely. Children do not simulate. That is the mechanism a party
|
||||
member or a held item is built on — see [Chapter 12](12-actors.md).
|
||||
|
||||
**The thrust accumulation happens *before* `movementlogicfunc`, which is what sets `ax` and
|
||||
`sx`.** So the `ax` and `sx` a step uses are the ones the *previous* step's movement logic
|
||||
installed, and an actor's very first step sees `sx == 0` and has its thrust zeroed. The
|
||||
header's summary — "movement logic, then gravity, then drag, then velocity, then move" —
|
||||
leaves that out. It is one frame of lag at the start of a movement and no player will see
|
||||
it, but it is the reason a unit test that calls `simulate` once and checks `tx` gets zero.
|
||||
|
||||
**`gravity_time` is stamped after the loop, not before it.** A `gravity` or `move` that
|
||||
raises returns straight out of `akgl_physics_simulate` without stamping, so the *next*
|
||||
step measures `dt` from the older epoch and takes a double-length step — bounded by
|
||||
`max_timestep`, but still a visible jump. Treat a raised status out of `simulate` as
|
||||
needing a re-stamp of `gravity_time` before you continue.
|
||||
|
||||
## `dt` is not yours to supply
|
||||
|
||||
`akgl_physics_simulate` takes no timestep. It calls `SDL_GetTicksNS()` itself and measures
|
||||
against `self->gravity_time`, then bounds the result:
|
||||
|
||||
```text
|
||||
dt = (SDL_GetTicksNS() - self->gravity_time) / 1e9
|
||||
if ( max_timestep > 0 and dt > max_timestep ) dt = max_timestep
|
||||
if ( dt < 0 ) dt = 0
|
||||
```
|
||||
|
||||
`max_timestep` comes from the `physics.max_timestep` property and defaults to
|
||||
`AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` — a twentieth of a second, three frames at 60 Hz, so an
|
||||
ordinary dropped frame passes through untouched and a level load does not.
|
||||
|
||||
**That bound is not a nicety.** Everything below it multiplies by `dt`, so one enormous
|
||||
`dt` moves every actor by however far its velocity carries it over the whole stall. A
|
||||
quarter-second load under platformer gravity is a hundred pixels of fall between the first
|
||||
frame and the second, straight through whatever was underneath. That was a live defect
|
||||
until 0.6.0: `gravity_time` was never seeded, so the first step measured the entire time
|
||||
since SDL started. Both initializers seed it now, and the bound catches the rest. Advancing
|
||||
the world in slow motion through a hitch is the trade every engine makes here.
|
||||
|
||||
A zero or negative `max_timestep` disables the bound, for a caller who would rather have
|
||||
the real elapsed time and handle it themselves.
|
||||
|
||||
### Stepping deterministically in a test
|
||||
|
||||
Because `dt` is measured rather than passed, **there is no way to step the engine by an
|
||||
exact interval — but there is a way to step it by an exact bound.** `tests/physics_sim.c`
|
||||
documents the only technique that holds up under load:
|
||||
|
||||
```text
|
||||
sim_physics.max_timestep = dt; /* the step you want */
|
||||
sim_physics.gravity_time = 0; /* measured interval = whole process uptime */
|
||||
sim_physics.simulate(&sim_physics, NULL);
|
||||
```
|
||||
|
||||
Zeroing `gravity_time` makes the measured interval the process's whole uptime, which is
|
||||
always past the bound, so the step taken is `max_timestep` and nothing else. The scheduler
|
||||
cannot get in the way of that. The first version of that helper set `gravity_time` to
|
||||
`now - dt` and let the real clock supply the step; a machine busy enough to deschedule the
|
||||
process between that store and the `SDL_GetTicksNS()` inside `simulate` produced a longer
|
||||
step and a red suite, exactly once, under a parallel `ctest`. **That the engine cannot be
|
||||
stepped exactly is itself a finding**, recorded in `TODO.md` under "Arcade physics feel".
|
||||
|
||||
Two more rules from that suite, both learned the hard way. `main` must call `SDL_Init` —
|
||||
without it SDL sets its clock epoch on first use, `SDL_GetTicksNS()` returns something
|
||||
around 130 ns, and every `dt`-dependent assertion passes for a reason unrelated to the
|
||||
code. And read the numbers each simulation prints even when it is green: a change that
|
||||
keeps every assertion passing while moving the apex of a jump by 30 px is a change worth
|
||||
noticing.
|
||||
|
||||
## Choosing a backend
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
if ( strncmp(type->data, "null", 4) == 0) {
|
||||
PASS(errctx, akgl_physics_init_null(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( strncmp(type->data, "arcade", 6) == 0) {
|
||||
PASS(errctx, akgl_physics_init_arcade(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);
|
||||
```
|
||||
|
||||
**The match is on leading characters.** `"nullify"` selects `null` and `"arcadia"` selects
|
||||
`arcade`; anything else raises `AKERR_KEY` quoting what was asked for. Adding a backend
|
||||
means adding a name here, not a branch in the simulation.
|
||||
|
||||
**`akgl_game_init` does not call the factory, and there is no `physics.engine` property.**
|
||||
The `@file` block and the `akgl_physics_factory` doc comment in `include/akgl/physics.h`
|
||||
both say otherwise. They are wrong: `akgl_game_init` points `akgl_physics` at
|
||||
`akgl_default_physics` — zeroed storage whose four method pointers are all `NULL` — and
|
||||
never initializes it, and the string `physics.engine` appears nowhere in `src/`. The
|
||||
startup sequence in `include/akgl/game.h` has it right: **the application calls an
|
||||
initializer or the factory itself**, at step 4, after the configuration properties are set
|
||||
and before the first frame. Skip that and the first `akgl_game_update` calls through a
|
||||
`NULL` `simulate`.
|
||||
|
||||
The only caller of `akgl_physics_factory` inside the library is
|
||||
`akgl_tilemap_load_physics`, driven by a map's `physics.model` custom property — see
|
||||
[Chapter 13](13-tilemaps.md). `util/charviewer.c` shows the direct form,
|
||||
`akgl_physics_init_null(akgl_physics)`. Correcting the header comments is filed separately;
|
||||
until then, believe `game.h` and this chapter.
|
||||
|
||||
```c
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
/*
|
||||
* Bring up the arcade backend. akgl_game_init does NOT do this: it points
|
||||
* akgl_physics at akgl_default_physics, which is zeroed storage whose method
|
||||
* pointers are all NULL. Call an initializer, or the first akgl_game_update
|
||||
* calls through a NULL simulate.
|
||||
*
|
||||
* Properties first, because akgl_physics_init_arcade reads them.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *bring_up_physics(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_set_property("physics.gravity.y", "900.0"));
|
||||
/* There is no terminal-velocity setting. Drag is the mechanism: ey
|
||||
* approaches gravity_y / drag_y, so 900 / 1.8 caps the fall at 500 px/s. */
|
||||
PASS(errctx, akgl_set_property("physics.drag.y", "1.8"));
|
||||
|
||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_physics_init_arcade` reads seven properties — `physics.gravity.x`/`.y`/`.z`,
|
||||
`physics.drag.x`/`.y`/`.z` and `physics.max_timestep` — all with defaults, so an arcade
|
||||
backend with no configuration behaves like the null one until something is set. **Set the
|
||||
properties first.** With `AKGL_REGISTRY_PROPERTIES` uninitialized every read comes back as
|
||||
its default and this silently configures zero gravity and zero drag rather than reporting
|
||||
anything; see [Chapter 6](06-the-registry.md).
|
||||
|
||||
The gravity signs are chosen for screen space: x pulls toward screen left, y pulls down
|
||||
because y grows downward, z pulls away from the camera.
|
||||
|
||||
## `AKGL_ERR_LOGICINTERRUPT`
|
||||
|
||||
The one status in libakgl that is not a failure. Raised from an actor's
|
||||
`movementlogicfunc`, it means **"skip the rest of this tick for me"** — no gravity, no drag,
|
||||
no move for that actor this step — and `akgl_physics_simulate` handles it and carries on to
|
||||
the next actor. It is a control signal wearing an error's clothes.
|
||||
|
||||
**Only `movementlogicfunc` gets that treatment.** The `gravity` and `move` calls return
|
||||
straight out of the loop on any status, `AKGL_ERR_LOGICINTERRUPT` included, so a backend
|
||||
must never use it to opt an actor out from there — there it aborts the whole step, leaving
|
||||
the actors already processed advanced and the rest not.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
/*
|
||||
* A movement logic function that pins an actor to a floor at y = 400.
|
||||
*
|
||||
* The arcade backend never calls collide and never consults the tilemap, so
|
||||
* standing on something is the caller's job. Doing it here rather than after
|
||||
* the step is what keeps the actor from being drawn inside the floor for one
|
||||
* frame.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *stand_on_floor(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
/* The default logic still has to run: it is what copies the character's
|
||||
* speeds and accelerations onto the actor. */
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
if ( obj->y >= 400.0f ) {
|
||||
obj->y = 400.0f;
|
||||
/* ey is where gravity accumulates. Zero it, or the actor keeps
|
||||
* "falling" into the floor and cannot jump out of it. */
|
||||
obj->ey = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Raising this from movement logic means "skip the rest of this tick for me".
|
||||
* akgl_physics_simulate handles it and moves on to the next actor; the actor
|
||||
* gets no gravity, no drag and no move this step. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *frozen_in_cutscene(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)dt;
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "cutscene");
|
||||
}
|
||||
```
|
||||
|
||||
## What is not implemented
|
||||
|
||||
Stated plainly, because all of it is reachable by an ordinary-looking call and none of it
|
||||
announces itself at compile time.
|
||||
|
||||
**There is no collision detection, at all.**
|
||||
|
||||
- `akgl_physics_arcade_collide` always raises `AKERR_API` with the message "Not
|
||||
implemented". It is deliberately loud rather than silently permissive.
|
||||
- **`akgl_physics_simulate` never calls `collide`.** Not for the arcade backend, not for
|
||||
the null one, not ever. The vtable slot exists and the simulation does not use it.
|
||||
- `akgl_physics_arcade_move` does `position += velocity * dt` and nothing else. It does not
|
||||
clamp to the map, consult the tilemap, or test anything. **An actor walks straight
|
||||
through a wall and off the edge of the world.**
|
||||
|
||||
`akgl_physics_null_collide` *succeeds*, and that is not an oversight: the null backend's
|
||||
contract is "nothing collides", which is an answer. The arcade one means "nobody has
|
||||
written this yet", and a caller who reaches it should find out.
|
||||
|
||||
Until it exists, collision belongs in your `movementlogicfunc`, as in the example above.
|
||||
The sidescroller tutorial in [Chapter 19](19-tutorial-sidescroller.md) does exactly that and
|
||||
says why. `akgl_collide_*` in [Chapter 18](18-utilities.md) gives you the rectangle tests.
|
||||
|
||||
### Four gaps in the feel
|
||||
|
||||
Found by `tests/physics_sim.c`, which runs the arcade backend the way a game does — a
|
||||
Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed —
|
||||
and prints what the actor actually did. Three other defects that suite found *are* fixed in
|
||||
0.6.0. These four are not. All are in `TODO.md`, "Arcade physics feel".
|
||||
|
||||
| Gap | What you see | The workaround today |
|
||||
|---|---|---|
|
||||
| **No terminal velocity** | Gravity adds `gravity_y * dt` to `ey` every step and nothing bounds it. The jump simulation reaches 560 px/s in 0.7 s and would keep going | `physics.drag.y`. It makes `ey` approach `gravity_y / drag_y` instead of diverging. That is the only brake, and it is not documented as one anywhere else |
|
||||
| **Releasing a direction stops the actor dead** | `akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character at full speed stops within one frame — 0.0 px of drift measured in the second after release | Correct for Zelda, wrong for Mario. There is no friction or deceleration anywhere in the backend; `ax` is the only rate and it applies only while a direction is held. Decay `tx` yourself in `movementlogicfunc` |
|
||||
| **Explicit Euler, so trajectories are frame-rate dependent** | The same jump traces a slightly different arc at 30 Hz than at 144 Hz. The reversal measures 0.500 s to turn around where the closed form predicts 0.489 s — about 2% at 60 Hz, growing with the step | Nothing. 2% is not a bug a player can see. It is recorded because the fix (a fixed-step accumulator) is the same piece of work as removing the slow-motion trade `max_timestep` makes |
|
||||
| **A large drag coefficient inverts velocity** | `ex -= ex * drag_x * dt` is a first-order decay, so it only decays for `drag * dt < 1`. Past that it overshoots zero; past `2` it diverges. **Nothing rejects it** | With `dt` bounded to the default 0.05 s that needs a drag above 20, which is not a plausible setting. But `max_timestep` is caller-settable, so keep `drag * max_timestep` below 1 |
|
||||
|
||||
The `_off` handlers used to be worse: they zeroed `ay`, `ey`, `ty` and `vy` together, and
|
||||
`ey` is where gravity accumulates, so tapping down mid-jump stopped the character in the
|
||||
air. They clear `ax`/`tx` (or `ay`/`ty`) and nothing else now. Velocity was never theirs to
|
||||
clear either — `simulate` recomputes `v` as `e + t` every step.
|
||||
|
||||
## Statuses
|
||||
|
||||
`akgl_physics_simulate` propagates whatever an actor's `movementlogicfunc`, or the
|
||||
backend's `gravity` or `move`, raises, and **the first failure aborts the whole step** —
|
||||
the actors already processed are advanced and the rest are not. The status meanings are in
|
||||
[Chapter 4](04-errors.md); the ones you will meet here are `AKERR_NULLPOINTER` (a `NULL`
|
||||
`self` or `self->move`), `AKERR_API` (`collide`, always), `AKERR_VALUE` and `ERANGE` (a
|
||||
`physics.*` property that is not a number, or does not fit a `double`), and
|
||||
`AKGL_ERR_LOGICINTERRUPT`, which is not a failure.
|
||||
351
docs/15-input.md
Normal file
351
docs/15-input.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# 15. Input
|
||||
|
||||
libakgl does not have an input system so much as a *binding* system. SDL3 owns events,
|
||||
keycodes, keymods, gamepad enumeration and text composition, and it documents all of them
|
||||
in the [SDL3 wiki](https://wiki.libsdl.org/SDL3/) — start with
|
||||
[SDL_Event](https://wiki.libsdl.org/SDL3/SDL_Event),
|
||||
[SDL_Keycode](https://wiki.libsdl.org/SDL3/SDL_Keycode) and
|
||||
[CategoryGamepad](https://wiki.libsdl.org/SDL3/CategoryGamepad). Nothing here replaces any
|
||||
of it.
|
||||
|
||||
What libakgl adds is two things: a table that says *"when event X arrives from device Y
|
||||
carrying key or button Z, call this handler on this actor"*, and a small ring buffer of
|
||||
keystrokes for a caller who wants to ask "is there a key waiting" without owning the event
|
||||
loop.
|
||||
|
||||
## Input is pushed, not polled
|
||||
|
||||
**The host pumps every SDL event into `akgl_controller_handle_event`.** There is no
|
||||
`akgl_input_update()` that reads the keyboard state for you, and there is no internal event
|
||||
loop. Whatever drains the SDL event queue — your `while ( SDL_PollEvent(&e) )`, or SDL's
|
||||
`SDL_AppEvent` callback — hands each event over, unconditionally.
|
||||
|
||||
Unconditionally is the point: **an event nothing binds is not an error.** It returns
|
||||
success having done nothing, which is what makes the blanket call correct and keeps the
|
||||
host from having to know which events matter.
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
/*
|
||||
* Bind WASD on top of control map 0, which akgl_controller_default has already
|
||||
* filled with the four arrow keys and the four D-pad directions.
|
||||
*
|
||||
* Bindings are appended and scanned in push order, and the first match stops
|
||||
* the scan -- so these fire only for keys the eight defaults do not claim.
|
||||
* There is no way to remove one; zeroing akgl_controlmaps[id] is the only reset.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *bind_wasd(int mapid, SDL_KeyboardID kbid)
|
||||
{
|
||||
akgl_Control control;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_controller_default(mapid, "player", kbid, 0));
|
||||
|
||||
/* Copied in by value, so one stack local does for every push. */
|
||||
SDL_memset(&control, 0x00, sizeof(akgl_Control));
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
|
||||
control.key = SDLK_A;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_actor_cmhf_left_off;
|
||||
PASS(errctx, akgl_controller_pushmap(mapid, &control));
|
||||
|
||||
control.key = SDLK_D;
|
||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||
control.handler_off = &akgl_actor_cmhf_right_off;
|
||||
PASS(errctx, akgl_controller_pushmap(mapid, &control));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Every event, unconditionally. An event nothing binds is success, not a
|
||||
* failure, which is what makes the unconditional call correct. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *pump(void *appstate, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_controller_handle_event(appstate, event));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## Two independent paths out of one call
|
||||
|
||||
`akgl_controller_handle_event` does two unrelated things with the event it is given, and
|
||||
**neither one suppresses the other**:
|
||||
|
||||
```text
|
||||
akgl_controller_handle_event(appstate, event)
|
||||
|
|
||||
+----------------------+----------------------+
|
||||
| |
|
||||
(a) the keystroke ring, FIRST (b) the control maps, SECOND
|
||||
| |
|
||||
KEY_DOWN -> push {key, mod, ""} for map 0..7 (target != NULL):
|
||||
TEXT_INPUT -> attach text to the for control 0..31, in push order:
|
||||
newest press, or push event type == event_on/off ?
|
||||
a text-only entry device id == kbid / jsid ?
|
||||
| key/button == this binding ?
|
||||
| -> call the handler and
|
||||
akgl_controller_poll_key() STOP the entire scan
|
||||
akgl_controller_poll_keystroke()
|
||||
akgl_controller_flush_keys()
|
||||
```
|
||||
|
||||
The ring is filled **first**, before the maps are consulted, and *whether or not* a map
|
||||
claims the key. A key that drives an actor is still a key an embedded interpreter polling
|
||||
with `akgl_controller_poll_key()` wants to see.
|
||||
|
||||
### The control-map scan
|
||||
|
||||
```c excerpt=include/akgl/controller.h
|
||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
||||
#define AKGL_MAX_CONTROL_MAPS 8
|
||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
||||
#define AKGL_MAX_CONTROLS 32
|
||||
```
|
||||
|
||||
Eight maps means up to eight locally controlled players, each on its own keyboard and
|
||||
gamepad. A map with a `NULL` `target` is skipped entirely — that is how an unused slot is
|
||||
spelled.
|
||||
|
||||
**Maps are scanned in index order, bindings within a map in push order, and the first
|
||||
match wins and stops the whole scan.** A key bound in two maps only ever fires in the
|
||||
lower-numbered one. A key bound twice in one map only fires on the earlier push. This is
|
||||
what makes `akgl_controller_default` followed by your own pushes behave sanely: the
|
||||
defaults are already in slots 0–7 and your additions land after them.
|
||||
|
||||
A match requires **all three** of: the event type equals the binding's `event_on` or
|
||||
`event_off`; the device id on the event equals the map's `kbid` (keyboard events) or `jsid`
|
||||
(gamepad events); and the key or button equals the binding's. The device-id half is what
|
||||
keeps two players on two keyboards apart, and it is exact — `akgl_controller_list_keyboards`
|
||||
logs every attached keyboard and its id, which is how you find the number to pass.
|
||||
|
||||
There is no way to remove a binding. `akgl_controller_pushmap` appends and that is the
|
||||
whole API; the only reset is zeroing `akgl_controlmaps[id]` yourself. **Calling
|
||||
`akgl_controller_default` twice on one map leaves sixteen bindings**, and the first eight
|
||||
are the ones that fire.
|
||||
|
||||
### The keystroke ring
|
||||
|
||||
```c excerpt=src/controller.c
|
||||
static void keybuffer_push_key(SDL_Keycode key, SDL_Keymod mod)
|
||||
{
|
||||
int tail = 0;
|
||||
|
||||
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
|
||||
keybuffer_awaiting_text = false;
|
||||
return;
|
||||
}
|
||||
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer[tail].key = key;
|
||||
keybuffer[tail].mod = mod;
|
||||
keybuffer[tail].text[0] = '\0';
|
||||
keybuffer_count += 1;
|
||||
keybuffer_awaiting_text = true;
|
||||
}
|
||||
```
|
||||
|
||||
Thirty-two entries (`AKGL_CONTROLLER_KEY_BUFFER`), one fixed array in the library's data
|
||||
segment, **one ring for the whole process** — it is a file-scope static, not per map and
|
||||
not per actor.
|
||||
|
||||
**A full buffer drops the newest keystroke, not the oldest.** A caller reading a line of
|
||||
input wants the characters that were typed first; overwriting the head would hand it the
|
||||
tail of what the user typed and silently lose the beginning.
|
||||
|
||||
Two pollers drain the same ring, and a keystroke taken by one is not waiting for the
|
||||
other:
|
||||
|
||||
| Call | Hands back | Discards |
|
||||
|---|---|---|
|
||||
| `akgl_controller_poll_key(&keycode, &available)` | The SDL keycode, as an `int` | Entries carrying only composed text and no keycode — there is no key to report |
|
||||
| `akgl_controller_poll_keystroke(&dest, &available)` | `key`, `mod` and the composed `text` | Nothing |
|
||||
| `akgl_controller_flush_keys()` | — | Everything waiting |
|
||||
|
||||
An empty ring is success, not a failure: `available` comes back `false` and the caller
|
||||
polls again next frame. **Check `available`, not the keycode against 0** — a text-only
|
||||
entry legitimately has a keycode of 0.
|
||||
|
||||
**The `text` field needs `SDL_StartTextInput()` on your window.** SDL reports one keystroke
|
||||
as two events — the key going down, and then the character it composed to, if it composed
|
||||
to anything — and it only sends the second while text input is started. Without that call
|
||||
`key` and `mod` still arrive and `text` is always the empty string. That is not a libakgl
|
||||
limitation to work around; **it is the only correct way to get a character out of SDL**, and
|
||||
it is what makes a keyboard layout, a compose key, a dead key and an IME commit work. `"`,
|
||||
`!`, `(`, `)`, `:` and `;` are all unreachable from a keycode alone, and so is every
|
||||
lower-case letter. See
|
||||
[SDL_StartTextInput](https://wiki.libsdl.org/SDL3/SDL_StartTextInput).
|
||||
|
||||
An entry with composed text and **no** keycode is not a degenerate case: an input method or
|
||||
a dead key finishes a character with no key press of its own, and a line editor wants that
|
||||
character. `akgl_controller_poll_keystroke` reports it; `akgl_controller_poll_key` cannot,
|
||||
and drops it on the way past.
|
||||
|
||||
Text longer than `AKGL_CONTROLLER_KEYSTROKE_TEXT` (8 bytes) — an IME committing a whole
|
||||
word at once — is truncated **on a code point boundary**, never through the middle of a
|
||||
UTF-8 sequence.
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
/*
|
||||
* Drain the keystroke ring into a line buffer, the way an embedded interpreter
|
||||
* or a name-entry screen wants it.
|
||||
*
|
||||
* The `text` field is only populated while SDL text input is started on the
|
||||
* window, so a caller that wants punctuation, lower case, dead keys or an IME
|
||||
* commit calls SDL_StartTextInput() first. Without it `key` and `mod` still
|
||||
* arrive and `text` is always empty.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *read_line(char *dest, size_t destsize, bool *done)
|
||||
{
|
||||
akgl_Keystroke stroke;
|
||||
bool available = false;
|
||||
size_t used = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, done, AKERR_NULLPOINTER, "done");
|
||||
|
||||
used = SDL_strlen(dest);
|
||||
*done = false;
|
||||
|
||||
/* The ATTEMPT block goes outside the loop: CATCH reports failure by
|
||||
* break-ing, which would bind to the loop instead of the block. */
|
||||
ATTEMPT {
|
||||
for ( ;; ) {
|
||||
CATCH(errctx, akgl_controller_poll_keystroke(&stroke, &available));
|
||||
if ( available == false ) {
|
||||
break;
|
||||
}
|
||||
if ( stroke.key == SDLK_RETURN ) {
|
||||
*done = true;
|
||||
break;
|
||||
}
|
||||
if ( (stroke.text[0] != '\0') && ((used + 1) < destsize) ) {
|
||||
dest[used] = stroke.text[0];
|
||||
used += 1;
|
||||
dest[used] = '\0';
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The ring is filled on whichever thread pumps events and **is not synchronized**. Poll it
|
||||
from that thread.
|
||||
|
||||
## The default bindings
|
||||
|
||||
`akgl_controller_default(controlmapid, actorname, kbid, jsid)` points a map at the actor
|
||||
registered under `actorname` and pushes **eight** bindings — the four arrow keys and the
|
||||
four D-pad directions — each wired to the matching `akgl_actor_cmhf_*_on`/`_off` pair:
|
||||
|
||||
| Key | Gamepad button | `handler_on` / `handler_off` |
|
||||
|---|---|---|
|
||||
| `SDLK_DOWN` | `SDL_GAMEPAD_BUTTON_DPAD_DOWN` | `akgl_actor_cmhf_down_on` / `_down_off` |
|
||||
| `SDLK_UP` | `SDL_GAMEPAD_BUTTON_DPAD_UP` | `akgl_actor_cmhf_up_on` / `_up_off` |
|
||||
| `SDLK_LEFT` | `SDL_GAMEPAD_BUTTON_DPAD_LEFT` | `akgl_actor_cmhf_left_on` / `_left_off` |
|
||||
| `SDLK_RIGHT` | `SDL_GAMEPAD_BUTTON_DPAD_RIGHT` | `akgl_actor_cmhf_right_on` / `_right_off` |
|
||||
|
||||
It is the "just give me something that works" path. A game wanting different keys builds
|
||||
its own bindings with `akgl_controller_pushmap`, as in the WASD example above.
|
||||
|
||||
**The default bindings cannot produce a diagonal.** Every `akgl_actor_cmhf_*_on` handler
|
||||
clears `AKGL_ACTOR_STATE_MOVING_ALL` before setting its own bit, so pressing Left while Up
|
||||
is held cancels Up rather than adding to it. That is fine for a four-way JRPG and wrong for
|
||||
anything that wants eight-way movement — and it means the thrust-vector ellipse cap in
|
||||
[Chapter 14](14-physics.md) is unreachable through these handlers. Eight-way movement wants
|
||||
your own handlers that `AKGL_BITMASK_ADD` the movement bit without clearing the others.
|
||||
|
||||
`akgl_controller_handle_button_down` / `_up` are a different thing again, and are **not**
|
||||
control-map bindings: they look the actor up by the literal name `"player"` in
|
||||
`AKGL_REGISTRY_ACTOR` rather than being told which actor to act on, so they only ever drive
|
||||
one. The `akgl_actor_cmhf_*` handlers are the general form; see
|
||||
[Chapter 12](12-actors.md).
|
||||
|
||||
## Gamepads
|
||||
|
||||
**SDL delivers no button events from a gamepad nobody has opened.** `akgl_game_init` calls
|
||||
`akgl_controller_open_gamepads()` for the devices present at startup; a device plugged in
|
||||
mid-session arrives as `SDL_EVENT_GAMEPAD_ADDED`, and `akgl_controller_handle_added` opens
|
||||
it. Route that event (and `SDL_EVENT_GAMEPAD_REMOVED`, to `akgl_controller_handle_removed`)
|
||||
or hot-plugged pads stay silent. No gamepads attached at all is success, not an error.
|
||||
|
||||
`akgl_controller_handle_added` logs the mapping it found, deliberately: **a controller with
|
||||
no entry in the mapping database produces no button events at all**, which is otherwise
|
||||
indistinguishable from a broken binding.
|
||||
|
||||
Which is why libakgl ships a database. `include/akgl/SDL_GameControllerDB.h` carries
|
||||
`AKGL_SDL_GAMECONTROLLER_DB_LEN` (2255) mappings from the community
|
||||
[SDL_GameControllerDB](https://github.com/mdqinc/SDL_GameControllerDB) project, and
|
||||
`akgl_game_init` feeds every one to `SDL_AddGamepadMapping` before opening anything.
|
||||
|
||||
That header is **generated, and tracked on purpose**. It is the offline fallback that keeps
|
||||
the library buildable when upstream is renamed, rate-limited or unreachable. Never
|
||||
hand-edit it; regeneration is `cmake --build build --target controllerdb` and nothing else
|
||||
runs the script. The full contract, including why a failed fetch used to destroy the very
|
||||
file it exists to protect, is in `AGENTS.md` under "Generated and Vendored Sources".
|
||||
|
||||
## Known defects
|
||||
|
||||
**A matched binding's handler pointer is not checked.** `akgl_controller_handle_event`
|
||||
calls `curcontrol->handler_on(...)` the moment the event type, device id and key all match,
|
||||
with no `NULL` test. A control pushed with a `NULL` `handler_on` or `handler_off` crashes
|
||||
when its event arrives — and since `akgl_controller_pushmap` copies whatever it is given,
|
||||
a `memset`-zeroed `akgl_Control` with only `event_on` filled in is a loaded gun. **Set both
|
||||
handlers on every binding you push**, even if one of them does nothing. There is no
|
||||
diagnostic for this at any layer.
|
||||
|
||||
**`appstate` is required and never read.** Every entry point in this subsystem —
|
||||
`akgl_controller_handle_event`, `handle_button_down`, `handle_button_up`, `handle_added`,
|
||||
`handle_removed` — raises `AKERR_NULLPOINTER` on a `NULL` `appstate` and then never looks
|
||||
at it. Pass any non-`NULL` pointer if your program has no app state.
|
||||
|
||||
**The analogue-axis, mouse and pen fields are declared and never consulted.**
|
||||
`akgl_Control::axis`, `axis_range_min` and `axis_range_max`, and `akgl_ControlMap::mouseid`
|
||||
and `penid`, are in the structs and `akgl_controller_handle_event` does not read any of
|
||||
them. Setting them has no effect; analogue sticks, mice and pens do not reach the control
|
||||
maps at all today. Handle those events yourself before calling
|
||||
`akgl_controller_handle_event`.
|
||||
|
||||
**Key auto-repeat is not filtered.** The ring push tests only `event->type ==
|
||||
SDL_EVENT_KEY_DOWN` and does not consult `event->key.repeat`, so a held key fills the
|
||||
32-entry ring at the platform's repeat rate. Check `repeat` in your event pump if you are
|
||||
polling for discrete presses.
|
||||
|
||||
Two defects this chapter used to carry are **fixed** and are recorded here so nobody
|
||||
re-derives them from the header prose:
|
||||
|
||||
- **A negative `controlmapid` is rejected.** Both `akgl_controller_pushmap` and
|
||||
`akgl_controller_default` check `controlmapid < 0` as well as the upper bound and raise
|
||||
`AKERR_OUTOFBOUNDS`. `tests/controller.c` passes `-1` and `-4096` to each. The
|
||||
`@warning` blocks in `include/akgl/controller.h` saying only the upper bound is checked
|
||||
are stale; `TODO.md`, "Known and still open" item 11.
|
||||
- **The `akgl_controller_handle_*` functions exist.** `controller.h` once declared four
|
||||
names that were defined under different spellings, so they linked nowhere. Fixed in
|
||||
0.5.0, and `scripts/check_api_surface.sh` runs as the `api_surface` test to stop that
|
||||
class of drift coming back.
|
||||
|
||||
## Statuses
|
||||
|
||||
The ones this subsystem raises, with their libakgl meanings in
|
||||
[Chapter 4](04-errors.md):
|
||||
|
||||
| Status | When |
|
||||
|---|---|
|
||||
| `AKERR_NULLPOINTER` | A `NULL` `appstate`, `event`, `control`, or poller destination; SDL cannot enumerate or open a device; no actor registered as `"player"` |
|
||||
| `AKERR_OUTOFBOUNDS` | `controlmapid` outside `0..AKGL_MAX_CONTROL_MAPS - 1`, or the map already holds `AKGL_MAX_CONTROLS` bindings |
|
||||
| `AKGL_ERR_REGISTRY` | `akgl_controller_default` could not find `actorname` — usually because the actor has not been created yet |
|
||||
| anything | Whatever the matched binding's handler raises, propagated unchanged |
|
||||
223
docs/16-text-and-fonts.md
Normal file
223
docs/16-text-and-fonts.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 16. Text and fonts
|
||||
|
||||
**SDL3_ttf owns fonts.** It opens the file, rasterizes the glyphs, and computes the metrics;
|
||||
its API and its behaviour are documented in the
|
||||
[SDL3_ttf wiki](https://wiki.libsdl.org/SDL3_ttf/). This chapter covers the three things
|
||||
libakgl adds on top: a name-keyed font registry, a one-call rasterize-and-blit, and two
|
||||
measure functions that need no window.
|
||||
|
||||
There is no akgl font type. A font is a `TTF_Font *`, and what libakgl gives you is a place
|
||||
to keep it.
|
||||
|
||||
## Fonts live in a registry, keyed by a name you choose
|
||||
|
||||
`akgl_text_loadfont(name, filepath, size)` opens the file and publishes the handle in
|
||||
`AKGL_REGISTRY_FONT` under `name`. The name is yours — it is not derived from the file — and
|
||||
`filepath` is used verbatim, not resolved against `SDL_GetBasePath()`.
|
||||
|
||||
**The size is baked into the handle, so one file at two sizes is two fonts under two
|
||||
names.** That is SDL_ttf's model, not libakgl's, and there is no way around it:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
/* Two sizes of one file are two fonts under two names. */
|
||||
akerr_ErrorContext *hud_load_fonts(char *path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_text_loadfont("hud", path, 14));
|
||||
PASS(errctx, akgl_text_loadfont("title", path, 32));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *hud_draw_score(int score)
|
||||
{
|
||||
TTF_Font *font = NULL;
|
||||
SDL_Color white = { 255, 255, 255, 255 };
|
||||
char line[32];
|
||||
int count = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* The registry hands back a raw TTF_Font *. It is not reference counted:
|
||||
nothing here takes a reference and nothing gives one back. */
|
||||
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "hud", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font registered as \"hud\"");
|
||||
|
||||
PASS(errctx, aksl_snprintf(&count, line, sizeof(line), "SCORE %d", score));
|
||||
PASS(errctx, akgl_text_rendertextat(font, line, white, 0, 8, 8));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**Fonts are not reference counted.** The four asset pools in [Chapter 5](05-the-heap.md) all
|
||||
count references; the font registry does not. It holds a bare pointer. Two consequences:
|
||||
|
||||
- `akgl_text_unloadfont(name)` closes the font whether or not anything is still using it.
|
||||
Anything holding the `TTF_Font *` — a caller that fetched it earlier, a `hud_draw_score`
|
||||
that fetched it last frame and cached it — is left with a dangling pointer.
|
||||
- Loading over an existing name replaces the entry and closes the font it displaced. The new
|
||||
font is opened **first**, so a failed load leaves you with the font you already had.
|
||||
|
||||
`akgl_text_unloadfont` on a name that is not registered raises `AKERR_KEY`, which makes a
|
||||
double unload an error rather than a double close. `akgl_text_loadfont` raises `AKERR_KEY`
|
||||
when it cannot write the registry — in practice, because `akgl_registry_init_font()` has not
|
||||
run.
|
||||
|
||||
## The teardown ordering trap
|
||||
|
||||
**`akgl_text_unloadallfonts()` must run before `TTF_Quit()` and `SDL_Quit()`.** This is the
|
||||
one ordering constraint in the subsystem and it is easy to get wrong, because nothing fails
|
||||
loudly when you do.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
/* Shutdown order. Both of the other two orderings are wrong. */
|
||||
void shutdown_text(void)
|
||||
{
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
TTF_Quit();
|
||||
SDL_Quit();
|
||||
}
|
||||
```
|
||||
|
||||
A font is reachable only by name, through `AKGL_REGISTRY_FONT`, which is an SDL property set.
|
||||
So:
|
||||
|
||||
| Order | What happens |
|
||||
|---|---|
|
||||
| `unloadallfonts`, `TTF_Quit`, `SDL_Quit` | Correct. Every font closed, then the registry destroyed, then SDL |
|
||||
| `SDL_Quit` first | **SDL_Quit destroys the property registry, taking the last reference to every font with it.** They are never closed and are now unreachable — a leak bounded by how many fonts you loaded, with no way to do anything about it |
|
||||
| `TTF_Quit` first | The `TTF_Font` handles are already invalid; `unloadallfonts` then closes freed pointers |
|
||||
|
||||
`akgl_text_unloadallfonts` enumerates the registry, closes every font, destroys the property
|
||||
set and sets `AKGL_REGISTRY_FONT` to 0. It has no failure path: an uninitialized registry is
|
||||
success rather than an error, because shutdown paths run after partial startups.
|
||||
|
||||
**Afterwards the registry is gone.** `akgl_text_loadfont` needs
|
||||
`akgl_registry_init_font()` again before it can register anything, so this is a shutdown
|
||||
call rather than a between-scenes one. To swap fonts mid-game, use `akgl_text_unloadfont`
|
||||
per name.
|
||||
|
||||
## Drawing is rasterize, upload, blit, destroy — every call
|
||||
|
||||
`akgl_text_rendertextat(font, text, color, wraplength, x, y)` does the whole job in one
|
||||
call: renders blended through SDL_ttf, uploads the surface as a texture, draws it through
|
||||
`akgl_renderer->draw_texture`, and destroys both the texture and the surface before it
|
||||
returns. **There is no cache.** Every call pays the full cost.
|
||||
|
||||
`PERFORMANCE.md` puts numbers on it, on a 640x480 software-rasterized frame:
|
||||
|
||||
| Operation | Cost |
|
||||
|---|---:|
|
||||
| `akgl_text_rendertextat`, 15 characters | 12,601.7 ns |
|
||||
| `akgl_text_measure`, 15 characters | 37.3 ns |
|
||||
| Six lines of HUD text | 0.076 ms — 0.5% of a 16.67 ms frame |
|
||||
|
||||
Measuring is 340 times cheaper than drawing, which tells you the whole cost is the rasterize
|
||||
and the upload. **Six HUD readouts is 76 µs a frame. That is fine at 60 fps on a laptop and
|
||||
it is 4% of a 2 ms GPU frame** — and it is being paid every frame for a score that changes
|
||||
once a second. A one-line cache keyed on (font, string, colour) would take it to nothing;
|
||||
`PERFORMANCE.md` calls it the single clearest optimisation in the library, and `TODO.md`
|
||||
carries it as a target.
|
||||
|
||||
So: **fine for a HUD line, wrong for a static body of text redrawn every frame.** If you are
|
||||
drawing a page of dialogue that does not change, rasterize it yourself once with SDL3_ttf,
|
||||
keep the texture, and blit it through `akgl_renderer->draw_texture`.
|
||||
|
||||
Two more properties of the draw:
|
||||
|
||||
- **Coordinates are screen coordinates, not world ones.** This does not go through
|
||||
`akgl_camera`, so a HUD stays put while the world scrolls under it. `x` and `y` are the
|
||||
top-left corner of the text, not a centre.
|
||||
- **`wraplength` greater than 0 wraps on word boundaries at that pixel width.** 0 or less
|
||||
draws a single line and breaks only on newlines in `text`.
|
||||
|
||||
The renderer guards are worth knowing because of what they catch. `akgl_text_rendertextat`
|
||||
raises `AKERR_NULLPOINTER` if `akgl_renderer`, its `sdl_renderer`, or its `draw_texture` is
|
||||
`NULL` — and that last one is the state a backend is in between being allocated and being
|
||||
run through `akgl_render_2d_bind()`. Reusing `AKERR_NULLPOINTER` for a failed rasterize or a
|
||||
failed texture upload is the same status doing double duty; the message carries
|
||||
`SDL_GetError()` and tells you which.
|
||||
|
||||
## Measuring needs no renderer and no window
|
||||
|
||||
`akgl_text_measure` and `akgl_text_measure_wrapped` touch nothing but the font. They work
|
||||
before `akgl_render_2d_init`, and in a program that never creates a window at all. A caller
|
||||
building a character grid measures one cell and derives the rest from it:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
/* Measuring touches no renderer and no window, so a grid can be derived
|
||||
before anything is on screen. */
|
||||
akerr_ErrorContext *cell_size(TTF_Font *font, int *cellw, int *cellh)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_text_measure(font, "M", cellw, cellh));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* A negative wrap length is refused rather than passed through: SDL_ttf reads
|
||||
it as a very large unsigned width and silently stops wrapping. */
|
||||
akerr_ErrorContext *box_size(TTF_Font *font, char *body, int width, int *w, int *h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_text_measure_wrapped(font, body, width, w, h));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The two differ in exactly the way `akgl_text_rendertextat`'s `wraplength` argument suggests:
|
||||
|
||||
| Function | Height reported | Width reported |
|
||||
|---|---|---|
|
||||
| `akgl_text_measure` | One line, whatever the text contains | The whole string on one line |
|
||||
| `akgl_text_measure_wrapped` | Every line the text wraps onto | The longest line, not `wraplength` |
|
||||
|
||||
**A negative `wraplength` is refused with `AKERR_OUTOFBOUNDS`**, and that is a deliberate
|
||||
libakgl decision rather than a passthrough. SDL_ttf takes the wrap width as an `int` and
|
||||
reads a negative one as a very large *unsigned* width, which silently disables wrapping —
|
||||
returning a measurement that is wrong rather than an error. A `wraplength` of 0 is legal and
|
||||
wraps on newlines only.
|
||||
|
||||
## The empty string
|
||||
|
||||
Both halves accept it. `akgl_text_measure("")` returns 0 wide by one line high, so a cursor
|
||||
sitting on an empty line has somewhere to be, and `akgl_text_rendertextat` with `""` returns
|
||||
success without rasterizing anything.
|
||||
|
||||
That is worth stating because it was not always true and the header still says otherwise.
|
||||
SDL_ttf refuses the empty string from both rasterizers with "Text has zero width", and until
|
||||
0.5.0 `akgl_text_rendertextat` passed that on as `AKERR_NULLPOINTER` — so the two halves of
|
||||
one header disagreed about one string, and a caller drawing a line that might be empty had
|
||||
to check for it. It is a `SUCCEED_RETURN` now, placed *after* the font, text and backend
|
||||
guards, so drawing nothing still refuses everything drawing something refuses.
|
||||
|
||||
> **Stale header prose.** `include/akgl/text.h:123-127` still documents the empty string as
|
||||
> refused, and `text.h:120-122` still warns that a failure after rasterizing leaks the
|
||||
> surface and the texture. Both describe pre-0.5.0 behaviour. `src/text.c:110-112` returns
|
||||
> success for `""`, and `src/text.c:140-146` destroys both objects in a `CLEANUP` block that
|
||||
> runs on every path. `TODO.md` items 27 and 23 record both as fixed. The header comments
|
||||
> want correcting in their own commit.
|
||||
|
||||
## Fonts, the pools, and what is not shared
|
||||
|
||||
Nothing about fonts goes through the akgl heap. A `TTF_Font` is about ten kilobytes once
|
||||
FreeType's own structures are counted, it is allocated by SDL_ttf, and it is freed by
|
||||
`TTF_CloseFont`. The registry stores a pointer and nothing else.
|
||||
|
||||
That is different from spritesheets, which *are* pooled and *are* shared by resolved path —
|
||||
see [Chapter 10](10-spritesheets-and-sprites.md). Two names pointing at the same `.ttf` at
|
||||
the same size are two open fonts, not one shared one.
|
||||
337
docs/17-audio.md
Normal file
337
docs/17-audio.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# 17. Audio
|
||||
|
||||
**There are two audio subsystems in libakgl and they have nothing to do with each other.**
|
||||
Not two layers, not two APIs over one engine — two unrelated things that happen to make
|
||||
sound. Knowing which one you are in is most of using either.
|
||||
|
||||
| | Three-voice synthesizer | The asset path |
|
||||
|---|---|---|
|
||||
| Header | `audio.h` | `assets.h` |
|
||||
| Functions | 10 | 1 |
|
||||
| Whose code | **libakgl's own, in full** | SDL3_mixer's, wrapped |
|
||||
| Reads a file | Never | Always |
|
||||
| Device | Its own `SDL_AudioStream`, opened by `akgl_audio_init` | The `MIX_Mixer` created by `akgl_game_init` |
|
||||
| Globals | `akgl_audio_voices[3]` | `akgl_mixer`, `akgl_bgm`, `akgl_tracks[64]` |
|
||||
| Vocabulary | Notes: waveform, frequency, envelope | Recordings: load, track, play |
|
||||
|
||||
The first half of this chapter documents the synthesizer completely, because it is ours. The
|
||||
second half is one function and a link, because the decoding, the formats, the mixers and
|
||||
the tracks all belong to
|
||||
[SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) and are documented there.
|
||||
|
||||
## Part 1 — the three-voice synthesizer
|
||||
|
||||
### Where the design comes from
|
||||
|
||||
The vocabulary is not invented. Every entry point maps onto a Commodore 128 BASIC 7.0
|
||||
statement, and **the waveform enum values are literally the numbers `SOUND` takes**:
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
typedef enum {
|
||||
AKGL_AUDIO_WAVE_TRIANGLE = 0, /**< SOUND waveform 0. Soft, flute-like. */
|
||||
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /**< SOUND waveform 1. Bright, brassy. */
|
||||
AKGL_AUDIO_WAVE_SQUARE = 2, /**< SOUND waveform 2. Hollow, reedy. The default. */
|
||||
AKGL_AUDIO_WAVE_NOISE = 3, /**< SOUND waveform 3. Unpitched; percussion. */
|
||||
AKGL_AUDIO_WAVE_SINE = 4 /**< No SOUND equivalent. A pure tone. */
|
||||
} akgl_AudioWaveform;
|
||||
```
|
||||
|
||||
| BASIC 7.0 | libakgl | Notes |
|
||||
|---|---|---|
|
||||
| `SOUND v, f, d` | `akgl_audio_tone(voice, hz, ms)` | One pitch, held for a gate length |
|
||||
| `SOUND v, f, d, dir, min, step` | `akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms)` | Direction comes from the two frequencies, not from a sign |
|
||||
| `SOUND v, f, d, , , , wf` | `akgl_audio_waveform(voice, waveform)` | Same five shapes, same first four numbers |
|
||||
| `ENVELOPE n, a, d, s, r` | `akgl_audio_envelope(voice, attack, decay, sustain, release)` | Set per voice rather than per numbered preset |
|
||||
| `VOL n` | `akgl_audio_volume(level)` | Master level, 0.0 to 1.0 |
|
||||
| — | `akgl_audio_stop(voice)` | Hard cut; skips the release |
|
||||
| — | `akgl_audio_voice_active(voice, &active)` | Wait out a note without a clock |
|
||||
|
||||
Three voices, because the machine this comes from had three and its music is written for
|
||||
three:
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
#define AKGL_AUDIO_MAX_VOICES 3
|
||||
```
|
||||
|
||||
Voices are addressed from **zero** here. A language whose own voices are numbered from one
|
||||
maps them itself.
|
||||
|
||||
### The vocabulary in practice
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/audio.h>
|
||||
|
||||
/* A coin pickup: a short square blip on voice 0. */
|
||||
akerr_ErrorContext *sfx_coin(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_SQUARE));
|
||||
PASS(errctx, akgl_audio_envelope(0, 0, 20, 0.6f, 60));
|
||||
PASS(errctx, akgl_audio_tone(0, 1046.5f, 80));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* A laser: pitch walks down from 1200 Hz to 200 Hz, 40 Hz every 1/60 s. */
|
||||
akerr_ErrorContext *sfx_laser(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_waveform(1, AKGL_AUDIO_WAVE_SAWTOOTH));
|
||||
PASS(errctx, akgl_audio_envelope(1, 0, 0, 1.0f, 30));
|
||||
PASS(errctx, akgl_audio_sweep(1, 1200.0f, 200.0f, 40.0f, 400));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Wait for a note to finish without keeping a clock. */
|
||||
akerr_ErrorContext *sfx_still_sounding(int voice, bool *sounding)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_voice_active(voice, sounding));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_audio_waveform` and `akgl_audio_envelope` are **settings**: they persist on the voice
|
||||
across notes and take effect on the next `akgl_audio_tone`. They do not reshape a note that
|
||||
is already sounding. `akgl_audio_volume` is the exception — it is applied after the voices
|
||||
are summed, so it does take effect on notes already playing.
|
||||
|
||||
**A voice you never described still makes a noise.** The table's defaults are a square wave,
|
||||
sustain 1.0, and no attack, decay or release — held at full level for as long as the gate is
|
||||
open. A *zeroed* voice would have a sustain of 0.0, which is silence, so a caller who sounded
|
||||
a note without first setting an envelope would get nothing and no error saying why. The first
|
||||
entry point to touch the table installs those defaults, whether or not a device is open.
|
||||
|
||||
### The envelope and the gate
|
||||
|
||||
`ms` is the length of the **gate**, not of the sound. The release runs after it, so a voice
|
||||
with a release stays audible slightly longer than `ms`:
|
||||
|
||||
```text
|
||||
level
|
||||
1.0 | /\
|
||||
| / \____________
|
||||
| / : \sustain release
|
||||
sus |___/ : \________________.
|
||||
| / : :\
|
||||
0.0 |_/______:______________________________:_\______ time
|
||||
|<-A-><-D->|<------- gate (ms) -------->|<-R->|
|
||||
| |
|
||||
gate opens gate closes
|
||||
```
|
||||
|
||||
Zero-length stages are skipped rather than divided by. A zero attack starts at full level; a
|
||||
zero decay drops to `sustain` at once; a zero release cuts off at once. `sustain` of 0.0 is
|
||||
legal and gives a plucked sound — audible only through its attack and decay. An attack plus
|
||||
decay longer than the gate is not an error either: the release simply starts partway up the
|
||||
ramp.
|
||||
|
||||
**A voice goes quiet on its own** once gate and release are both spent. The mixer clears
|
||||
`active`, which is why `akgl_audio_voice_active` is how you wait out a note.
|
||||
|
||||
### Sweeps
|
||||
|
||||
`akgl_audio_sweep` steps the pitch by `step_hz` every 1/60 of a second toward `to_hz`,
|
||||
stopping when it arrives and holding there for whatever is left of the gate:
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
#define AKGL_AUDIO_SWEEP_TICK_HZ 60
|
||||
```
|
||||
|
||||
Sixty, because the machine whose `SOUND` statement this serves advanced its sweep on a 60 Hz
|
||||
interrupt and its tunes are written for that rate. It divides the 44100 Hz sample rate
|
||||
exactly, so a step boundary always lands on a whole frame.
|
||||
|
||||
**Direction comes from the two frequencies, not from the sign of `step_hz`.** A downward
|
||||
sweep is `to_hz` below `from_hz` with `step_hz` still positive; a negative or zero `step_hz`
|
||||
raises `AKERR_OUTOFBOUNDS`. Equal frequencies are legal and are simply a held tone, so a
|
||||
caller translating a statement that computes its own limits does not have to special-case
|
||||
them.
|
||||
|
||||
**The step is taken on the mixer's own frame counter, and that is the whole reason this
|
||||
function exists.** A caller re-issuing tones ties audible pitch to how often it happens to
|
||||
run, so the same siren changes shape with the frame rate. Nothing outside the library can
|
||||
see that counter.
|
||||
|
||||
`akgl_audio_tone` clears any sweep left on the voice — a step of 0 is what says "one pitch,
|
||||
held" — so you never have to undo one.
|
||||
|
||||
### The voice table exists without a device
|
||||
|
||||
`akgl_audio_voices[3]` is process-wide static storage. It is reachable, settable and
|
||||
readable before `akgl_audio_init` has ever run, and every entry point works. What you cannot
|
||||
do is set up voices with no device open and expect to *hear* them.
|
||||
|
||||
That leaves two ways to get sound out, and **you must pick one**:
|
||||
|
||||
```text
|
||||
Path A -- libakgl owns the device
|
||||
---------------------------------
|
||||
akgl_audio_init()
|
||||
| opens an SDL_AudioStream, F32, mono, 44100 Hz, and resumes it
|
||||
| (SDL opens devices paused; this one does not, because a caller
|
||||
| who set up a voice and heard nothing would have no error to
|
||||
| explain it)
|
||||
v
|
||||
SDL's callback thread -> akgl_audio_mix(internal buffer, <= 512 frames)
|
||||
|
||||
|
||||
Path B -- the host owns the device
|
||||
----------------------------------
|
||||
your own audio pipeline -> akgl_audio_mix(your buffer, frames)
|
||||
| no device is ever opened here
|
||||
```
|
||||
|
||||
**Do not use both.** `akgl_audio_mix` mutates the voice table — it advances each voice's
|
||||
frame counter and clears `active` on voices that have finished — and **it does not take the
|
||||
stream lock.** Every other entry point does, when there is a stream to lock: the internal
|
||||
`lock_voices` is a no-op with no device open, which is exactly right for path B and exactly
|
||||
wrong if you mix the two. Calling `akgl_audio_mix` yourself while a device opened by
|
||||
`akgl_audio_init` is running is two threads advancing the same voices.
|
||||
|
||||
Path B is what a host with its own audio pipeline wants, and it is how `tests/audio.c`
|
||||
exercises the synthesis without depending on a sound card's timing:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/audio.h>
|
||||
|
||||
#define HOST_FRAMES 512
|
||||
|
||||
/* A host that owns its own audio pipeline pulls samples itself and never
|
||||
calls akgl_audio_init. Do not do both: this path does not take the
|
||||
stream lock. */
|
||||
akerr_ErrorContext *host_fill(float32_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_mix(dest, HOST_FRAMES));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Samples are single-precision, one channel, in the range -1.0 to 1.0. `dest` is **overwritten,
|
||||
not accumulated into**, and the frame count is trusted rather than checked against anything —
|
||||
`akgl_audio_mix` raises `AKERR_NULLPOINTER` for a `NULL` destination and `AKERR_OUTOFBOUNDS`
|
||||
for a negative count, and that is the extent of the validation. A count of 0 is a no-op.
|
||||
|
||||
Three voices at full level can sum past full scale. The mix is **clamped** rather than scaled
|
||||
by the voice count, which keeps a single voice at the level it was asked for instead of a
|
||||
third of it.
|
||||
|
||||
### Sample rate, mix size, and the numbers
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
||||
|
||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
||||
```
|
||||
|
||||
Durations are milliseconds everywhere in the API and frames everywhere inside it. The
|
||||
conversion truncates, so a duration under about 0.023 ms rounds to nothing. There is **no
|
||||
upper bound on frequency**: a frequency above half the sample rate aliases rather than being
|
||||
refused.
|
||||
|
||||
`akgl_audio_shutdown` closes the device and resets the voice table to its defaults —
|
||||
including the master volume, back to 1.0 — so a later `akgl_audio_init` starts from a known
|
||||
state. It is safe with no device open and safe to call twice. `akgl_audio_init` is a no-op
|
||||
when a device is already open, and resets the voice table **only on the first call**, so it
|
||||
does not silence a voice that is already sounding.
|
||||
|
||||
### What raises what
|
||||
|
||||
| Status | When |
|
||||
|---|---|
|
||||
| `AKERR_OUTOFBOUNDS` | A voice index outside 0–2; a non-positive frequency or sweep step; a zero `ms`; a `sustain` or `volume` outside 0.0–1.0; a waveform outside the five; a negative frame count |
|
||||
| `AKERR_NULLPOINTER` | `NULL` passed to `akgl_audio_voice_active`'s `active` or `akgl_audio_mix`'s `dest` |
|
||||
| `AKGL_ERR_SDL` | `akgl_audio_init` cannot open or resume a playback device. The message carries `SDL_GetError()` |
|
||||
|
||||
A zero-length tone is **refused** rather than treated as "stop", because `akgl_audio_stop`
|
||||
already means that.
|
||||
|
||||
## Part 2 — the asset path
|
||||
|
||||
One function:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/assets.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* akgl_game_init creates akgl_mixer; this needs it. Nothing else in the
|
||||
library calls akgl_load_start_bgm, and there is no music registry entry. */
|
||||
akerr_ErrorContext *start_music(char *path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_load_start_bgm(path));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
It loads `fname` through `akgl_mixer`, creates a `MIX_Track` for it, stores that track in
|
||||
`akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]`, publishes the audio as `akgl_bgm`, and starts
|
||||
playback.
|
||||
|
||||
**Which formats work is SDL3_mixer's question, not libakgl's** — see the
|
||||
[SDL3_mixer documentation](https://wiki.libsdl.org/SDL3_mixer/). libakgl passes the path
|
||||
through verbatim; it is not resolved against `SDL_GetBasePath()`, so a relative path is
|
||||
relative to the process working directory. Loading is not deferred: `MIX_LoadAudio(..., true)`
|
||||
predecodes, so a large file costs its decode time here rather than at first play.
|
||||
|
||||
`akgl_game.h` reserves slot 1 of a 64-slot track table for the music and leaves the rest to
|
||||
you:
|
||||
|
||||
```c excerpt=include/akgl/game.h
|
||||
/** @brief Slot in ::akgl_tracks reserved for background music. Note that slot 0 is unused. */
|
||||
#define AKGL_GAME_AUDIO_TRACK_BGM 1
|
||||
/** @brief Size of the ::akgl_tracks table. Every simultaneous sound needs its own slot. */
|
||||
#define AKGL_GAME_AUDIO_MAX_TRACKS 64
|
||||
```
|
||||
|
||||
### Four honest statements about this half
|
||||
|
||||
**It requires `akgl_game_init`, not `akgl_audio_init`.** `akgl_mixer` is created by
|
||||
`akgl_game_init` at `src/game.c:250`, and it is the only place in the tree that creates it.
|
||||
`assets.h:17-18` says "`akgl_game_init` (or a bare `akgl_audio_init`) has to have run first";
|
||||
that is wrong. `akgl_audio_init` opens the *synthesizer's* `SDL_AudioStream` and never
|
||||
touches `akgl_mixer`, so calling only that leaves `akgl_load_start_bgm` handing `NULL` to
|
||||
`MIX_LoadAudio`.
|
||||
|
||||
**The music does not loop.** `src/assets.c:20` initialises `bgmprops` to 0,
|
||||
`src/assets.c:43` sets `MIX_PROP_PLAY_LOOPS_NUMBER` on it, and `src/assets.c:45` plays the
|
||||
track with it. **0 is SDL's "no property set" sentinel**, not a set this function owns, so the
|
||||
write is rejected — unchecked — and the play call is given no options. The music plays once
|
||||
and stops, and `akgl_load_start_bgm` reports success either way. `TODO.md` item 19; the fix
|
||||
is `SDL_CreateProperties()` into `bgmprops`, checked, and destroyed in `CLEANUP`.
|
||||
|
||||
**`AKGL_REGISTRY_MUSIC` exists and nothing populates it.** `akgl_registry_init_music()`
|
||||
creates the property set and `akgl_game_init` calls it, but no function in the library ever
|
||||
writes an entry. If you want music by name, that registry is yours to fill and yours to read
|
||||
— through `SDL_SetPointerProperty` and `SDL_GetPointerProperty` directly, as
|
||||
[Chapter 6](06-the-registry.md) describes. There is no `akgl_music_*` API.
|
||||
|
||||
**There are no audio fixtures anywhere in this repository.** Not one `.wav`, `.ogg`, `.mp3`,
|
||||
`.flac` or tracker module, in `tests/assets/` or `util/assets/` or anywhere else. Nothing in
|
||||
the tree calls `akgl_load_start_bgm` either. **This function has no test coverage at all**,
|
||||
which is worth knowing before you build on it: the loop defect above was found by reading
|
||||
the code, and a test would have caught it.
|
||||
|
||||
### The overwrite and the leak on the way out
|
||||
|
||||
`akgl_load_start_bgm` overwrites `akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]` without releasing
|
||||
whatever was there, so calling it twice leaks a `MIX_Track`. On a failure *after* the audio
|
||||
loads, the audio is destroyed again before the error is returned — but the track, if one was
|
||||
created, is not. Both are startup-helper behaviour and both are visible in the twelve lines
|
||||
of `src/assets.c`; treat this as a "call it once at startup" function, which is what its
|
||||
`@brief` says it is.
|
||||
|
||||
## Which one do I want?
|
||||
|
||||
**Sound effects for a game you are writing from scratch: the synthesizer.** No files, no
|
||||
formats, no decode cost, no licensing question about the samples, and a laser is four lines.
|
||||
|
||||
**Music, or anything recorded: SDL3_mixer.** Either through `akgl_load_start_bgm` for the one
|
||||
background track, or by using `akgl_mixer` directly with SDL3_mixer's own API — `akgl_mixer`
|
||||
and `akgl_tracks` are public globals for exactly that reason, and there is nothing libakgl
|
||||
adds that you would be going around.
|
||||
437
docs/18-utilities.md
Normal file
437
docs/18-utilities.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# 18. Utilities
|
||||
|
||||
`util.h`, `json_helpers.h` and `staticstring.h` hold the pieces that belong to no subsystem:
|
||||
rectangle overlap, asset path resolution, typed JSON accessors, and the pooled string type
|
||||
everything else passes around. The JSON accessors are the valuable part of this chapter —
|
||||
their status semantics are what every asset loader in the library is written against, and
|
||||
they are documented nowhere else.
|
||||
|
||||
## Collision helpers
|
||||
|
||||
Three functions, all axis-aligned, all in `util.h`:
|
||||
|
||||
| Function | Question it answers |
|
||||
|---|---|
|
||||
| `akgl_rectangle_points(dest, rect)` | What are the four corners of this `SDL_FRect`? |
|
||||
| `akgl_collide_point_rectangle(p, r, collide)` | Is this point inside those corners? |
|
||||
| `akgl_collide_rectangles(r1, r2, collide)` | Do these two rectangles overlap? |
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/* Do two actor boxes touch? Edges count as touching. */
|
||||
akerr_ErrorContext *boxes_touch(SDL_FRect *a, SDL_FRect *b, bool *hit)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_collide_rectangles(a, b, hit));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Is the mouse over a button? Corners first, then the point test. */
|
||||
akerr_ErrorContext *point_in_box(int x, int y, SDL_FRect *box, bool *hit)
|
||||
{
|
||||
akgl_RectanglePoints corners;
|
||||
akgl_Point p;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
p.x = x;
|
||||
p.y = y;
|
||||
p.z = 0;
|
||||
PASS(errctx, akgl_rectangle_points(&corners, box));
|
||||
PASS(errctx, akgl_collide_point_rectangle(&p, &corners, hit));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Four properties, all deliberate:
|
||||
|
||||
**Axis-aligned only.** There is no rotation support and no separating-axis test. An actor
|
||||
drawn with a non-zero `angle` still collides as its unrotated box.
|
||||
|
||||
**Edges count as touching.** A point exactly on a boundary is inside; two rectangles sharing
|
||||
an edge and no area overlap. Both comparisons are `>=` and `<=`.
|
||||
|
||||
**Coordinates are truncated from `float` to `int` on the way in.** A rectangle at
|
||||
`x = 10.9` has its corners at 10. That is right for tile-grid work and wrong for sub-pixel
|
||||
work; if you need the latter, do not round-trip through `akgl_rectangle_points`.
|
||||
|
||||
**`z` is carried and never used.** `akgl_Point` has one, `akgl_rectangle_points` never
|
||||
writes it, and neither collision test reads it. These are 2D tests.
|
||||
|
||||
### The arrangement it misses
|
||||
|
||||
`akgl_collide_rectangles` works corner by corner: it tests each rectangle's four corners
|
||||
against the other, eight tests, stopping at the first hit. Testing both directions is what
|
||||
catches one rectangle wholly inside the other, which has no corner in its neighbour.
|
||||
|
||||
**It still misses the cross.** A tall thin rectangle crossing a short wide one overlaps
|
||||
without either enclosing a corner of the other, and both are reported as not colliding:
|
||||
|
||||
```text
|
||||
+---+ +---+
|
||||
| | | |
|
||||
+---|---|---+ +---+---+---+ <- overlaps, but
|
||||
| | | | is | | | | no corner of
|
||||
+---|---|---+ reported +---+---+---+ either is inside
|
||||
| | as | | the other
|
||||
+---+ "no hit" +---+
|
||||
```
|
||||
|
||||
The fix is an edge-span comparison instead of a corner-containment one — `r1.x < r2.x + r2.w
|
||||
&& r2.x < r1.x + r1.w` on both axes — and it is not made here. If your game can produce that
|
||||
shape, and a long thin platform crossing a tall thin character is exactly that shape, test
|
||||
for it yourself. Recorded as an `@note` on the function.
|
||||
|
||||
This is separate from the fact that **nothing in the library calls these during a frame.**
|
||||
`akgl_physics_simulate` never calls `collide`, and `akgl_physics_arcade_collide` raises
|
||||
`AKERR_API` — see [Chapter 14](14-physics.md). Collision detection is yours to run, and these
|
||||
are the helpers for running it.
|
||||
|
||||
## Path resolution
|
||||
|
||||
Asset files name their neighbours relatively: a sprite definition names its spritesheet, a
|
||||
tilemap names its tilesets. "Relative" has to mean *relative to the file doing the naming*,
|
||||
not to wherever the game was launched from — and `akgl_path_relative` is what makes that
|
||||
true.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/* Resolve one asset's reference to a neighbour. dest must already be a
|
||||
claimed pool string -- this writes into it, it does not claim one. */
|
||||
akerr_ErrorContext *resolve_neighbour(char *mapdir, char *reference)
|
||||
{
|
||||
akgl_String *resolved = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_heap_next_string(&resolved));
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_path_relative(mapdir, reference, resolved));
|
||||
SDL_Log("%s -> %s", reference, resolved->data);
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(resolved));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The order it tries things in:
|
||||
|
||||
```text
|
||||
akgl_path_relative(root, path, dest)
|
||||
|
|
||||
1. realpath(path) <- relative to the process CWD
|
||||
| |
|
||||
| +-- succeeds -> absolute result into dest, done
|
||||
| |
|
||||
| +-- ENOENT ---+
|
||||
| |
|
||||
2. +-> realpath(root + "/" + path)
|
||||
|
|
||||
+-- succeeds -> absolute result, done
|
||||
|
|
||||
+-- fails -> the errno propagates
|
||||
```
|
||||
|
||||
Either way the result is **absolute**, with symlinks and `..` folded out, because both
|
||||
branches end in `realpath(3)`.
|
||||
|
||||
Three things to know:
|
||||
|
||||
- **`dest` must already be a claimed pool string.** This writes into it; it does not claim
|
||||
one for you. `root` is required even on the path where it goes unused.
|
||||
- **The fallback pastes `root` in front of `path` unconditionally**, so an absolute `path`
|
||||
that does not exist gets `root` prepended and fails on a nonsense spelling. Do not send
|
||||
absolute paths you expect to fall through.
|
||||
- **`AKERR_OUTOFBOUNDS`** if `root + "/" + path` would not fit `AKGL_MAX_STRING_LENGTH`
|
||||
(`PATH_MAX`); **`ENOENT`**, or whatever else `realpath(3)` reports — `EACCES`, `ELOOP`,
|
||||
`ENOTDIR` — if neither spelling names an existing file; **`AKGL_ERR_HEAP`** if the string
|
||||
pool cannot supply the scratch buffers.
|
||||
|
||||
> **Stale defect note.** `util.h:124-128` warns that the fallback path "returns straight out
|
||||
> of the ENOENT handler and so never releases the error context it was handling", costing one
|
||||
> of libakerror's 128 context slots per call for the life of the process, and `TODO.md` item
|
||||
> 15 records the same thing as open. **Both are out of date.** `src/util.c:115-129` sets a
|
||||
> flag in the `HANDLE(errctx, ENOENT)` block and calls `path_relative_root` *after* `FINISH`,
|
||||
> which is exactly the fix that entry proposes, and the code carries a comment explaining
|
||||
> why. The leak is gone; the two notes describing it are not.
|
||||
|
||||
## The JSON accessors
|
||||
|
||||
Every asset format in this library is JSON, parsed by
|
||||
[jansson](https://jansson.readthedocs.io/), and jansson's own accessors answer "missing
|
||||
key", "wrong type" and "index past the end" all with the same `NULL`. `json_helpers.h`
|
||||
splits them apart so a malformed asset file produces a message naming the key and what was
|
||||
wrong with it, instead of a `NULL` dereference three frames later.
|
||||
|
||||
**jansson owns `json_t`, `json_decref`, the parser and the reference-counting rules.** What
|
||||
this section documents is which status means what, and who owns the results.
|
||||
|
||||
### Five conventions that run through the whole set
|
||||
|
||||
**Absence is an error.** A missing key is `AKERR_KEY` — not a quiet `NULL`, not a zero. That
|
||||
is the opposite of the search functions in libakstdlib, and it is deliberate: an optional key
|
||||
is expressed by handing the resulting error to `akgl_get_json_with_default`, not by the
|
||||
accessor staying quiet. So `AKERR_KEY` in a loader is frequently a `HANDLE` block rather than
|
||||
a failure.
|
||||
|
||||
**Results come back through `dest`,** because the return value is the error context. Every
|
||||
argument is `NULL`-checked — the document or array, the key where there is one, and `dest` —
|
||||
and each raises `AKERR_NULLPOINTER` with a message saying which. `dest` is not written on any
|
||||
failure path.
|
||||
|
||||
**`json_t *` results are borrowed, not owned.** `akgl_get_json_object_value`,
|
||||
`akgl_get_json_array_value` and `akgl_get_json_array_index_object` hand back pointers *into*
|
||||
the document. They are valid until the document is freed and **must never be
|
||||
`json_decref`'d.** Doing so drops a reference the accessor never took and takes the sub-object
|
||||
out from under the document that owns it.
|
||||
|
||||
**Strings are the exception: they are copied.** `akgl_get_json_string_value` and
|
||||
`akgl_get_json_array_index_string` write into a pooled `akgl_String` you release yourself.
|
||||
`*dest` doubles as an input — `NULL` means "claim one for me", non-`NULL` means "write into
|
||||
this one" — and it must be *initialized* either way, because an indeterminate `*dest` is
|
||||
dereferenced. A value longer than `AKGL_MAX_STRING_LENGTH` is truncated silently.
|
||||
|
||||
**The document is never modified.** No accessor writes to it, adds to it, or reorders it.
|
||||
|
||||
### The status table
|
||||
|
||||
These are the four statuses this family raises. [Chapter 4](04-errors.md) carries the full
|
||||
tables for the library; this one says what each means *here*.
|
||||
|
||||
| Status | Means | Raised by |
|
||||
|---|---|---|
|
||||
| `AKERR_NULLPOINTER` | Any argument was `NULL` — the document, the key, or `dest` | All |
|
||||
| `AKERR_KEY` | **The key is absent.** The idiomatic "optional thing was not there" | The object accessors |
|
||||
| `AKERR_TYPE` | The key is present but is the wrong JSON type | All |
|
||||
| `AKERR_OUTOFBOUNDS` | The index is outside the array. A negative index reports the same way as one past the end | The `akgl_get_json_array_index_*` family |
|
||||
| `AKGL_ERR_HEAP` | `*dest` was `NULL` and the string pool is exhausted | The two string accessors |
|
||||
|
||||
**`AKERR_INDEX` is not in that list, and libakgl never raises it anywhere.** It appears in
|
||||
this subsystem only as a `HANDLE_GROUP` arm inside `akgl_get_json_with_default`, defending
|
||||
against a status no accessor produces. Harmless, and worth knowing before you write a
|
||||
`HANDLE` block for it.
|
||||
|
||||
> **Stale header prose.** The conventions block at the top of `json_helpers.h` says "**@p
|
||||
> dest is not `NULL`-checked** except where noted, so a `NULL` there is a crash rather than an
|
||||
> error context", and `akgl_get_json_string_value`'s `@param key` says it is "checked here,
|
||||
> unlike the other accessors in this file". **Neither is true.** All eleven accessors check
|
||||
> `dest`, and all seven that take a `key` check that too — `src/json_helpers.c` raises
|
||||
> `AKERR_NULLPOINTER` with "NULL destination pointer" or "NULL key string" at every one. The
|
||||
> per-function claim that a `NULL` key "is reported as a missing key rather than as a `NULL`
|
||||
> pointer" is wrong the same way.
|
||||
|
||||
### Integer is strict; number is not
|
||||
|
||||
This is the distinction that catches people:
|
||||
|
||||
| Accessor | `"w": 3` | `"w": 3.0` | `"w": "3"` |
|
||||
|---|---|---|---|
|
||||
| `akgl_get_json_integer_value` | 3 | **`AKERR_TYPE`** | `AKERR_TYPE` |
|
||||
| `akgl_get_json_number_value` | 3.0f | 3.0f | `AKERR_TYPE` |
|
||||
| `akgl_get_json_double_value` | 3.0 | 3.0 | `AKERR_TYPE` |
|
||||
|
||||
`3.0` is a *real* in JSON, and `akgl_get_json_integer_value` refuses it rather than
|
||||
truncating it. Use one of the number accessors where either spelling should be accepted;
|
||||
`_double_value` exists because the physics constants are `double`.
|
||||
|
||||
`akgl_get_json_boolean_value` is strict in the same direction: `0` and `1` are numbers in
|
||||
JSON and are refused. It wants `true` or `false`.
|
||||
|
||||
### Reading a document
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <jansson.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/* A required key, an optional one with a default, and a borrowed sub-object. */
|
||||
akerr_ErrorContext *read_sprite_header(json_t *doc)
|
||||
{
|
||||
json_t *frames = NULL; /* BORROWED. Never json_decref this. */
|
||||
akgl_String *name = NULL; /* Copied, and pooled. Release it. */
|
||||
int width = 0;
|
||||
int width_default = 32;
|
||||
float32_t scale = 0.0f;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
/* Required: an absent "name" is AKERR_KEY and propagates. */
|
||||
CATCH(errctx, akgl_get_json_string_value(doc, "name", &name));
|
||||
|
||||
/* Optional: AKERR_KEY becomes width_default and a NULL return. */
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_integer_value(doc, "width", &width),
|
||||
&width_default,
|
||||
&width,
|
||||
sizeof(int)));
|
||||
|
||||
/* Strict: "scale": 2 and "scale": 2.0 are both accepted here.
|
||||
akgl_get_json_integer_value would refuse the second. */
|
||||
CATCH(errctx, akgl_get_json_number_value(doc, "scale", &scale));
|
||||
|
||||
/* Borrowed: valid until the document is freed. */
|
||||
CATCH(errctx, akgl_get_json_array_value(doc, "frames", &frames));
|
||||
SDL_Log("%s: %d wide, %.1f scale, %d frames",
|
||||
name->data, width, scale, (int)json_array_size(frames));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(name));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
### `akgl_get_json_with_default` — how optional is spelled
|
||||
|
||||
Run the accessor, hand its error context here along with the fallback. A status it handles
|
||||
becomes `dest` holding the default and a `NULL` return; anything else propagates untouched.
|
||||
When the error *is* consumed it is also released, so you must not release it again. A `NULL`
|
||||
`e` — the accessor succeeded — returns at once without touching `dest`.
|
||||
|
||||
**It defaults on three statuses**, and this is the current behaviour, checked against
|
||||
`src/json_helpers.c:207-211`:
|
||||
|
||||
| Status | Defaulted? | Why |
|
||||
|---|---|---|
|
||||
| `AKERR_KEY` | Yes | The key is absent. That is what "optional" means for an object member |
|
||||
| `AKERR_OUTOFBOUNDS` | **Yes** | The index is past the end. That is what "optional" means for an array element |
|
||||
| `AKERR_INDEX` | Yes | Defensive only — nothing in libakgl raises it |
|
||||
| `AKERR_TYPE` | **No** | Propagates, and that is the correct line to draw |
|
||||
|
||||
**The `AKERR_TYPE` exclusion is the interesting one.** "The key is missing" and "the key is
|
||||
present but holds a string where a number belongs" are not the same situation. The first is
|
||||
an omitted setting and a default is exactly right for it. The second is a malformed document,
|
||||
and quietly substituting a default there would hide the defect and hand the game a value the
|
||||
author never wrote. So it propagates.
|
||||
|
||||
`AKERR_OUTOFBOUNDS` is the one that changed, because it is what the
|
||||
`akgl_get_json_array_index_*` family actually raises for a short array. **An array element
|
||||
does get a default now**, and "this element is optional" works for an array index as well as
|
||||
for an object member.
|
||||
|
||||
> **Stale header prose.** `include/akgl/json_helpers.h:230-234` still says the opposite —
|
||||
> that it defaults on `AKERR_KEY` and `AKERR_INDEX` "but *not* on `AKERR_OUTOFBOUNDS` … so
|
||||
> this pairs with the object accessors and does not currently give an array index a default".
|
||||
> That was true before 0.5.0. `src/json_helpers.c` now carries a third
|
||||
> `HANDLE_GROUP(e, AKERR_OUTOFBOUNDS)` arm, placed *above* the arm holding the `memcpy`
|
||||
> because `HANDLE_GROUP` emits no `break` and every arm falls into that body, and
|
||||
> `tests/json_helpers.c` covers it through both the integer and object index accessors.
|
||||
> `TODO.md` item 18 records it as fixed. The header comment wants correcting in its own
|
||||
> commit.
|
||||
|
||||
`defsize` is trusted, not derived: it is a `memcpy` through `void *` with no type
|
||||
information, and it must match the type both sides actually are.
|
||||
|
||||
**`defsize` is not the trap, though. Ownership is.**
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <jansson.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
|
||||
/* akgl_get_json_with_default hands BACK the context it was given when it
|
||||
does not handle the status. Take the result into a local, drop your own
|
||||
pointer, and release it yourself -- a CLEANUP block that also releases it
|
||||
double-releases, and a double-released context corrupts the failure
|
||||
instead of reporting it. */
|
||||
akerr_ErrorContext *optional_int(json_t *doc, char *key, int *dest, int fallback)
|
||||
{
|
||||
akerr_ErrorContext *keyerr = NULL;
|
||||
akerr_ErrorContext *defaulted = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
keyerr = akgl_get_json_integer_value(doc, key, dest);
|
||||
defaulted = akgl_get_json_with_default(keyerr, &fallback, dest, sizeof(int));
|
||||
keyerr = NULL; /* ownership passed either way */
|
||||
if ( defaulted != NULL ) {
|
||||
defaulted->handled = true;
|
||||
defaulted = akerr_release_error(defaulted);
|
||||
FAIL_RETURN(errctx, AKGL_ERR_BEHAVIOR, "%s was present but not an integer", key);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The inline form in the previous example — passing the accessor's return straight into
|
||||
`akgl_get_json_with_default` inside a `CATCH` — is safe, because you never hold a second
|
||||
pointer to the context. It is when you *keep* one that this matters, and a test written the
|
||||
naive way passes against broken code: the first draft of the `AKERR_OUTOFBOUNDS` test in
|
||||
`tests/json_helpers.c` did exactly that. See [Chapter 4](04-errors.md) and `AGENTS.md` under
|
||||
"Testing Guidelines".
|
||||
|
||||
### The Tiled property helpers are elsewhere
|
||||
|
||||
`akgl_get_json_properties_integer`, `_number`, `_float`, `_double`, `_string` and
|
||||
`akgl_get_json_tilemap_property` are declared in `tilemap.h`, not here. They read Tiled's
|
||||
custom-property array — a list of `{"name": …, "type": …, "value": …}` objects — rather than
|
||||
a plain JSON object, which is a different shape and belongs to a different chapter. See
|
||||
[Chapter 13](13-tilemaps.md).
|
||||
|
||||
## `akgl_String`
|
||||
|
||||
The library allocates nothing at runtime, so a string here is a fixed-capacity buffer claimed
|
||||
from the pool:
|
||||
|
||||
```c excerpt=include/akgl/staticstring.h
|
||||
#define AKGL_MAX_STRING_LENGTH PATH_MAX
|
||||
|
||||
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
|
||||
typedef struct
|
||||
{
|
||||
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
|
||||
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
|
||||
} akgl_String;
|
||||
```
|
||||
|
||||
`data` is a plain `char[]`, so `str->data` goes anywhere a `char *` does. `refcount` belongs
|
||||
to the heap layer — [Chapter 5](05-the-heap.md) — and **`akgl_heap_next_string` is the one
|
||||
acquire function that takes the reference for you**, unlike the other four pools.
|
||||
|
||||
Two functions operate on the contents:
|
||||
|
||||
| Function | What it does |
|
||||
|---|---|
|
||||
| `akgl_string_initialize(obj, init)` | Copies `init` in, or zero-fills when `init` is `NULL`, and sets `refcount` to 1. Always NUL-terminates; truncates silently |
|
||||
| `akgl_string_copy(src, dest, count)` | Bounded copy between two already-claimed slots. `count` of 0 selects the whole buffer. Always NUL-terminates. Leaves `refcount` alone |
|
||||
|
||||
**Capacity is `PATH_MAX`**, which is what makes the string pool by far the largest of the
|
||||
five — 256 slots is about a megabyte — and why claiming the *last* free slot from it costs
|
||||
250 ns against 4 ns from the actor pool. Same algorithm, different cache behaviour; the scan
|
||||
touches one reference count every 4 KiB. `PERFORMANCE.md` has the measurement.
|
||||
|
||||
`akgl_string_copy` refuses a negative `count` or one above `AKGL_MAX_STRING_LENGTH` with
|
||||
`AKERR_OUTOFBOUNDS`, because both buffers are exactly that long and a larger count walked off
|
||||
the end of two pool slots at once.
|
||||
|
||||
## The two test-only helpers
|
||||
|
||||
`akgl_compare_sdl_surfaces` and `akgl_render_and_compare` are in `util.h` and are labelled in
|
||||
the header as "REALLY slow routines that are only useful in testing harnesses". They are not
|
||||
frame-path functions and this manual does not otherwise cover them:
|
||||
|
||||
- `akgl_compare_sdl_surfaces(s1, s2)` — dimensions, pitch and format first, then a `memcmp`
|
||||
over the raw pixels. A mismatch is reported as `AKERR_VALUE`, an error rather than an
|
||||
out-parameter.
|
||||
- `akgl_render_and_compare(t1, t2, x, y, w, h, writeout)` — draws each texture into the same
|
||||
rectangle, reads the framebuffer back after each, and compares. Answers "do these render
|
||||
the same", which is not the same question as "are these identical", because the renderer's
|
||||
scaling and blending sit in between. `writeout` names a PNG of the first render, under
|
||||
`SDL_GetBasePath()`, which is how you see what was actually drawn when an assertion fails.
|
||||
|
||||
Both have a scar worth repeating. Until 0.5.0 `akgl_render_and_compare` drew `t1` on **both**
|
||||
passes, so it always reported a match and every image assertion built on it asserted nothing.
|
||||
384
docs/21-appendix-limits.md
Normal file
384
docs/21-appendix-limits.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# 21. Appendix: limits, statuses and properties
|
||||
|
||||
Three reference tables that no single chapter owns: which function raises which status,
|
||||
every compile-time bound, and every configuration property the library reads.
|
||||
|
||||
The constant tables come in as `excerpt=` blocks against their headers, so they cannot
|
||||
drift from the values the library was built with — and so the hand-aligned columns those
|
||||
headers maintain survive.
|
||||
|
||||
## A. Status cross-reference
|
||||
|
||||
**What this table is.** The statuses a caller can expect to see out of each function. It is
|
||||
built from the `FAIL_*` and `HANDLE*` sites in `src/` — the function's own body and the
|
||||
`static` helpers only it calls — plus, where a nested libakgl call is part of the documented
|
||||
contract, that status too.
|
||||
|
||||
**It is not exhaustive, and cannot be.** Every libakgl function a given one calls can
|
||||
propagate anything from its own row, and so can libakstdlib: nearly everything that touches
|
||||
a file also carries whatever `aksl_fopen`, `aksl_fread`, `aksl_fwrite` and `aksl_fclose`
|
||||
report, including bare `errno` values such as `ENOENT`, `EACCES` and `ENOSPC`. Use the table
|
||||
to write the `HANDLE` arms you care about, not to prove a status cannot arrive.
|
||||
|
||||
`(H)` marks a status the function **handles** rather than raises.
|
||||
|
||||
What the statuses mean is [Chapter 4](04-errors.md).
|
||||
|
||||
### `error.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_error_init` | `AKERR_STATUS_RANGE_OVERLAP`, `AKERR_STATUS_RANGE_FULL`, `AKERR_STATUS_NAME_FULL` (all libakerror's) |
|
||||
|
||||
### `heap.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_heap_init`, `akgl_heap_init_actor` | *nothing — no failure path today* |
|
||||
| `akgl_heap_next_actor`, `_sprite`, `_spritesheet`, `_character`, `_string` | `AKGL_ERR_HEAP` |
|
||||
| `akgl_heap_release_actor`, `_sprite`, `_spritesheet`, `_character`, `_string` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `registry.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_registry_init` and the eight `akgl_registry_init_*` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_registry_load_properties` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKERR_TYPE`, `AKGL_ERR_HEAP` |
|
||||
| `akgl_set_property` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_get_property` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKERR_VALUE`, `AKGL_ERR_HEAP` |
|
||||
|
||||
### `game.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_game_init` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_game_update_fps`, `akgl_game_lowfps` | *`void` — cannot report* |
|
||||
| `akgl_game_state_lock` | `AKGL_ERR_SDL` |
|
||||
| `akgl_game_state_unlock` | *nothing* |
|
||||
| `akgl_game_update` | `AKGL_ERR_SDL`, plus whatever `updatefunc`, `akgl_tilemap_scale_actor`, the physics backend or the renderer raises |
|
||||
| `akgl_game_save` | `AKERR_NULLPOINTER`, `AKERR_IO`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_game_save_actors` | `AKERR_NULLPOINTER`, `AKERR_IO` |
|
||||
| `akgl_game_load` | `AKERR_NULLPOINTER`, `AKERR_API`, `AKERR_EOF`, `AKERR_IO`, `AKERR_VALUE` |
|
||||
| `akgl_game_load_versioncmp` | `AKERR_NULLPOINTER`, `AKERR_VALUE`, `AKERR_API` |
|
||||
|
||||
### `actor.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_actor_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_actor_set_character` | `AKERR_NULLPOINTER` — **including "no such character"** |
|
||||
| `akgl_actor_add_child` | `AKERR_NULLPOINTER`, `AKERR_RELATIONSHIP`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_actor_update` | `AKERR_NULLPOINTER`, `AKERR_KEY` (H) |
|
||||
| `akgl_actor_render` | `AKERR_NULLPOINTER`, `AKERR_KEY` (H), `AKERR_OUTOFBOUNDS` (H) |
|
||||
| `akgl_actor_automatic_face`, `_logic_movement`, `_logic_changeframe` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_actor_cmhf_*_on`, `akgl_actor_cmhf_*_off` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_registry_iterate_actor` | `AKERR_NULLPOINTER`, `AKERR_KEY` — **`void`; unhandled means the process exits** |
|
||||
|
||||
### `character.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_character_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_character_sprite_add` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_character_sprite_get` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_character_load_json` | `AKERR_NULLPOINTER`, `AKERR_KEY` for a state name that is not in `AKGL_REGISTRY_ACTOR_STATE_STRINGS`, and `AKERR_TYPE` from the JSON accessors |
|
||||
| `akgl_character_state_sprites_iterate` | `AKERR_NULLPOINTER` — **`void`; unhandled means the process exits** |
|
||||
|
||||
### `sprite.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_sprite_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_sprite_load_json` | `AKERR_NULLPOINTER`, `AKERR_VALUE`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_spritesheet_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKGL_ERR_SDL` |
|
||||
| `akgl_spritesheet_coords_for_frame` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `json_helpers.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_get_json_object_value`, `_boolean_`, `_integer_`, `_number_`, `_double_`, `_string_`, `_array_value` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for absent**, **`AKERR_TYPE` for wrong type** |
|
||||
| `akgl_get_json_array_index_object`, `_integer`, `_string` | `AKERR_NULLPOINTER`, **`AKERR_OUTOFBOUNDS` for a short array**, `AKERR_TYPE` |
|
||||
| `akgl_get_json_with_default` | `AKERR_NULLPOINTER`; handles `AKERR_KEY` (H), `AKERR_OUTOFBOUNDS` (H), `AKERR_INDEX` (H) |
|
||||
|
||||
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` also raise
|
||||
`AKGL_ERR_HEAP` when they have to claim a pooled string and the pool is empty.
|
||||
|
||||
### `physics.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_physics_init_null`, `akgl_physics_init_arcade` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_physics_factory` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for an unknown backend name** |
|
||||
| `akgl_physics_simulate` | `AKERR_NULLPOINTER`; handles `AKGL_ERR_LOGICINTERRUPT` (H) |
|
||||
| `akgl_physics_null_gravity`, `_collide`, `_move` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_physics_arcade_gravity`, `_move` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_physics_arcade_collide` | `AKERR_NULLPOINTER`, **`AKERR_API` — not implemented** |
|
||||
|
||||
### `renderer.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_render_2d_init` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, plus `akgl_get_property`'s |
|
||||
| `akgl_render_2d_bind`, `_shutdown`, `_frame_start`, `_frame_end`, `_draw_texture`, `_draw_world` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_render_2d_draw_mesh` | **`AKERR_API` — not implemented** |
|
||||
|
||||
### `draw.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_draw_point`, `_line`, `_rect`, `_filled_rect` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_draw_background` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_circle` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_flood_fill` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, `AKERR_OUTOFBOUNDS` past `AKGL_DRAW_MAX_FLOOD_SPANS` |
|
||||
| `akgl_draw_copy_region`, `_paste_region` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
|
||||
### `tilemap.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_tilemap_load` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layers` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layer_tile` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layer_image` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_tilemap_load_layer_objects` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layer_object_actor` | `AKERR_KEY` |
|
||||
| `akgl_tilemap_load_tilesets` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` past `AKGL_TILEMAP_MAX_TILESETS` |
|
||||
| `akgl_tilemap_load_tilesets_each` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_tilemap_load_physics` | handles `AKERR_KEY` (H) — every physics property is optional |
|
||||
| `akgl_get_json_tilemap_property` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKERR_TYPE` |
|
||||
| `akgl_tilemap_draw`, `_draw_tileset` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_scale_actor`, `akgl_tilemap_release` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `controller.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_controller_open_gamepads`, `_list_keyboards` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_controller_handle_event`, `_button_down`, `_button_up`, `_added`, `_removed` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_controller_pushmap` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_controller_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site |
|
||||
| `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_text_loadfont` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_text_unloadfont` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_text_rendertextat` | `AKERR_NULLPOINTER` — including a `NULL` `akgl_renderer`, a `NULL` `sdl_renderer`, and a backend never run through `akgl_render_2d_bind` and so missing `draw_texture`. An **empty** string is not an error: it returns success having drawn nothing |
|
||||
| `akgl_text_measure`, `_measure_wrapped` | `AKERR_NULLPOINTER`. An empty string is legal and measures 0 wide by one line high |
|
||||
| `akgl_audio_tone`, `akgl_audio_sweep` | `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_audio_voice_active`, `akgl_audio_mix` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_load_start_bgm` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_path_relative` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_rectangle_points`, `akgl_collide_point_rectangle`, `akgl_collide_rectangles` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_compare_sdl_surfaces` | `AKERR_NULLPOINTER`, `AKERR_VALUE` |
|
||||
| `akgl_render_and_compare` | `AKERR_NULLPOINTER`, `AKERR_IO`, `AKGL_ERR_SDL` |
|
||||
| `akgl_string_initialize` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_string_copy` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKERR_VALUE` |
|
||||
|
||||
## B. Compile-time limits
|
||||
|
||||
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` five
|
||||
are overridable**, and even those have to be overridden for the whole build — see
|
||||
[Chapter 5](05-the-heap.md), "The ceilings are a compile-time ABI constraint". The rest are
|
||||
plain `#define`s with no `#ifndef` guard: changing one means editing the header and
|
||||
rebuilding libakgl and everything linking it.
|
||||
|
||||
### The object pools
|
||||
|
||||
```c excerpt=include/akgl/heap.h
|
||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
||||
#define AKGL_MAX_HEAP_ACTOR 64
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_SPRITE
|
||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_SPRITESHEET
|
||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_CHARACTER
|
||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
||||
#endif
|
||||
#ifndef AKGL_MAX_HEAP_STRING
|
||||
#define AKGL_MAX_HEAP_STRING 256
|
||||
#endif
|
||||
```
|
||||
|
||||
Exceeding any of them is `AKGL_ERR_HEAP`.
|
||||
|
||||
### Strings
|
||||
|
||||
`AKGL_MAX_STRING_LENGTH` is `PATH_MAX`, which is 4096 on Linux. That is the capacity of
|
||||
every `akgl_String`, and it is why the string pool is over a megabyte on its own.
|
||||
|
||||
### Actors
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
/** @brief Longest actor name, including the terminator. Names are truncated, not rejected. */
|
||||
#define AKGL_ACTOR_MAX_NAME_LENGTH 128
|
||||
/** @brief Children one actor can carry. A child moves with its parent rather than simulating. */
|
||||
#define AKGL_ACTOR_MAX_CHILDREN 8
|
||||
```
|
||||
|
||||
`AKGL_ACTOR_MAX_STATES` is 32, fixed by the width of `int32_t state` rather than chosen.
|
||||
A ninth child is `AKERR_OUTOFBOUNDS`; an over-long name is silently truncated.
|
||||
|
||||
### Sprites and spritesheets
|
||||
|
||||
```c excerpt=include/akgl/sprite.h
|
||||
#define AKGL_SPRITE_MAX_FRAMES 16
|
||||
#define AKGL_SPRITE_MAX_NAME_LENGTH 128
|
||||
#define AKGL_SPRITE_MAX_REGISTRY_SIZE 1024
|
||||
#define AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH 512
|
||||
```
|
||||
|
||||
`akgl_sprite_load_json` bounds the `frames` array against `AKGL_SPRITE_MAX_FRAMES` before
|
||||
writing anything, and refuses a frame number that will not fit a `uint8_t` rather than
|
||||
truncating it into an index naming a different tile (`TODO.md` item 16).
|
||||
|
||||
**`AKGL_SPRITE_MAX_REGISTRY_SIZE` is dead.** It is defined in `sprite.h` and referenced
|
||||
nowhere in `src/`, `include/`, `tests/` or `util/`. It bounds nothing. Do not size anything
|
||||
against it.
|
||||
|
||||
### Tilemaps
|
||||
|
||||
```c excerpt=include/akgl/tilemap.h
|
||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
||||
/** @brief Tallest map, in tiles. */
|
||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
||||
/** @brief Tilesets per map. */
|
||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
||||
/** @brief Longest tileset name. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
||||
/** @brief Longest resolved tileset image path. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
||||
```
|
||||
|
||||
Three corrections to what those comments say, all verified against `src/tilemap.c`:
|
||||
|
||||
- **`AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` *is* enforced.** The comment above says it is not.
|
||||
`akgl_tilemap_load_layer_objects` bounds `j` at the top of the loop body and raises
|
||||
`AKERR_OUTOFBOUNDS`; `akgl_tilemap_load_tilesets` does the same for
|
||||
`AKGL_TILEMAP_MAX_TILESETS`. Both landed in 0.5.0 (`TODO.md`, "Known and still open"
|
||||
item 17), and the header comment was not updated with them.
|
||||
- **Width and height are not bounded individually.** `akgl_tilemap_load` checks
|
||||
`width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, and the comparison
|
||||
is `>=`, so the true ceiling is **262143 tiles** in any shape. A 1024×256 map loads; a
|
||||
512×512 one does not.
|
||||
- **`AKGL_TILEMAP_MAX_TILES_PER_IMAGE` is not checked anywhere.** It sizes
|
||||
`tile_offsets[65536][2]` in the struct and nothing validates a tileset against it. It is
|
||||
also why `akgl_Tileset` is large: 512 KiB of offset table per tileset, whatever the image
|
||||
actually holds.
|
||||
|
||||
### Controllers
|
||||
|
||||
```c excerpt=include/akgl/controller.h
|
||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
||||
#define AKGL_MAX_CONTROL_MAPS 8
|
||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
||||
#define AKGL_MAX_CONTROLS 32
|
||||
```
|
||||
|
||||
`AKGL_CONTROLLER_KEY_BUFFER` is 32 — the keystroke ring, sized so a program polling once a
|
||||
frame never loses a key to a fast typist. `AKGL_CONTROLLER_KEYSTROKE_TEXT` is 8, enough for
|
||||
one UTF-8 code point and its terminator; a longer commit is truncated **on a code point
|
||||
boundary** rather than through the middle of one.
|
||||
|
||||
### Drawing
|
||||
|
||||
`AKGL_DRAW_MAX_FLOOD_SPANS` is 4096 — the fixed stack of horizontal runs `akgl_draw_flood_fill`
|
||||
keeps instead of recursing per pixel. A region needing more pending runs at once reports
|
||||
`AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape
|
||||
needs a few dozen.
|
||||
|
||||
### Audio
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
||||
|
||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
||||
```
|
||||
|
||||
`AKGL_AUDIO_MAX_VOICES` is 3. `AKGL_AUDIO_SWEEP_TICK_HZ` is 60, and it divides the sample
|
||||
rate exactly, so a sweep step boundary always lands on a whole frame —
|
||||
`AKGL_AUDIO_SWEEP_TICK_FRAMES` is 735.
|
||||
|
||||
The SDL_mixer side is separate: `AKGL_GAME_AUDIO_MAX_TRACKS` is 64 playback tracks, and
|
||||
**slot 0 is unused** — `AKGL_GAME_AUDIO_TRACK_BGM` is slot 1, and the rest are yours.
|
||||
|
||||
### Time and the frame
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `AKGL_TIME_ONESEC_NS` | 1000000000 | Nanoseconds in a second — the unit `SDL_GetTicksNS` reports in |
|
||||
| `AKGL_TIME_ONEMS_NS` | 1000000 | Nanoseconds in a millisecond — the scale factor from JSON frame durations to internal ones |
|
||||
| `AKGL_GAME_STATE_LOCK_BUDGET_MS` | 1000 | How long `akgl_game_state_lock` keeps trying before raising |
|
||||
| `AKGL_GAME_STATE_LOCK_RETRY_MS` | 100 | How long it sleeps between attempts |
|
||||
| `AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` | 0.05 | Default `physics.max_timestep`, in seconds — three frames at 60 Hz |
|
||||
|
||||
`AKGL_TIME_ONEMS_NS` was called `AKGL_TIME_ONESEC_MS` until 0.5.0 and held 1000000 — a name
|
||||
and a value describing two different quantities. The state lock read it as a one-second
|
||||
budget and was wrong by a factor of a thousand, blocking for roughly sixteen minutes.
|
||||
|
||||
### The status band
|
||||
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) |
|
||||
| `AKGL_ERR_COUNT` | 5 |
|
||||
| `AKGL_ERR_LIMIT` | 261 — **the one-past-the-end sentinel, not a status** |
|
||||
|
||||
## C. Configuration properties
|
||||
|
||||
Everything the library reads out of `AKGL_REGISTRY_PROPERTIES`. **Every value is a string**,
|
||||
including the numbers, and each is parsed by whoever reads it.
|
||||
|
||||
| Property | Default | Read by | Read when | Meaning |
|
||||
|---|---|---|---|---|
|
||||
| `game.screenwidth` | `"0"` | `akgl_render_2d_init` | once, at init | Window and camera width in pixels. Passed straight to `SDL_CreateWindowAndRenderer` |
|
||||
| `game.screenheight` | `"0"` | `akgl_render_2d_init` | once, at init | Window and camera height in pixels |
|
||||
| `physics.gravity.x` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on x, px/s² |
|
||||
| `physics.gravity.y` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on y, px/s². Positive is down |
|
||||
| `physics.gravity.z` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on z, px/s² |
|
||||
| `physics.drag.x` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Fraction of environmental velocity shed per second on x |
|
||||
| `physics.drag.y` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Same for y. **The only brake on falling** — there is no terminal velocity, and `ey` approaches `gravity_y / drag_y` |
|
||||
| `physics.drag.z` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Same for z |
|
||||
| `physics.max_timestep` | `"0.05"` | `akgl_physics_init_arcade` | once, at init | Ceiling on a single simulation `dt`, in seconds |
|
||||
|
||||
That is the complete list — nine properties, read by two functions, each exactly once.
|
||||
|
||||
**`physics.engine` is not read by anything.** `physics.h` says in two places that
|
||||
`akgl_game_init` passes it to `akgl_physics_factory`; `akgl_game_init` never calls the
|
||||
factory, and the string does not appear in `src/`. Pass `"null"` or `"arcade"` to
|
||||
`akgl_physics_factory` yourself. Correcting the header is a source change and is deliberately
|
||||
out of scope for this manual.
|
||||
|
||||
**Everything is read at init.** Setting any of these after `akgl_render_2d_init` or
|
||||
`akgl_physics_init_arcade` has no effect at all, and reports success. See
|
||||
[Chapter 6](06-the-registry.md), and the startup order in
|
||||
[Chapter 7](07-the-game-and-the-frame.md).
|
||||
|
||||
**A property set before `AKGL_REGISTRY_PROPERTIES` exists is silently discarded.**
|
||||
`akgl_set_property` does not check SDL's return value. `akgl_game_init` and
|
||||
`akgl_registry_init` both create the registry; a hand-rolled startup that creates neither
|
||||
gets no configuration and no error.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 4](04-errors.md) — what each status means.
|
||||
- [Chapter 5](05-the-heap.md) — the pools, and the ABI constraint on their ceilings.
|
||||
- [Chapter 6](06-the-registry.md) — the property store these live in.
|
||||
- The generated Doxygen reference — every function's own `@throws` list and full signature.
|
||||
64
docs/README.md
Normal file
64
docs/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# The libakgl manual
|
||||
|
||||
libakgl is a C library for building 2D games on SDL3. It is not an engine: there is no
|
||||
editor, no scripting layer, no inheritance and no runtime `malloc`. Behaviour attaches to a
|
||||
struct as function pointers, objects come from fixed pools, and every call reports failure
|
||||
through an error context you cannot silently ignore.
|
||||
|
||||
This manual teaches the library. It deliberately does **not** re-document its dependencies
|
||||
— libakerror owns the error-handling protocol, SDL3 owns renderers and events, Tiled owns
|
||||
the map format, jansson owns the JSON API. Each chapter says what libakgl adds or
|
||||
constrains, shows what a caller actually writes, and links out for the rest. For
|
||||
per-function reference, build the Doxygen output with `doxygen Doxyfile`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[1. Introduction](01-introduction.md)** | What libakgl is, what it refuses to be, the frame budget, and who owns which documentation |
|
||||
| **[2. Design philosophy](02-design-philosophy.md)** | Bounded pools, pluggable backends, bit flags, name-based registries, one world at a time |
|
||||
| **[3. Getting started](03-getting-started.md)** | Dependencies, `add_subdirectory` vs `akgl.pc`, and the smallest program that opens a window |
|
||||
| **[4. Errors and status codes](04-errors.md)** | The status tables, what each code means *here*, and the traps that fail silently |
|
||||
| **[5. The heap](05-the-heap.md)** | The five pools, `akgl_heap_next_*`, `akgl_String`, and why exhaustion usually means a missing release |
|
||||
| **[6. The registry](06-the-registry.md)** | The eight registries, configuration properties, and the id-0 silent no-op |
|
||||
| **[7. The game and the frame](07-the-game-and-the-frame.md)** | Startup order, `akgl_game_update`, the state lock, iterators, savegames |
|
||||
| **[8. Rendering](08-rendering.md)** | The backend vtable, the frame contract, cameras, and embedding a host's own `SDL_Renderer` |
|
||||
| **[9. Drawing](09-drawing.md)** | Points, lines, rectangles, circles, flood fill, and saving a region |
|
||||
| **[10. Spritesheets and sprites](10-spritesheets-and-sprites.md)** | One texture shared by path, the sprite JSON format, frame animation |
|
||||
| **[11. Characters](11-characters.md)** | State-to-sprite mappings, movement constants, the character JSON format |
|
||||
| **[12. Actors](12-actors.md)** | The state bitmask, the six behaviour hooks, parents and children, layers |
|
||||
| **[13. Tilemaps](13-tilemaps.md)** | Tiled TMJ, libakgl's three extensions, and the limits that bind a map |
|
||||
| **[14. Physics](14-physics.md)** | Thrust, environment and velocity; the `null` and `arcade` backends; what is not implemented |
|
||||
| **[15. Input](15-input.md)** | Control maps, push-not-poll dispatch, the keystroke ring, gamepads |
|
||||
| **[16. Text and fonts](16-text-and-fonts.md)** | Loading, drawing, measuring, and the teardown order that matters |
|
||||
| **[17. Audio](17-audio.md)** | The three-voice synthesizer, and the separate background-music path |
|
||||
| **[18. Utilities](18-utilities.md)** | Collision helpers, path resolution, the JSON accessors, static strings |
|
||||
| **[19. Tutorial: a 2D sidescroller](19-tutorial-sidescroller.md)** | Gravity, a jump, collision you write yourself, coins and hazards |
|
||||
| **[20. Tutorial: a top-down JRPG](20-tutorial-jrpg.md)** | A town map, NPCs spawned from map objects, four-way animation, a text box, a follower |
|
||||
| **[21. Appendix](21-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.
|
||||
|
||||
## Every example here is checked
|
||||
|
||||
The examples in these chapters are compiled, linked, run, and cross-checked against the
|
||||
source tree by the test suite. A snippet that stops compiling, an excerpt that no longer
|
||||
matches the header it quotes, or a JSON document the loader would reject turns
|
||||
`ctest -R docs_examples` red.
|
||||
|
||||
This exists because the documentation it replaces had drifted badly: the previous FAQ's
|
||||
examples did not compile — two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];`
|
||||
that is not C in any dialect, and, in the first snippet a reader ever saw, the exact
|
||||
`strncpy` call `AGENTS.md` forbids. The prose had drifted with it, and writing these
|
||||
chapters turned up **twenty-seven** header claims that were false against `src/`.
|
||||
|
||||
Where a chapter documents behaviour that is a known defect rather than a design decision,
|
||||
it says so and points at `TODO.md`. See `MAINTENANCE.md` if you are editing an example.
|
||||
|
||||
## Assets
|
||||
|
||||
The tutorial art, tiles and music are CC0, vendored under
|
||||
[`tutorials/assets/`](tutorials/assets) with provenance recorded per file in
|
||||
[`PROVENANCE.md`](tutorials/assets/PROVENANCE.md) and geometry documented in
|
||||
[`README.md`](tutorials/assets/README.md). CC0 specifically, not merely free: a reader who
|
||||
copies a tutorial into their own game inherits no obligation.
|
||||
BIN
docs/images/primitives.png
Normal file
BIN
docs/images/primitives.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
Reference in New Issue
Block a user