Add two tutorial games that build, run in CI, and cannot drift
examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
675
docs/20-tutorial-jrpg.md
Normal file
675
docs/20-tutorial-jrpg.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# 20. Tutorial: a top-down JRPG
|
||||
|
||||
A town, three people standing in it, a party member who follows you around, and
|
||||
a box that tells you what the elder thinks of the road north. About six hundred
|
||||
lines of C, in `examples/jrpg/`, built by `all` and run headless by `ctest`.
|
||||
|
||||
This is the **content-pipeline** tutorial. Its subject is the road from a
|
||||
directory of JSON and PNG to a world with people in it: twenty-four sprites, three
|
||||
characters, one Tiled map that spawns its own actors, four-way per-facing
|
||||
animation, and text on top. [Chapter 19](19-tutorial-sidescroller.md) is the
|
||||
physics one — gravity, a jump, a coin — and the two are deliberately
|
||||
complementary. If you want to know how `ey` accumulates, read that one.
|
||||
|
||||
Everything the chapter shows is quoted out of the program with `excerpt` blocks
|
||||
rather than retyped, so a chapter that no longer matches the game fails the
|
||||
build. The program itself is the specification.
|
||||
|
||||
Six things in it are workarounds for gaps in the library rather than choices,
|
||||
and each one is called out where you would hit it. They are collected at the end
|
||||
under [What this costs](#what-this-costs).
|
||||
|
||||
## Build it and run it
|
||||
|
||||
```sh norun
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build --parallel
|
||||
./build/examples/jrpg/jrpg
|
||||
```
|
||||
|
||||
Arrow keys walk. Space talks to whoever is standing next to you, and dismisses
|
||||
the box again. There is nothing else; it is a tutorial, not a game.
|
||||
|
||||
Two options exist for the sake of the smoke run. `--frames N` stops after N
|
||||
frames, and `--demo` drives the arrow keys from a script and steps the physics
|
||||
clock by a fixed 1/60 s so that a run is deterministic and takes no wall-clock
|
||||
time at all:
|
||||
|
||||
```sh norun
|
||||
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
|
||||
./build/examples/jrpg/jrpg --frames 320 --demo
|
||||
```
|
||||
|
||||
Both blocks are `norun`: the first rebuilds the tree the documentation suite is
|
||||
running inside, and the second is registered as a CTest case already, so running
|
||||
it from here would run it twice.
|
||||
|
||||
## What is in the directory
|
||||
|
||||
| File | What it holds |
|
||||
|---|---|
|
||||
| `jrpg.h` | Every declaration, the compile-time asset paths, and the constants |
|
||||
| `jrpg.c` | `main`, startup order, the frame, teardown, the demo script |
|
||||
| `world.c` | Asset loading, the actors the map spawned, walls, the follower |
|
||||
| `textbox.c` | The dialogue panel |
|
||||
| `CMakeLists.txt` | The target, the baked-in asset paths, and the smoke test |
|
||||
|
||||
The art and the data are in `docs/tutorials/assets/jrpg/`: `tiles.png`,
|
||||
three 32×32 character sheets, `town.tmj`, three character definitions and
|
||||
twenty-four sprite definitions. They are CC0 — `docs/tutorials/assets/PROVENANCE.md`
|
||||
has the row-per-file provenance — so a reader who copies this into their own game
|
||||
inherits no obligation.
|
||||
|
||||
## Sprites, then characters, then the map
|
||||
|
||||
The load order is not a preference. Each step resolves the previous one's output
|
||||
by name through a registry, so getting it wrong fails on every single mapping:
|
||||
|
||||
- `akgl_character_load_json` looks each sprite up in `AKGL_REGISTRY_SPRITE` and
|
||||
refuses one it cannot find. See [Chapter 11](11-characters.md).
|
||||
- `akgl_tilemap_load` resolves each actor object's `character` property through
|
||||
`AKGL_REGISTRY_CHARACTER` while it spawns the actor. See
|
||||
[Chapter 13](13-tilemaps.md).
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
for ( c = 0; c < CAST_COUNT; c++ ) {
|
||||
for ( m = 0; m < MOTION_COUNT; m++ ) {
|
||||
for ( f = 0; f < FACING_COUNT; f++ ) {
|
||||
PASS(errctx, sprite_load(CAST[c], MOTIONS[m], FACINGS[f]));
|
||||
}
|
||||
}
|
||||
}
|
||||
for ( c = 0; c < CAST_COUNT; c++ ) {
|
||||
PASS(errctx, character_load(CAST[c]));
|
||||
}
|
||||
```
|
||||
|
||||
Twenty-four sprites, and not one of them is named in the source. The file names
|
||||
are a product of three tables, because a product of three tables is what the
|
||||
naming convention *is* — one sprite per character, per motion, per facing:
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
static char *CAST[] = {
|
||||
"player", /* jrpg_player jrpg_player_* */
|
||||
"elder", /* jrpg_elder jrpg_elder_* */
|
||||
"shopkeeper" /* jrpg_shopkeeper jrpg_shopkeeper_* */
|
||||
};
|
||||
|
||||
static char *MOTIONS[] = { "idle", "walk" };
|
||||
static char *FACINGS[] = { "up", "down", "left", "right" };
|
||||
```
|
||||
|
||||
A missing combination then arrives as a load failure naming the file it could
|
||||
not open, at startup. Write the twenty-four names out by hand and a missing one
|
||||
arrives as art that never appears, in the middle of a frame, silently — because
|
||||
an actor with no sprite for its state is skipped rather than reported. That is
|
||||
the same trade the whole error protocol is about: fail early and loudly rather
|
||||
than surprisingly.
|
||||
|
||||
### Full four-way idle and walk is eight mappings per character
|
||||
|
||||
The state-to-sprite lookup is an **exact match on the whole state word**, with no
|
||||
subset fallback — `akgl_character_sprite_get` builds a decimal key out of the
|
||||
`int32_t` and asks the property set for it. [Chapter 11](11-characters.md)
|
||||
covers the consequences; the one that binds here is that four facings times
|
||||
{standing, walking} is eight distinct combinations, and every one of them needs
|
||||
its own mapping or the actor vanishes when it reaches that state:
|
||||
|
||||
| State | Value | Sprite |
|
||||
|---|---|---|
|
||||
| `ALIVE|FACE_DOWN` | 17 | `jrpg_player_idle_down` |
|
||||
| `ALIVE|FACE_DOWN|MOVING_DOWN` | 1041 | `jrpg_player_walk_down` |
|
||||
| `ALIVE|FACE_LEFT` | 18 | `jrpg_player_idle_left` |
|
||||
| `ALIVE|FACE_LEFT|MOVING_LEFT` | 146 | `jrpg_player_walk_left` |
|
||||
| `ALIVE|FACE_RIGHT` | 20 | `jrpg_player_idle_right` |
|
||||
| `ALIVE|FACE_RIGHT|MOVING_RIGHT` | 276 | `jrpg_player_walk_right` |
|
||||
| `ALIVE|FACE_UP` | 24 | `jrpg_player_idle_up` |
|
||||
| `ALIVE|FACE_UP|MOVING_UP` | 536 | `jrpg_player_walk_up` |
|
||||
|
||||
There is no diagonal row and there does not need to be one. The default arrow
|
||||
bindings clear *every* facing and movement bit before setting their own
|
||||
(`akgl_actor_cmhf_left_on` and its five siblings all do), so holding two
|
||||
directions moves in whichever was pressed last rather than diagonally. That is
|
||||
documented in [Chapter 15](15-input.md), and it is why an eight-way character
|
||||
sheet would be wasted on the default bindings.
|
||||
|
||||
## The map spawns the actors
|
||||
|
||||
`town.tmj` is 30×20 cells of 16×16 tiles with three layers: `ground`,
|
||||
`decoration`, and an object group holding three objects of type `actor`.
|
||||
|
||||
| Object `name` | `character` (string) | `state` (int) | At |
|
||||
|---|---|---|---|
|
||||
| `player` | `jrpg_player` | 17 | (224, 256) |
|
||||
| `shopkeeper` | `jrpg_shopkeeper` | 17 | (80, 128) |
|
||||
| `elder` | `jrpg_elder` | 17 | (272, 112) |
|
||||
|
||||
Loading the map creates all three, publishes them in `AKGL_REGISTRY_ACTOR` under
|
||||
the object's `name`, binds each to its character, and sets its position,
|
||||
visibility and layer. No code in this game creates them. The format's rules —
|
||||
every object needs a `type` string, `state` is an **int** here and not the
|
||||
string array that character JSON accepts, and the tileset must be **embedded** —
|
||||
are [Chapter 13](13-tilemaps.md)'s subject and are not restated.
|
||||
|
||||
The map also declares `physics.model`, and that is worth a paragraph, because
|
||||
what the library does with it is half of what you would expect:
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
if ( akgl_gamemap->use_own_physics ) {
|
||||
akgl_physics = &akgl_gamemap->physics;
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_tilemap_load` reads the property, builds a whole `akgl_PhysicsBackend` on
|
||||
the map from it, and sets `use_own_physics`. It does not switch to it.
|
||||
`akgl_game_update` simulates through the global `akgl_physics`, whatever that
|
||||
happens to point at, and nothing in the library ever consults `use_own_physics`.
|
||||
Honouring the map is the caller's job, and the two lines above are it.
|
||||
|
||||
### Every actor the map spawned is invisible on frame one
|
||||
|
||||
This is the first workaround, and it is the one most likely to cost you an
|
||||
afternoon, because the symptom is that nothing happens.
|
||||
|
||||
`akgl_actor_initialize` sets `movement_controls_face` to true and installs
|
||||
`akgl_actor_automatic_face` as the `facefunc`. That function clears every facing
|
||||
bit and then sets the one matching a *movement* bit. An NPC has no movement
|
||||
bits. So on the first update its state falls from `ALIVE|FACE_DOWN` (17) to
|
||||
`ALIVE` (16) — a combination no character JSON maps a sprite to — and
|
||||
`akgl_actor_render` skips it rather than reporting anything. The player goes the
|
||||
same way the moment they stop walking.
|
||||
|
||||
`actor.h` says as much in a `@note` on `akgl_actor_automatic_face`, and the
|
||||
implementation carries a `TODO : This doesn't really work properly` above the
|
||||
line that does it. The fix is one field:
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
akgl_heap_actors[i].movement_controls_face = false;
|
||||
}
|
||||
```
|
||||
|
||||
Turning the automatic facing *off* is not a compromise here. The arrow-key
|
||||
handlers already set the facing bit alongside the movement bit on the way down,
|
||||
and `akgl_actor_cmhf_*_off` clears only the movement bit on the way up — so the
|
||||
facing an actor is left with is exactly the one it was walking in. The automatic
|
||||
`facefunc` is for an actor whose facing is not otherwise decided.
|
||||
|
||||
## Walls, because the library has none
|
||||
|
||||
There is no collision in libakgl. Not "a simple one" — none:
|
||||
|
||||
- `akgl_physics_arcade_collide` raises `AKERR_API` with the message
|
||||
`"Not implemented"`.
|
||||
- `akgl_physics_simulate` never calls `collide` at all, so that status is
|
||||
unreachable through the normal path.
|
||||
- `akgl_physics_arcade_move` is `x += vx * dt` on three axes and consults no
|
||||
tilemap, no other actor and no bound of any kind.
|
||||
|
||||
An actor therefore walks through a building and off the edge of the world.
|
||||
[Chapter 14](14-physics.md) lists this among what is not implemented. A game
|
||||
that wants a wall writes one, and the place to write it is the actor's
|
||||
`movementlogicfunc` — the one hook the physics step calls *before* it commits
|
||||
anything.
|
||||
|
||||
The rule this game applies is that the outer ring of the map is solid and every
|
||||
non-empty cell of the `decoration` layer is solid:
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
if ( (tx <= 0) || (ty <= 0) ||
|
||||
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
|
||||
return true;
|
||||
}
|
||||
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
|
||||
```
|
||||
|
||||
`JRPG_LAYER_SOLID` is the number 1, not the string `"decoration"`, and that is
|
||||
the second workaround. **`akgl_TilemapLayer` has no `name` member.** The loader
|
||||
reads a layer's `id`, `type`, `opacity`, `visible` and offset, and drops the
|
||||
name Tiled wrote — the only `"name"` an object layer keeps is the one on each
|
||||
*object*. So a game cannot say "the layer called collision"; the map and the
|
||||
program agree on an index, and if somebody inserts a layer in Tiled the game
|
||||
starts colliding with the scenery. This is not in `TODO.md`.
|
||||
|
||||
Then the hook itself. Two things make the prediction below exact rather than a
|
||||
guess, and both are worth understanding before you copy it:
|
||||
|
||||
- By the time a `movementlogicfunc` runs, `akgl_physics_simulate` has **already**
|
||||
integrated this step's thrust and capped it against the character's speed
|
||||
ellipse. `tx` and `ty` are this step's final thrust.
|
||||
- Velocity is recomputed as `e + t` every step, and this map has zero gravity and
|
||||
zero drag, so `e` stays zero and `v` *is* `t`. A map with gravity would have to
|
||||
fold `ey` into the prediction as well.
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) {
|
||||
obj->tx = 0.0f;
|
||||
obj->ax = 0.0f;
|
||||
}
|
||||
if ( feet_blocked(obj->x, obj->y + (obj->ty * dt)) ) {
|
||||
obj->ty = 0.0f;
|
||||
obj->ay = 0.0f;
|
||||
}
|
||||
```
|
||||
|
||||
One axis at a time, so walking diagonally into a wall slides along it instead of
|
||||
stopping dead. Zeroing thrust rather than moving the actor back is what keeps
|
||||
this inside the library's model: the arcade backend will still do its own
|
||||
`x += vx * dt` a few lines later, and it will add zero.
|
||||
|
||||
`feet_blocked` tests a 20×10 footprint at the bottom of the 32×32 frame rather
|
||||
than the whole frame, because a character stands on their feet and every
|
||||
doorway in the town is one tile wide. Four corners is enough only because the
|
||||
footprint is smaller than a tile in both directions.
|
||||
|
||||
## Freezing the world with `AKGL_ERR_LOGICINTERRUPT`
|
||||
|
||||
`AKGL_ERR_LOGICINTERRUPT` is the one status in libakgl that is a control-flow
|
||||
signal rather than a failure. Raised from a `movementlogicfunc` it means *skip
|
||||
the rest of this tick for this actor*, and `akgl_physics_simulate` swallows it in
|
||||
a `HANDLE` block: gravity, drag, the velocity recompute and the move are all
|
||||
skipped, and the frame carries on. Raised from `gravity` or `move` instead it
|
||||
aborts the whole step for every actor, which is not the same thing at all — see
|
||||
[Chapter 14](14-physics.md).
|
||||
|
||||
A conversation is exactly the case it was made for:
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
if ( jrpg_textbox_showing() ) {
|
||||
obj->tx = 0.0f;
|
||||
obj->ty = 0.0f;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_ALL);
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKGL_ERR_LOGICINTERRUPT,
|
||||
"%s does not move while a conversation is open",
|
||||
(char *)obj->name
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`FAIL_RETURN` and not `FAIL_BREAK`, because this is not inside an `ATTEMPT`
|
||||
block — the `_BREAK` variants belong inside one and the `_RETURN` variants
|
||||
outside, and a `_RETURN` inside an `ATTEMPT` returns straight past `CLEANUP`.
|
||||
[Chapter 4](04-errors.md) has the whole protocol.
|
||||
|
||||
The two lines above the raise are not decoration. Thrust has already been
|
||||
integrated for this step, so leaving it would let it pile up for as long as the
|
||||
box is open and lurch the actor forward on the first step after it closes.
|
||||
Clearing the movement bits stops the walk cycle marching on the spot, at the
|
||||
price of one honest wart: a direction held down across the dismissal has to be
|
||||
pressed again, because nothing will re-set the bit until the next key-down. A
|
||||
game with a real conversation state would push a different control map instead
|
||||
of leaving the walk bindings live.
|
||||
|
||||
## The party member, and a defect in the parent/child mechanism
|
||||
|
||||
`akgl_actor_add_child` attaches one actor to another. A child is not simulated:
|
||||
`akgl_physics_simulate` snaps it to the parent's position plus its own velocity
|
||||
fields, used as a fixed offset, and `continue`s. The parent takes a reference,
|
||||
and releasing the parent releases the child with it. For a carried lantern, a
|
||||
turret on a tank, or a party member walking a step behind you, that is exactly
|
||||
right.
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
PASS(errctx, akgl_heap_next_actor(&follower));
|
||||
PASS(errctx, akgl_actor_initialize(follower, JRPG_FOLLOWER_NAME));
|
||||
PASS(errctx, akgl_actor_set_character(follower, "jrpg_elder"));
|
||||
follower->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN);
|
||||
follower->movement_controls_face = false;
|
||||
follower->visible = true;
|
||||
follower->layer = player->layer;
|
||||
follower->renderfunc = &jrpg_follower_render;
|
||||
PASS(errctx, player->addchild(player, follower));
|
||||
```
|
||||
|
||||
Note what the follower costs: one pool slot, and a `basechar` pointer at a
|
||||
character that is already registered and already dressed in eight sprites. That
|
||||
is the whole point of splitting the instance from the template
|
||||
([Chapter 11](11-characters.md)) — the elder and the companion are two actors
|
||||
sharing one character.
|
||||
|
||||
The offset is set **after** `addchild`, because `addchild` is what makes the
|
||||
velocity fields mean an offset:
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
follower->vx = FOLLOWER_OFFSET_X;
|
||||
follower->vy = FOLLOWER_OFFSET_Y;
|
||||
```
|
||||
|
||||
### The parent's position is counted twice at draw time
|
||||
|
||||
This is a real defect, it is not recorded in `TODO.md`, and it makes the
|
||||
parent/child mechanism unusable as shipped for any parent that is not sitting at
|
||||
the world origin.
|
||||
|
||||
`akgl_physics_simulate` writes a child's position as an **absolute world
|
||||
coordinate**:
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
if ( actor->parent != NULL ) {
|
||||
// Children don't move independently of their parents, they just have an offset
|
||||
actor->x = actor->parent->x + actor->vx;
|
||||
actor->y = actor->parent->y + actor->vy;
|
||||
actor->z = actor->parent->z + actor->vz;
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_actor_render` then reads the same field as an **offset** and adds the
|
||||
parent's position to it again:
|
||||
|
||||
```c excerpt=src/actor.c
|
||||
if ( obj->parent != NULL ) {
|
||||
dest.x = (obj->parent->x + obj->x - akgl_camera->x);
|
||||
dest.y = (obj->parent->y + obj->y - akgl_camera->y);
|
||||
} else {
|
||||
dest.x = (obj->x - akgl_camera->x);
|
||||
dest.y = (obj->y - akgl_camera->y);
|
||||
}
|
||||
```
|
||||
|
||||
`actor.h` documents both readings without noticing that they contradict each
|
||||
other: the comment on `akgl_Actor::x` says *"For a child, an offset from the
|
||||
parent"*, and the one on `akgl_actor_render` says *"A child actor is drawn at
|
||||
its parent's position plus its own, which is what makes an offset mean an
|
||||
offset"* — while the field it is describing has held an absolute position since
|
||||
the physics step ran. `actor_visible`, three lines further up the same function,
|
||||
tests the camera against the raw `obj->x`, treating it as absolute. Two readings,
|
||||
one field, one function.
|
||||
|
||||
Measured rather than reasoned about. At frame 300 of the scripted demo the
|
||||
player is at (280, 146), the follower's own `x` and `y` read (266, 156) — the
|
||||
player's position plus the (−14, +10) offset, so absolute — and the camera is at
|
||||
(136, 42). The guarded draw puts the sprite at (130, 114), on a 320×240 screen.
|
||||
The unguarded one computes `280 + 266 − 136` and `146 + 156 − 42` and puts it at
|
||||
(410, 260), off the screen entirely and off the 480×320 map. Rendering the same
|
||||
frame with and without the guard produces different pixels.
|
||||
|
||||
It is invisible only while the parent sits at the origin, which is where a first
|
||||
test tends to put it.
|
||||
|
||||
**The guard**, until the library picks one of the two readings: give the child a
|
||||
`renderfunc` that detaches the parent for the duration of the draw, so
|
||||
`akgl_actor_render` takes the branch that does not add it.
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
parent = obj->parent;
|
||||
obj->parent = NULL;
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_actor_render(obj));
|
||||
} CLEANUP {
|
||||
obj->parent = parent;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
```
|
||||
|
||||
`CLEANUP` restores it on every path, including the failing one. An actor left
|
||||
holding a `NULL` parent would stop being snapped and start being simulated as a
|
||||
free agent on the very next step, which is a stranger bug than the one being
|
||||
worked around.
|
||||
|
||||
The alternative was to drive the follower by hand — no `addchild`, a position
|
||||
written from the game's own logic every frame — and it is a perfectly reasonable
|
||||
choice. This game does not take it, because the mechanism is worth showing and
|
||||
because a fifteen-line hook is cheaper than reimplementing the parent/child
|
||||
lifecycle. The real fix is one line in either `src/physics.c` or `src/actor.c`
|
||||
and a decision about which reading is canonical.
|
||||
|
||||
## The text box
|
||||
|
||||
Text in libakgl is immediate mode. Every `akgl_text_rendertextat` call
|
||||
rasterizes the string through SDL_ttf, uploads it to a texture, blits it, and
|
||||
destroys the texture and the surface again — there is no glyph atlas and no text
|
||||
object to keep. [Chapter 16](16-text-and-fonts.md) says plainly that this is
|
||||
wrong for a page of static prose redrawn sixty times a second, and it is exactly
|
||||
right for a line of dialogue that is on screen only while somebody is talking.
|
||||
|
||||
```c excerpt=examples/jrpg/textbox.c
|
||||
panel.x = TEXTBOX_MARGIN;
|
||||
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
|
||||
panel.h = TEXTBOX_HEIGHT;
|
||||
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
|
||||
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
|
||||
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
|
||||
```
|
||||
|
||||
The panel is placed off `akgl_camera->w` and `akgl_camera->h` rather than off a
|
||||
second copy of the window size, because `akgl_render_2d_init` copies the
|
||||
`game.screenwidth` and `game.screenheight` properties onto the camera on its way
|
||||
past. Two numbers, one place. Text coordinates are screen coordinates and do not
|
||||
go through the camera at all, so the box stays put while the world scrolls under
|
||||
it.
|
||||
|
||||
Three smaller things this box has to know:
|
||||
|
||||
- **The empty string is refused, not drawn as nothing.** SDL_ttf reports "Text
|
||||
has zero width" and libakgl passes it on as `AKERR_NULLPOINTER`, while
|
||||
`akgl_text_measure` accepts it happily. The two disagree; `TODO.md`, "Known and
|
||||
still open", carries it. Anything that might draw an empty line checks first.
|
||||
- **Fonts are not reference counted.** `akgl_text_unloadfont` invalidates every
|
||||
`TTF_Font *` anybody fetched earlier. Fetching it from `AKGL_REGISTRY_FONT` per
|
||||
frame is the cheap way to stay honest about that.
|
||||
- **The font is `tests/assets/akgl_test_mono.ttf`**, which ships beside its
|
||||
licence file. The two RPG-Maker-named images elsewhere in this tree do not, and
|
||||
that is recorded in `TODO.md` rather than fixed here.
|
||||
|
||||
## Startup, the frame, and teardown
|
||||
|
||||
The startup order is [Chapter 7](07-the-game-and-the-frame.md)'s subject. Three
|
||||
things in it are easy to get wrong, and this block is where two of them land:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
PASS(errctx, akgl_game_init());
|
||||
akgl_game.lowfpsfunc = &lowfps_quiet;
|
||||
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT));
|
||||
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
```
|
||||
|
||||
- **The properties go between `akgl_game_init` and `akgl_render_2d_init`**,
|
||||
because the second one reads them. Set them earlier and the properties registry
|
||||
does not exist yet; set them later and you get a zero-sized window.
|
||||
- **`akgl_game.name`, `.version` and `.uri` have to be filled in before
|
||||
`akgl_game_init`**, which refuses to run without all three. They are written
|
||||
with `aksl_strncpy`, never `strncpy`, because they are fixed-width fields.
|
||||
- **`akgl_game.lowfpsfunc` is worth replacing.** `akgl_game.fps` is a
|
||||
completed-second average, so it reads 0 for the first second of every process
|
||||
— which is under the threshold — and the default hook logs a line on *every*
|
||||
frame until the first second is up. The hook exists to be replaced.
|
||||
|
||||
Then the fourth thing, which is the third workaround and the one that segfaults:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||
```
|
||||
|
||||
`akgl_game_init` points `akgl_physics` at `akgl_default_physics` and stops.
|
||||
That storage is BSS, so all four of its method pointers are `NULL`, and
|
||||
`akgl_game_update` calls `akgl_physics->simulate(...)` without checking it — a
|
||||
`NULL` function pointer, and a `SIGSEGV` on frame one rather than an error
|
||||
context.
|
||||
|
||||
Be precise about what saves this particular program: `town.tmj` declares its own
|
||||
`physics.model`, and the two-line switch above hands `akgl_physics` a backend
|
||||
that `akgl_tilemap_load` *did* initialize. Remove the line above alone and this
|
||||
game still runs. Remove it **and** that switch and frame one is a segmentation
|
||||
fault — measured, by removing both. So the line is not belt and braces for a map
|
||||
that declares no physics, which is most of them, and it is the only thing
|
||||
covering the window between `akgl_game_init` and the map being loaded.
|
||||
|
||||
Nothing in the library calls the factory for you, and
|
||||
`physics.h`'s claim that `akgl_game_init` "passes whatever the `physics.engine`
|
||||
property holds" to the factory is false: no code anywhere reads that property. A top-down game wants the arcade backend with
|
||||
zero gravity, which is what the property defaults already give you.
|
||||
|
||||
### One frame
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
PASS(errctx, akgl_game_update(&opflags));
|
||||
PASS(errctx, jrpg_textbox_draw());
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
```
|
||||
|
||||
`akgl_game_update` is update-every-actor, step the physics, draw the world. It
|
||||
does not clear the target and it does not present, so the frame is bracketed by
|
||||
the backend's own `frame_start` and `frame_end` — and anything drawn between the
|
||||
update and `frame_end` lands on top of the world. That is the entire trick to a
|
||||
HUD.
|
||||
|
||||
A failing frame is treated as terminal, and that is deliberate:
|
||||
**every failure path out of `akgl_game_update` returns with the game-state mutex
|
||||
still held.** SDL's mutexes are recursive and this loop is single-threaded, so
|
||||
the next frame would not deadlock — it would just be running on top of a frame
|
||||
that never finished. Bailing out is the honest response.
|
||||
|
||||
The loop is the last thing in its `ATTEMPT` block, and that is load-bearing:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
while ( running ) {
|
||||
started = SDL_GetTicks();
|
||||
CATCH(errctx, frame(frameno));
|
||||
frameno += 1;
|
||||
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
|
||||
running = false;
|
||||
}
|
||||
```
|
||||
|
||||
`CATCH` reports failure by `break`ing, and a `break` inside a loop binds to the
|
||||
loop rather than to the block. With nothing after the loop, a failing frame
|
||||
leaves it and falls straight into `CLEANUP`, `PROCESS` and `FINISH`, which is
|
||||
what is wanted. Put a statement after the loop and it would run after a failure
|
||||
as well. `akgl_tilemap_load_layer_objects` carries the same note over the same
|
||||
shape; `AGENTS.md` states the rule.
|
||||
|
||||
### Teardown is yours
|
||||
|
||||
There is no `akgl_game_shutdown`. Two of these three lines are load-bearing and
|
||||
the third thing here is a deliberate omission:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
if ( akgl_window != NULL ) {
|
||||
SDL_DestroyWindow(akgl_window);
|
||||
akgl_window = NULL;
|
||||
}
|
||||
SDL_Quit();
|
||||
```
|
||||
|
||||
- **`akgl_text_unloadallfonts` must run before `SDL_Quit`.** Fonts live in an SDL
|
||||
property registry, and `SDL_Quit` destroys the registry — taking the last
|
||||
reference to every font still in it, with no way left to close them.
|
||||
[Chapter 16](16-text-and-fonts.md) covers the ordering.
|
||||
- **`akgl_tilemap_release` is not called**, and that is the fourth workaround.
|
||||
Its layer loop destroys `tilesets[i].texture` rather than `layers[i].texture`,
|
||||
so it double-frees every tileset texture and never frees an image layer's, and
|
||||
it NULLs nothing, so a second call is a use-after-free. `TODO.md`, "Known and
|
||||
still open" item 2, and `tilemap.h` carries the warning too. `SDL_Quit`
|
||||
reclaims the textures correctly; calling the function that is supposed to
|
||||
would be worse than not.
|
||||
- **The pools are static storage.** There is nothing to free and the process is
|
||||
about to exit. Releasing the characters would be actively risky:
|
||||
`akgl_heap_release_character` enumerates the state-sprite map through
|
||||
`akgl_character_state_sprites_iterate`, an SDL callback that returns `void`,
|
||||
ends in `FINISH_NORETURN`, and therefore **exits the process** on any error.
|
||||
[Chapter 5](05-the-heap.md) and [Chapter 4](04-errors.md) both cover it.
|
||||
|
||||
## The headless smoke run
|
||||
|
||||
```c excerpt=examples/jrpg/CMakeLists.txt
|
||||
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
|
||||
set_tests_properties(example_jrpg PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
```
|
||||
|
||||
A smoke test that only proves `main` returns is not worth registering, so this
|
||||
one walks. `--demo` synthesizes real `SDL_Event`s and pushes them through
|
||||
`akgl_controller_handle_event`, so the run goes through the control-map scan and
|
||||
the binding exactly as a keypress does:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
|
||||
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
|
||||
{ 70, SDLK_RIGHT, false },
|
||||
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
|
||||
{ 205, SDLK_UP, false },
|
||||
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
|
||||
{ 216, SDLK_SPACE, false },
|
||||
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
|
||||
{ 245, SDLK_LEFT, false },
|
||||
{ 255, SDLK_SPACE, true }, /* dismiss */
|
||||
{ 256, SDLK_SPACE, false },
|
||||
{ 265, SDLK_DOWN, true }, /* and walk away */
|
||||
{ 285, SDLK_DOWN, false }
|
||||
};
|
||||
```
|
||||
|
||||
320 frames of that would be five and a third seconds of test suite if the loop
|
||||
ran at 60 Hz, and it does not run at 60 Hz — headless with a software renderer it
|
||||
runs as fast as it can, which would make the walk cover about a fifth of the
|
||||
ground. Both problems have the same answer, and it is the one
|
||||
`tests/physics_sim.c` already uses: **drive the clock, do not sleep on it.**
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
|
||||
```
|
||||
|
||||
`akgl_physics_simulate` measures `dt` from `gravity_time`, which is a public
|
||||
field. Setting it one fixed step into the past before each update makes every
|
||||
frame worth exactly 1/60 s of simulated time no matter how fast the loop
|
||||
actually runs. The whole 320-frame run finishes in about a third of a second and
|
||||
lands the player in the same place every time. [Chapter 14](14-physics.md)
|
||||
covers `dt`, `max_timestep` and why the first step used to be however long the
|
||||
level took to load.
|
||||
|
||||
The run prints where the player ended up, so the result can be read rather than
|
||||
merely passed:
|
||||
|
||||
```text
|
||||
jrpg: 320 frames, player at (280, 146)
|
||||
```
|
||||
|
||||
## What this costs
|
||||
|
||||
Six things in this program exist because the library does not do them. None of
|
||||
them is hidden, and none of them is a criticism of a design — they are the
|
||||
current state of a library that is honest about being unfinished.
|
||||
|
||||
| # | Gap | What this game does | Recorded in |
|
||||
|---|---|---|---|
|
||||
| 1 | `movement_controls_face` erases the facing bit of any actor that is not moving, so every map-spawned actor stops being drawn on frame one | Clears the field on every live actor after loading the map | `actor.h` `@note`; a `TODO` in `src/actor.c`. Not in `TODO.md` |
|
||||
| 2 | `akgl_TilemapLayer` has no `name`, so a collision layer cannot be found by name | Hard-codes the layer index in `JRPG_LAYER_SOLID` | Not recorded anywhere |
|
||||
| 3 | `akgl_default_physics` is zeroed BSS and `akgl_game_init` never calls the factory; `akgl_game_update` calls `simulate` through a `NULL` pointer | Calls `akgl_physics_init_arcade` explicitly, and switches to the map's backend when it declares one | The false `physics.engine` claim in `physics.h` is in the plan's out-of-scope list |
|
||||
| 4 | No collision of any kind: `arcade_collide` raises `AKERR_API`, `simulate` never calls it, `arcade_move` clamps nothing | Tile-based blocking in the player's `movementlogicfunc` | `TODO.md`; [Chapter 14](14-physics.md) |
|
||||
| 5 | A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice | A `renderfunc` that detaches the parent for the duration of the draw | **Not in `TODO.md`.** `actor.h` documents both readings |
|
||||
| 6 | `akgl_tilemap_release` double-frees tileset textures and never frees image-layer ones | Does not call it; lets `SDL_Quit` reclaim them | `TODO.md`, "Known and still open" item 2 |
|
||||
|
||||
Two more that are not workarounds but will bite anyone extending this: `akgl_Actor::layer`
|
||||
is a `uint32_t` with no bound, while `akgl_render_2d_draw_world` stops at
|
||||
`AKGL_TILEMAP_MAX_LAYERS` (16) — an actor on layer 20 is simulated and never
|
||||
drawn. And the SDL enumeration callbacks (`akgl_registry_iterate_actor`,
|
||||
`akgl_character_state_sprites_iterate`) return `void` and end in
|
||||
`FINISH_NORETURN`, so an error inside one **exits the process** rather than
|
||||
failing a frame. [Chapter 4](04-errors.md) covers installing your own
|
||||
`akerr_handler_unhandled_error` if that is not what you want.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 19](19-tutorial-sidescroller.md) — the same shape of program with
|
||||
gravity, a jump and platforms, which is where the physics model earns its keep.
|
||||
- [Chapter 13](13-tilemaps.md) — the map format, the three libakgl extensions to
|
||||
Tiled, and the four constraints the loader really enforces.
|
||||
- [Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) — the template
|
||||
and the instance, and why the state word is the whole key.
|
||||
- [Chapter 15](15-input.md) — control maps, the default bindings, and the
|
||||
keystroke ring this game does not use.
|
||||
- [Chapter 21](21-appendix-limits.md) — every compile-time ceiling in one place,
|
||||
including the 64-actor pool this town uses four of.
|
||||
Reference in New Issue
Block a user