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:
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.
|
||||
Reference in New Issue
Block a user