cell_solid and feet_blocked go, and so does the prediction in the player's movementlogicfunc. That prediction was only ever exact because the town has zero gravity and zero drag -- v is t, so `x + tx * dt` is where the step lands. A map with gravity would have had to fold ey in as well, which is the game re-implementing the integrator. Resolution runs after the move now, so there is nothing to predict. The map's decoration layer carries `collidable`, which retires JRPG_LAYER_SOLID: akgl_TilemapLayer has no name member, so the game and the map had to agree on an index out of band and inserting a layer in Tiled broke it. The edge of the world is not on any layer, so it is four static proxies covering the outer ring plus a tile of overhang -- one pool slot per side instead of a hundred solid tiles, and the first use of the static-proxy path in either example. They carry LAYER_STATIC explicitly: shape_box defaults a shape to LAYER_ACTOR, and a wall left on that layer is a wall everything walks through. Only the player gets a shape. NPCs stand still and are spoken to; the follower is a child snapped to its parent every step. Verified against the old implementation with the demo script rewritten to hold one direction: holding left into a building stops the player at x=122 with both, and holding up into the map's edge stops them at y=-4 with both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
709 lines
33 KiB
Markdown
709 lines
33 KiB
Markdown
# 21. Tutorial: a top-down JRPG
|
||
|
||
A town, three people standing in it, a party member who follows you around, and
|
||
a box that tells you what the elder thinks of the road north. About six hundred
|
||
lines of C, in `examples/jrpg/`, built by `all` and run headless by `ctest`.
|
||
|
||
This is the **content-pipeline** tutorial. Its subject is the road from a
|
||
directory of JSON and PNG to a world with people in it: twenty-four sprites, three
|
||
characters, one Tiled map that spawns its own actors, four-way per-facing
|
||
animation, and text on top. [Chapter 20](20-tutorial-sidescroller.md) is the
|
||
physics one — gravity, a jump, a coin — and the two are deliberately
|
||
complementary. If you want to know how `ey` accumulates, read that one.
|
||
|
||
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 16](16-input.md), and it is why an eight-way character
|
||
sheet would be wasted on the default bindings.
|
||
|
||
## The map spawns the actors
|
||
|
||
`town.tmj` is 30×20 cells of 16×16 tiles with three layers: `ground`,
|
||
`decoration`, and an object group holding three objects of type `actor`.
|
||
|
||
| Object `name` | `character` (string) | `state` (int) | At |
|
||
|---|---|---|---|
|
||
| `player` | `jrpg_player` | 17 | (224, 256) |
|
||
| `shopkeeper` | `jrpg_shopkeeper` | 17 | (80, 128) |
|
||
| `elder` | `jrpg_elder` | 17 | (272, 112) |
|
||
|
||
Loading the map creates all three, publishes them in `AKGL_REGISTRY_ACTOR` under
|
||
the object's `name`, binds each to its character, and sets its position,
|
||
visibility and layer. No code in this game creates them. The format's rules —
|
||
every object needs a `type` string, `state` is an **int** here and not the
|
||
string array that character JSON accepts, and the tileset must be **embedded** —
|
||
are [Chapter 13](13-tilemaps.md)'s subject and are not restated.
|
||
|
||
The map also declares `physics.model`, and that is worth a paragraph, because
|
||
what the library does with it is half of what you would expect:
|
||
|
||
```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
|
||
|
||
The library resolves them ([Chapter 15](15-collision.md)); this game says which
|
||
things are solid and gets out of the way.
|
||
|
||
Two rules, and they arrive by two different mechanisms because they are two
|
||
different kinds of geometry. Every non-empty cell of the `decoration` layer — a
|
||
building, a tree, a lamp post — is solid, and the layer says so itself:
|
||
|
||
```json excerpt=docs/tutorials/assets/jrpg/town.tmj
|
||
"properties": [
|
||
{
|
||
"name": "collidable",
|
||
"type": "bool",
|
||
"value": true
|
||
}
|
||
]
|
||
```
|
||
|
||
That replaces a `#define JRPG_LAYER_SOLID 1`, and the replacement matters more
|
||
than it looks. **`akgl_TilemapLayer` has no `name` member** — the loader reads a
|
||
layer's `id`, `type`, `opacity`, `visible` and offset and drops the name Tiled
|
||
wrote, so a game could not say "the layer called decoration" and had to agree
|
||
with the map on an *index*. Insert a layer in Tiled and the game starts colliding
|
||
with the scenery. Now the map carries the answer and the game names no layer at
|
||
all.
|
||
|
||
The second rule is the edge of the world, which is not on any layer. That is four
|
||
static proxies:
|
||
|
||
```c excerpt=examples/jrpg/world.c
|
||
PASS(errctx, akgl_heap_next_collision_proxy(&jrpg_edges[i]));
|
||
PASS(errctx, akgl_collision_shape_box(&jrpg_edge_shapes[i], &sides[i], 0.0f));
|
||
jrpg_edge_shapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
|
||
jrpg_edge_shapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
|
||
jrpg_edge_shapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;
|
||
```
|
||
|
||
Four proxies rather than a hundred solid tiles, because a long thin box costs one
|
||
pool slot whatever its length — this is what proxies are for, and tiles are the
|
||
other mechanism precisely because there are a hundred thousand of them.
|
||
|
||
Three details in five lines:
|
||
|
||
- **`layermask` is set by hand.** `akgl_collision_shape_box` defaults a shape to
|
||
`AKGL_COLLISION_LAYER_ACTOR`, which is right for an actor and wrong for
|
||
scenery. An actor responds to `AKGL_COLLISION_LAYER_STATIC` and nothing else,
|
||
so a wall left on the default layer is a wall everything walks through.
|
||
- **`collidemask` is cleared.** The wall is not asking to be pushed out of
|
||
anything.
|
||
- **The acquire and the initialize are adjacent, with nothing between them.**
|
||
`akgl_heap_next_collision_proxy` finds a free slot and does *not* claim it; the
|
||
initialize takes the reference. Between the two lines a second acquire returns
|
||
the same pointer. [Chapter 5](05-the-heap.md) covers why the pools are built
|
||
that way.
|
||
|
||
The walls cover the map's outermost ring of cells *and* extend a tile beyond it.
|
||
The ring is what the art expects; the overhang is what stops an actor arriving
|
||
fast enough from being resolved out the far side of a wall exactly one tile
|
||
thick.
|
||
|
||
Then the player gets a footprint:
|
||
|
||
```c excerpt=examples/jrpg/world.c
|
||
body = (SDL_FRect){ FEET_X, FEET_TOP, FEET_W, FEET_H };
|
||
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
|
||
player->shape_override = true;
|
||
```
|
||
|
||
A 20×10 box at the bottom of the 32×32 frame rather than the whole frame, because
|
||
a character stands on their feet and every doorway in the town is one tile wide.
|
||
Testing the whole frame would make every corridor two tiles wide.
|
||
|
||
**Only the player gets one.** The NPCs stand still and are spoken to, not walked
|
||
into; shapes on them would make the town a pinball table. The follower is a
|
||
child, snapped to the player's position every step, so a shape on it would fight
|
||
the snap rather than stop anything. That is what the default masks are for — an
|
||
actor with a shape collides with map geometry and with no other actor until you
|
||
add a bit.
|
||
|
||
### What went away
|
||
|
||
This chapter used to carry a `movementlogicfunc` that *predicted* the step:
|
||
|
||
```text
|
||
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) { obj->tx = 0.0f; ... }
|
||
```
|
||
|
||
It worked, and it came with a qualifier: velocity is recomputed as `e + t` every
|
||
step, and it was only exact because this map has zero gravity and zero drag, so
|
||
`e` stays zero and `v` *is* `t`. A map with gravity would have had to fold `ey`
|
||
into the prediction as well — which is the game re-implementing the library's
|
||
integrator, in a file that would not be recompiled when the integrator changed.
|
||
|
||
Resolution runs after the move now, on a position that already happened, so
|
||
there is nothing to predict and the qualifier goes away with it. Both this game
|
||
and the sidescroller land in exactly the same pixel they did before: holding left
|
||
into a building stops the player at x=122, and holding up into the map's edge
|
||
stops them at y=-4, with either implementation.
|
||
|
||
## 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 17](17-text-and-fonts.md) says plainly that this is
|
||
wrong for a page of static prose redrawn sixty times a second, and it is exactly
|
||
right for a line of dialogue that is on screen only while somebody is talking.
|
||
|
||
```c excerpt=examples/jrpg/textbox.c
|
||
panel.x = TEXTBOX_MARGIN;
|
||
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
|
||
panel.h = TEXTBOX_HEIGHT;
|
||
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
|
||
|
||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
|
||
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
|
||
```
|
||
|
||
The panel is placed off `akgl_camera->w` and `akgl_camera->h` rather than off a
|
||
second copy of the window size, because `akgl_render_2d_init` copies the
|
||
`game.screenwidth` and `game.screenheight` properties onto the camera on its way
|
||
past. Two numbers, one place. Text coordinates are screen coordinates and do not
|
||
go through the camera at all, so the box stays put while the world scrolls under
|
||
it.
|
||
|
||
Three smaller things this box has to know:
|
||
|
||
- **The empty string is refused, not drawn as nothing.** SDL_ttf reports "Text
|
||
has zero width" and libakgl passes it on as `AKERR_NULLPOINTER`, while
|
||
`akgl_text_measure` accepts it happily. The two disagree; `TODO.md`, "Known and
|
||
still open", carries it. Anything that might draw an empty line checks first.
|
||
- **Fonts are not reference counted.** `akgl_text_unloadfont` invalidates every
|
||
`TTF_Font *` anybody fetched earlier. Fetching it from `AKGL_REGISTRY_FONT` per
|
||
frame is the cheap way to stay honest about that.
|
||
- **The font is `tests/assets/akgl_test_mono.ttf`**, which ships beside its
|
||
licence file. The two RPG-Maker-named images elsewhere in this tree do not, and
|
||
that is recorded in `TODO.md` rather than fixed here.
|
||
|
||
## Startup, the frame, and teardown
|
||
|
||
The startup order is [Chapter 7](07-the-game-and-the-frame.md)'s subject. Three
|
||
things in it are easy to get wrong, and this block is where two of them land:
|
||
|
||
```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 17](17-text-and-fonts.md) covers the ordering.
|
||
- **`akgl_tilemap_release` is not called**, and that is the fourth workaround.
|
||
Its layer loop destroys `tilesets[i].texture` rather than `layers[i].texture`,
|
||
so it double-frees every tileset texture and never frees an image layer's, and
|
||
it NULLs nothing, so a second call is a use-after-free. `TODO.md`, "Known and
|
||
still open" item 2, and `tilemap.h` carries the warning too. `SDL_Quit`
|
||
reclaims the textures correctly; calling the function that is supposed to
|
||
would be worse than not.
|
||
- **The pools are static storage.** There is nothing to free and the process is
|
||
about to exit. Releasing the characters would be actively risky:
|
||
`akgl_heap_release_character` enumerates the state-sprite map through
|
||
`akgl_character_state_sprites_iterate`, an SDL callback that returns `void`,
|
||
ends in `FINISH_NORETURN`, and therefore **exits the process** on any error.
|
||
[Chapter 5](05-the-heap.md) and [Chapter 4](04-errors.md) both cover it.
|
||
|
||
## 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
|
||
|
||
Four things in this program exist because the library does not do them. None of
|
||
them is hidden, and none of them is a criticism of a design — they are the
|
||
current state of a library that is honest about being unfinished. Two more were
|
||
here in 0.7.0 and are gone: the hard-coded collision layer index, and the
|
||
tile-based blocking in the player's `movementlogicfunc`. Collision closed both.
|
||
|
||
| # | Gap | What this game does | Recorded in |
|
||
|---|---|---|---|
|
||
| 1 | `movement_controls_face` erases the facing bit of any actor that is not moving, so every map-spawned actor stops being drawn on frame one | Clears the field on every live actor after loading the map | `actor.h` `@note`; a `TODO` in `src/actor.c`. Not in `TODO.md` |
|
||
| 2 | `akgl_default_physics` is zeroed BSS and `akgl_game_init` never calls the factory; `akgl_game_update` calls `simulate` through a `NULL` pointer | Calls `akgl_physics_init_arcade` explicitly, and switches to the map's backend when it declares one | The false `physics.engine` claim in `physics.h` is in the plan's out-of-scope list |
|
||
| 3 | A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice | A `renderfunc` that detaches the parent for the duration of the draw | **Not in `TODO.md`.** `actor.h` documents both readings |
|
||
| 4 | `akgl_tilemap_release` double-frees tileset textures and never frees image-layer ones | Does not call it; lets `SDL_Quit` reclaim them | `TODO.md`, "Known and still open" item 2 |
|
||
|
||
Two more that are not workarounds but will bite anyone extending this: `akgl_Actor::layer`
|
||
is a `uint32_t` with no bound, while `akgl_render_2d_draw_world` stops at
|
||
`AKGL_TILEMAP_MAX_LAYERS` (16) — an actor on layer 20 is simulated and never
|
||
drawn. And the SDL enumeration callbacks (`akgl_registry_iterate_actor`,
|
||
`akgl_character_state_sprites_iterate`) return `void` and end in
|
||
`FINISH_NORETURN`, so an error inside one **exits the process** rather than
|
||
failing a frame. [Chapter 4](04-errors.md) covers installing your own
|
||
`akerr_handler_unhandled_error` if that is not what you want.
|
||
|
||
## Where to look next
|
||
|
||
- [Chapter 20](20-tutorial-sidescroller.md) — the same shape of program with
|
||
gravity, a jump and platforms, which is where the physics model earns its keep.
|
||
- [Chapter 13](13-tilemaps.md) — the map format, the three libakgl extensions to
|
||
Tiled, and the four constraints the loader really enforces.
|
||
- [Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) — the template
|
||
and the instance, and why the state word is the whole key.
|
||
- [Chapter 16](16-input.md) — control maps, the default bindings, and the
|
||
keystroke ring this game does not use.
|
||
- [Chapter 22](22-appendix-limits.md) — every compile-time ceiling in one place,
|
||
including the 64-actor pool this town uses four of.
|