TODO.md carried two records in one file: what had been done, with the measurements behind it, and what was left. The second half is what a tracker is for, and keeping it here has already cost something -- AGENTS.md records a round where eleven entries described code that had already changed, and this file admitted to three more. Every open item is now an issue on source.starfort.tech/andrew/libakgl, labelled by kind and blast radius and milestoned by what it can land in: 0.9.x for anything that breaks no ABI, 0.10.0 for new or changed public symbols, 1.0.0 for the design work. Four are epics: the performance plan (#60), coverage (#61), actor rotation (#62), and the false header comments (#63). Verified against the tree before filing rather than transcribed. Three entries were already fixed and were not filed: the akgl_path_relative context leak, the akgl_draw_background test extension, and the SDL enumeration audit -- keyboards, gamepads and mappings are all freed in CLEANUP today. Two were reworded because the code had moved: the fonts item is a missing teardown entry point rather than a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap call is already bounded by numlayers, so only the per-layer actor rescan remains. TODO.md keeps the part a tracker has no place for: why a decision went the way it did, what the measurement was, and which arguments turned out to be wrong. TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor collision, actor-to-world collision, automatic facing, image layers -- and the four that had not are #74 through #77, with the GPU renderer's research links kept because that is the part that took the time. Every reference that named an item number or a moved section is repointed, in the manual, the headers, the tests and the examples. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
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.
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.
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.
#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 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
#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 — 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
{
"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:
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.jsonis invalid against the current loaderThe 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:
- The state names are unprefixed —
"ACTOR_STATE_ALIVE"rather than"AKGL_ACTOR_STATE_ALIVE". Every one fails withAKERR_KEY, "Unknown actor state".- It has
velocity_xandvelocity_y, which nothing reads, and lacksspeed_xandspeed_y, which are required.- It has no
speedtime,acceleration_xoracceleration_yeither — all three required, all threeAKERR_KEY.
tests/assets/testcharacter.jsonis 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 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_initializewrites the registry entry under the caller'snamepointer, whileakgl_heap_release_characterclears 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 issue #54, which covers the collision half. speedtimeis written through anint *cast of auint64_tfield.src/character.cpasses(int *)&obj->speedtimetoakgl_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 thatAGENTS.mdargues against under "Never silence a warning with a cast".akgl_character_initializesilently replaces a same-named character, and the one it displaced becomes unreachable rather than being released.
Where to look next
- Chapter 10 — the sprites these map to.
- Chapter 12 — the state bitmask itself, and what sets it.
- Chapter 14 — what
sx/syandax/aymean once an actor is simulated.