The sidescroller's controls did nothing. `akgl_controller_handle_event` matched `event->key.which == curmap->kbid` exactly, with no way to say "whatever keyboard the player is typing on", so the game did the obvious thing and bound `SDL_GetKeyboards()[0]`. That cannot work. The id a key event *carries* is chosen by the video backend and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while `SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is 1; with XInput2 the events carry the physical slave device's `sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map matched nothing and every key press was dropped. A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend: SDL documents `which` as 0 when the source is unknown or virtual, and joystick ids start at 1, so no real device is 0. A non-zero id still matches only that device, which is what keeps two local players on two keyboards apart, and there is a test for that half too. `util/charviewer.c` already passed 0 and depended on the old behaviour by accident; it works under XInput2 now as well. Two reasons this shipped, both closed: - Every test in tests/controller.c dispatched an event whose id equalled the id the map was bound with, so none of them could see it. - The sidescroller's smoke test called the control handlers directly instead of dispatching events, so it passed against a control map that matched nothing. It now pushes synthetic events through akgl_controller_handle_event from a deliberately non-zero device id and fails if the press does not arrive. Verified by breaking the binding and watching the run exit non-zero. `test_controller_wildcard_device_ids` was written first and failed against the unfixed library with "a map bound to keyboard 0 ignored a key press from device 11", which is the whole reason to trust it now that it passes. The controls were documented, in one sentence. Chapter 19 has a proper table of them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The header said the match was exact and now says what it does. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
813 lines
36 KiB
Markdown
813 lines
36 KiB
Markdown
# 19. Tutorial: a 2D sidescroller
|
|
|
|
A complete game: a Tiled level, a player who runs and jumps, platforms that hold him up,
|
|
four coins to collect, a blob that patrols and a moth that flies. It is about nine hundred
|
|
lines of C — a header and four translation units, and rather more comment than that — and it
|
|
builds and runs as part of this repository's ordinary `ctest` run.
|
|
|
|
It is here for one reason above the others. **libakgl has no collision detection at all**,
|
|
and a sidescroller is the shortest path to finding that out. So this chapter is mostly about
|
|
what you write when the engine stops, and where exactly that code has to live for the frame
|
|
to come out right.
|
|
|
|
The program is `examples/sidescroller/`; the art, the map and the JSON are in
|
|
`docs/tutorials/assets/sidescroller/`. Every listing below is quoted straight out of those
|
|
files by the `docs_examples` test, so this chapter cannot describe a program that no longer
|
|
exists.
|
|
|
|
## Building it and running it
|
|
|
|
```sh norun
|
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
|
cmake --build build --parallel --target sidescroller
|
|
./build/examples/sidescroller/sidescroller
|
|
```
|
|
|
|
### Controls
|
|
|
|
| Key | Gamepad | Does |
|
|
|---|---|---|
|
|
| Left arrow | D-pad left | Run left |
|
|
| Right arrow | D-pad right | Run right |
|
|
| Space | South button (A) | Jump — hold it longer to jump higher |
|
|
|
|
Close the window to quit; there is no key bound to it.
|
|
|
|
Three flags exist for the smoke test and are useful by hand too: `--assets DIR` points
|
|
somewhere other than the compiled-in asset directory, `--frames N` (or
|
|
`AKGL_SIDESCROLLER_FRAMES`) exits after that many frames, and `--autoplay` drives the
|
|
player from a script instead of the keyboard.
|
|
|
|
## The level
|
|
|
|
`level1.tmj` is 40x15 tiles of 16 pixels — a world 640x240 pixels — with three layers:
|
|
a `background` tile layer, a `terrain` tile layer, and an `actors` object group. The camera
|
|
is a 480x240 window onto it that scrolls sideways.
|
|
|
|
```text
|
|
0 1 2 3
|
|
0123456789012345678901234567890123456789
|
|
0 ........................................
|
|
1 ........................................
|
|
2 ........................................
|
|
3 ........................................
|
|
4 ........................................
|
|
5 ........................................
|
|
6 ............................##..........
|
|
7 ..............##........................
|
|
8 ......................##................
|
|
9 ........##..............................
|
|
10 ........................................
|
|
11 ...#......#...............#......#......
|
|
12 ##################...###################
|
|
13 ##################...###################
|
|
14 ##################...###################
|
|
|
|
the terrain layer, every row of it. `#` is any non-zero tile; the three
|
|
empty columns at 18-20 are the pit, the two-tile runs on rows 6 to 9 are
|
|
the platforms, and the four single tiles on row 11 are steps.
|
|
```
|
|
|
|
The object group places seven actors: `player`, `coin1` through `coin4`, `blob1` and
|
|
`moth1`. Each is a Tiled object of type `actor` with a `character` string property and a
|
|
`state` **integer** property — the two custom properties
|
|
[Chapter 13](13-tilemaps.md) documents, and `state` is a number here even though character
|
|
JSON accepts the symbolic form. `20` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT`,
|
|
from the bit table in [Chapter 12](12-actors.md).
|
|
|
|
Nothing in the program creates an actor. **Loading the map does**, which is why the load
|
|
order below is what it is.
|
|
|
|
## Starting up
|
|
|
|
libakgl has exactly one startup order that works and it is written down at the top of
|
|
`include/akgl/game.h`. Three of its steps are the ones a first program gets wrong.
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
PASS(errctx, aksl_strncpy(
|
|
(char *)&akgl_game.name,
|
|
sizeof(akgl_game.name),
|
|
"libakgl sidescroller tutorial",
|
|
sizeof(akgl_game.name) - 1));
|
|
```
|
|
|
|
`akgl_game.name`, `.version` and `.uri` are required and have no defaults —
|
|
`akgl_game_init` refuses to run without all three, because the window title, SDL's
|
|
application metadata and the savegame compatibility check are built from them. `aksl_strncpy`
|
|
rather than `strncpy`, per [Chapter 18](18-utilities.md) and `AGENTS.md`: these are
|
|
fixed-width fields that end up as registry keys.
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
|
|
PASS(errctx, akgl_set_property("game.screenheight", "480"));
|
|
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
|
```
|
|
|
|
**Properties before the renderer.** `akgl_render_2d_init` reads both dimensions out of
|
|
`AKGL_REGISTRY_PROPERTIES` and an unset one defaults to the string `"0"`, which asks SDL for
|
|
a zero-sized window rather than reporting anything. See [Chapter 6](06-the-registry.md).
|
|
|
|
The window is 960x480 and the view is 480x240, because libakgl draws in map pixels and has
|
|
no scale factor of its own — the only scaling it applies is the tilemap's perspective band,
|
|
and this map has none. A 16-pixel tile would otherwise be 16 screen pixels. SDL's logical
|
|
presentation supplies the missing factor:
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
FAIL_ZERO_RETURN(
|
|
errctx,
|
|
SDL_SetRenderLogicalPresentation(
|
|
akgl_renderer->sdl_renderer,
|
|
SS_VIEW_WIDTH,
|
|
SS_VIEW_HEIGHT,
|
|
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
|
AKGL_ERR_SDL,
|
|
"%s",
|
|
SDL_GetError()
|
|
);
|
|
```
|
|
|
|
That is an SDL call, not a libakgl one; it is documented in the
|
|
[SDL3 render API](https://wiki.libsdl.org/SDL3/SDL_SetRenderLogicalPresentation). The camera
|
|
then has to be told the same thing, because `akgl_render_2d_init` sized it from the window.
|
|
|
|
### `akgl_game_init` does not choose a physics backend
|
|
|
|
This is the one that produces a crash rather than a message, so it gets its own heading.
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
|
```
|
|
|
|
`akgl_game_init` points `akgl_physics` at `akgl_default_physics`, which is **zeroed storage
|
|
whose four method pointers are all `NULL`**, and never initializes it. There is no
|
|
`physics.engine` property either, whatever the `@file` block in `include/akgl/physics.h`
|
|
says — [Chapter 14](14-physics.md) has the details and the correction. Skip this call and
|
|
the first `akgl_game_update` calls through a `NULL` `simulate`.
|
|
|
|
### The low-FPS hook fires every frame for the first second
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
akgl_game.lowfpsfunc = &ss_lowfps;
|
|
```
|
|
|
|
`akgl_game.fps` is a completed-second average, so it reads 0 for the whole first second of
|
|
the process — which is under the threshold, so the default `akgl_game_lowfps` logs a line on
|
|
every frame until the second is up. The hook exists to be replaced; a real game sheds work
|
|
there. This one replaces it with an empty function so the log is readable.
|
|
|
|
## Loading in dependency order
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
|
|
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
|
|
PASS(errctx, akgl_sprite_load_json((char *)&path));
|
|
}
|
|
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
|
|
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
|
|
PASS(errctx, akgl_character_load_json((char *)&path));
|
|
}
|
|
```
|
|
|
|
Sprites, then characters, then the map. That is not a preference:
|
|
|
|
- a character's JSON names its sprites by **registry name**, and
|
|
`akgl_character_load_json` looks each one up as it reads the mapping, so a character
|
|
loaded first fails on the first sprite it cannot find ([Chapter 11](11-characters.md));
|
|
- an `actor` object names its character the same way, and the map loader creates the actor
|
|
and binds the character during the load ([Chapter 13](13-tilemaps.md)).
|
|
|
|
Nine sprite files and four character files, in a table rather than thirteen calls:
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
static char *ss_sprite_files[] = {
|
|
"sprite_ss_player_idle_left.json",
|
|
"sprite_ss_player_idle_right.json",
|
|
"sprite_ss_player_run_left.json",
|
|
"sprite_ss_player_run_right.json",
|
|
"sprite_ss_player_jump_left.json",
|
|
"sprite_ss_player_jump_right.json",
|
|
"sprite_ss_coin.json",
|
|
"sprite_ss_hazard_blob.json",
|
|
"sprite_ss_hazard_moth.json",
|
|
NULL
|
|
};
|
|
```
|
|
|
|
The map goes into `akgl_gamemap`, which already points at `akgl_default_gamemap`:
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
|
|
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
|
|
```
|
|
|
|
That is 25 MiB of static storage in the library, and using it rather than declaring one is
|
|
not a micro-optimisation: `sizeof(akgl_Tilemap)` is three times a default 8 MiB thread
|
|
stack, so a local one is a segfault before the loader writes a byte. [Chapter 13](13-tilemaps.md)
|
|
has the size breakdown.
|
|
|
|
## The map brings its own physics
|
|
|
|
`level1.tmj` carries `physics.model`, `physics.gravity.y` and `physics.drag.y` as map-level
|
|
custom properties, so the loader built a backend for it and set `use_own_physics`. **Nothing
|
|
in the library acts on that flag** — `akgl_game_update` steps the global `akgl_physics` and
|
|
never looks at the map's — so honouring it is one line the game writes:
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
if ( akgl_gamemap->use_own_physics == true ) {
|
|
akgl_physics = &akgl_gamemap->physics;
|
|
```
|
|
|
|
The numbers are gravity 900 px/s² and drag 1.5. The drag is doing a job the engine has no
|
|
other word for. **There is no terminal velocity in libakgl**: `akgl_physics_arcade_gravity`
|
|
adds `gravity_y * dt` to the actor's `ey` every step and nothing bounds it — the simulation
|
|
in `tests/physics_sim.c` reaches 560 px/s in 0.7 s and keeps going. Drag is a first-order
|
|
decay applied to the same term, so `ey` converges on `gravity_y / drag_y` instead of
|
|
diverging: 900 / 1.5 = **600 px/s, and that is this level's terminal velocity**. It is the
|
|
only brake there is; `TODO.md`, "Arcade physics feel", records the gap.
|
|
|
|
Keep `drag * max_timestep` below 1. `ex -= ex * drag_x * dt` only decays while
|
|
`drag * dt < 1`; past that it overshoots zero and past 2 it diverges, and nothing rejects
|
|
it. With `physics.max_timestep` at its default 0.05 s that needs a drag above 20.
|
|
|
|
One more line before the loop starts:
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
akgl_physics->gravity_time = SDL_GetTicksNS();
|
|
```
|
|
|
|
`dt` is measured from `gravity_time`, not passed in, and everything above — nine sprite
|
|
files, four characters, a map and a tileset image — happened between the backend being
|
|
created and the first step. The `max_timestep` bound would have caught it, at the cost of
|
|
one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md).
|
|
|
|
## The collision libakgl does not have
|
|
|
|
Here is the whole problem, stated as three facts about `src/physics.c`:
|
|
|
|
- **`akgl_physics_arcade_collide` raises `AKERR_API`** with the message "Not implemented".
|
|
- **`akgl_physics_simulate` never calls `collide` at all** — not for the arcade backend, not
|
|
for the null one. The vtable slot exists and the simulation does not use it.
|
|
- **`akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.** It does
|
|
not clamp to the map, consult the tilemap, or test anything.
|
|
|
|
So an actor walks through a wall and off the edge of the world, and none of that announces
|
|
itself at compile time. [Chapter 14](14-physics.md) says so plainly and this game is what
|
|
the consequence looks like.
|
|
|
|
The only hook the physics step calls is the actor's own `movementlogicfunc`, so that is
|
|
where the collision goes. And that placement is awkward in exactly one way, which shapes
|
|
everything in `collision.c`:
|
|
|
|
```text
|
|
akgl_physics_simulate, per actor:
|
|
|
|
tx += ax * dt <- thrust, from the previous step's ax
|
|
cap (tx,ty,tz) to the speed ellipse
|
|
movementlogicfunc(actor, dt) <- YOU ARE HERE
|
|
gravity(self, actor, dt) ey += gravity_y * dt
|
|
ex -= ex * drag_x * dt (and y, z)
|
|
vx = ex + tx (and y, z)
|
|
move(self, actor, dt) x += vx * dt
|
|
```
|
|
|
|
**Your hook runs before the step it has to resolve.** It cannot look at where the actor
|
|
ended up, because the actor has not moved yet. So the game predicts the step instead:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
ex = obj->ex;
|
|
ey = obj->ey;
|
|
if ( akgl_physics->gravity_x != 0 ) {
|
|
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
|
}
|
|
if ( akgl_physics->gravity_y != 0 ) {
|
|
ey += (float32_t)akgl_physics->gravity_y * dt;
|
|
}
|
|
if ( akgl_physics->drag_x != 0 ) {
|
|
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
|
|
}
|
|
if ( akgl_physics->drag_y != 0 ) {
|
|
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
|
|
}
|
|
*dx = (ex + obj->tx) * dt;
|
|
*dy = (ey + obj->ty) * dt;
|
|
```
|
|
|
|
That is `akgl_physics_simulate`'s own arithmetic in its own order, `!= 0` guards included —
|
|
dropping them would let a zero-gravity axis pick up drag, which the library does not do. Get
|
|
it wrong and the actor is resolved against a step it never takes.
|
|
|
|
Be clear about what this is: a copy of somebody else's implementation, in a file that will
|
|
not be recompiled when that implementation changes. It is a liability, and it is not a
|
|
design — it is what the missing `collide` call costs. When `akgl_physics_simulate` grows a
|
|
collision hook, this function is the first thing to delete.
|
|
|
|
### Solid is a layer, not a flag
|
|
|
|
There is no per-tile "solid" property. The level says what is solid by which layer a tile is
|
|
drawn on:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
|
```
|
|
|
|
Finding that layer takes a small piece of knowledge that is easy to lose an afternoon to:
|
|
**`akgl_TilemapLayer` does not record the layer's name.** `akgl_tilemap_load_layers` reads
|
|
`id`, `opacity`, `visible`, `x`, `y` and `type` and nothing else, so there is no way to ask
|
|
for "the layer called terrain". The game matches on Tiled's numeric layer id instead:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
|
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
|
ss_terrain = &map->layers[i];
|
|
}
|
|
```
|
|
|
|
Off the left or right edge of the map counts as solid, so the level has walls at its ends.
|
|
Off the top or the bottom does not: the sky is open, and falling into the pit is the whole
|
|
point of the pit.
|
|
|
|
### Sweeping, not testing
|
|
|
|
A fall at 600 px/s with the step bounded to 0.05 s covers 30 pixels, which is nearly two
|
|
tiles. A single test at the destination walks straight through a floor. So the motion is
|
|
walked in sub-steps of at most half a tile, each axis separately — testing the axes
|
|
separately is what lets an actor slide along a wall instead of sticking to it — and a
|
|
blocked axis is snapped to the boundary it was about to cross rather than simply not moved,
|
|
so an actor lands flush at whatever speed it arrives:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
if ( stepy != 0.0f ) {
|
|
trial = *box;
|
|
trial.y += stepy;
|
|
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
|
if ( solid == true ) {
|
|
if ( stepy > 0.0f ) {
|
|
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
|
|
} else {
|
|
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
|
}
|
|
stepy = 0.0f;
|
|
dest->blocked_y = true;
|
|
} else {
|
|
box->y = trial.y;
|
|
}
|
|
}
|
|
```
|
|
|
|
Only a blocked axis is written back to the actor. The free axis is left for
|
|
`akgl_physics_arcade_move` to advance by exactly the amount that was predicted — writing it
|
|
here as well would move the actor twice.
|
|
|
|
### The quarter of a pixel that breaks everything
|
|
|
|
This one cost an afternoon and is the most useful thing in the chapter.
|
|
|
|
The obvious thing to do on a blocked axis is to zero the environmental term. It is wrong,
|
|
and the reason is the ordering again: the step is about to add `gravity_y * dt` back, and
|
|
`move` commits `gravity_y * dt²` of fall. At 900 px/s² and 60 Hz that is a quarter of a
|
|
pixel. Invisible — and fatal.
|
|
|
|
A quarter of a pixel of overlap means the actor's box intersects the floor tile. On the next
|
|
step the **horizontal** sweep therefore finds itself blocked wherever it tries to go, and
|
|
snaps the actor back to a tile boundary. The symptom is a character who cannot walk, jerking
|
|
backwards by up to a tile every time it tries. Nothing about it looks like a vertical
|
|
problem.
|
|
|
|
The fix is to pre-load the cancellation instead of zeroing:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
if ( dest->blocked_y == true ) {
|
|
obj->y = box.y - body->y;
|
|
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
|
obj->ty = 0.0f;
|
|
}
|
|
```
|
|
|
|
After the step's gravity, `ey` is exactly zero; after drag, still zero; `move` commits
|
|
nothing. A standing actor rests exactly on the surface, forever, at a `y` that is a whole
|
|
number of pixels.
|
|
|
|
### Standing on something is measured, not recorded
|
|
|
|
Nothing in libakgl knows whether an actor is on the ground. It is one probe, a pixel below
|
|
where the box will be when the step finishes:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
probe = box;
|
|
probe.y += 1.0f;
|
|
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
|
dest->grounded = solid;
|
|
```
|
|
|
|
The verdict is one frame stale by the time the jump reads it, because this hook runs before
|
|
the step it is deciding about. One frame of coyote time is not something a player can feel.
|
|
|
|
### Spawning inside the scenery
|
|
|
|
A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the
|
|
player at x=32 with a step at tile (3,11) — under the right half of the player's frame. The
|
|
swept resolution cannot help: it stops an actor *entering* terrain and has nothing to say
|
|
about one that began inside it. What it does instead is refuse every horizontal move,
|
|
because the box is blocked wherever it goes.
|
|
|
|
So spawn points get lifted clear once, before the first step:
|
|
|
|
```c excerpt=examples/sidescroller/collision.c
|
|
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
|
if ( solid == false ) {
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
obj->y -= (float32_t)SS_TILE_SIZE;
|
|
```
|
|
|
|
The player and the blob both need it, and both end up standing on the block they were
|
|
overlapping. Failing after four tiles is deliberate: a spawn point buried that deep is a
|
|
level bug, and a silent nudge would hide it.
|
|
|
|
## Making it feel like a platformer
|
|
|
|
Two of libakgl's documented gaps are about feel rather than correctness, and both are in
|
|
`TODO.md` under "Arcade physics feel". The tutorial works around both rather than pretending.
|
|
|
|
### Releasing a direction stops the actor dead
|
|
|
|
`akgl_actor_cmhf_left_off` clears the movement bit, zeroes `ax`, **and zeroes `tx`**. There
|
|
is no friction and no deceleration anywhere in the arcade backend, so a character at full
|
|
speed stops within one frame — 0.0 px of drift, measured. That is correct for a top-down
|
|
Zelda and wrong for a sidescroller.
|
|
|
|
The game binds its own release handlers, which do everything the library's do except the
|
|
last part:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
|
obj->ax = 0.0f;
|
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
and decays `tx` in the movement logic instead, faster on the ground than in the air:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
friction = SS_FRICTION_AIR;
|
|
if ( ss_game.grounded == true ) {
|
|
friction = SS_FRICTION_GROUND;
|
|
}
|
|
obj->tx -= obj->tx * friction * dt;
|
|
if ( fabsf(obj->tx) < 1.0f ) {
|
|
obj->tx = 0.0f;
|
|
}
|
|
```
|
|
|
|
The snap to zero below a pixel per second is there because an exponential decay never
|
|
actually arrives.
|
|
|
|
The `_on` handlers are the library's unchanged. `akgl_actor_cmhf_right_on` clears
|
|
`FACE_ALL | MOVING_ALL`, sets `MOVING_RIGHT | FACE_RIGHT` and signs `ax` from the character
|
|
— exactly right. Only the release half needed replacing. [Chapter 15](15-input.md) covers
|
|
building a control map; the whole of this game's is six bindings, three keyboard and three
|
|
gamepad.
|
|
|
|
### A jump is an impulse into `ey`, not thrust
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
|
obj->ey = -SS_JUMP_SPEED;
|
|
}
|
|
ss_game.jump_requested = false;
|
|
```
|
|
|
|
It has to be the environmental term. `ey` is the axis gravity accumulates on, and the two
|
|
have to cancel for the arc to come back down. Written as thrust it would not work at all:
|
|
`ty` is capped against the character's `speed_y`, which is `0.0` for this character, so
|
|
`akgl_physics_simulate`'s ellipse cap scales it to nothing. See the thrust-versus-velocity
|
|
model in [Chapter 14](14-physics.md).
|
|
|
|
`ey` being an ordinary field that nothing else owns between steps also buys variable jump
|
|
height for four lines — releasing the button while still rising cuts the remaining
|
|
velocity:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
if ( obj->ey < 0.0f ) {
|
|
obj->ey *= 0.4f;
|
|
}
|
|
```
|
|
|
|
## The state word chooses the sprite, and one bit is missing
|
|
|
|
An actor's whole 32-bit `state` is the key that selects a sprite ([Chapter 12](12-actors.md)).
|
|
The player's character binds ten combinations: idle and running each way, and jumping each
|
|
way.
|
|
|
|
The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever the actor is
|
|
not on the ground:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
if ( contact.grounded == true ) {
|
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
|
} else {
|
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
|
}
|
|
```
|
|
|
|
Nothing else reads the bit here. `speed_y` and `acceleration_y` are both `0.0` in
|
|
`character_ss_player.json`, so the vertical thrust it would authorise is capped to nothing —
|
|
the bit is being used purely as an animation selector, which is a thing the state mask is
|
|
good for.
|
|
|
|
The missing bit is the one that makes the player disappear. The default `facefunc`,
|
|
`akgl_actor_automatic_face`, clears every facing bit and then sets one from the *movement*
|
|
bits — so **an actor that stops moving is left facing nowhere**, its state drops to bare
|
|
`ALIVE`, and there is no sprite for that. It is not an error: `akgl_actor_render` handles
|
|
the `AKERR_KEY`, treats the actor as invisible, and draws nothing. A player who stops
|
|
walking vanishes.
|
|
|
|
The documented way out is to take the facing bits off the library entirely:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
obj->movement_controls_face = false;
|
|
```
|
|
|
|
`akgl_actor_automatic_face` leaves an actor alone when that is clear, so the facing bits stay
|
|
wherever the control handlers last put them — and `akgl_actor_cmhf_left_off` clears
|
|
`MOVING_LEFT` without touching `FACE_LEFT`, which is exactly the behaviour a standing sprite
|
|
needs. The alternative, mapping bare `ALIVE` to something, is in
|
|
[Chapter 12](12-actors.md).
|
|
|
|
## `AKGL_ERR_LOGICINTERRUPT`, in a game
|
|
|
|
The one status in libakgl that is not a failure. Raised from a `movementlogicfunc` it means
|
|
"skip the rest of this tick for me" — no gravity, no drag, no move — and
|
|
`akgl_physics_simulate` handles it and carries on to the next actor.
|
|
|
|
Two of this game's three non-player behaviours are built on it, for two different reasons.
|
|
|
|
The coins need it because **gravity is not thrust**. Their character declares no speed and
|
|
no acceleration, so they cannot thrust; `akgl_physics_arcade_gravity` accumulates into `ey`
|
|
for every actor it is handed regardless, and a coin left to the default logic falls out of
|
|
the level with everything else.
|
|
|
|
```c excerpt=examples/sidescroller/actors.c
|
|
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
(void)dt;
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
|
|
}
|
|
```
|
|
|
|
The moth needs it because it flies and the level's physics has gravity, which the player
|
|
needs. Rather than fighting the backend it writes its own position and opts out:
|
|
|
|
```c excerpt=examples/sidescroller/actors.c
|
|
data->phase += dt;
|
|
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
|
|
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
|
|
```
|
|
|
|
Two rules go with it, both from [Chapter 4](04-errors.md):
|
|
|
|
- **Only `movementlogicfunc` gets this treatment.** The backend's `gravity` and `move` are
|
|
called through `PASS` in the same block, so a raise from either aborts the whole step —
|
|
leaving every remaining actor unsimulated and `gravity_time` unadvanced.
|
|
- The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN`
|
|
inside one returns past `CLEANUP`.
|
|
|
|
The blob is the counter-example: it stays inside the physics step, walks under the level's
|
|
gravity like the player does, and uses the same swept resolution. It turns around when the
|
|
sweep blocks it or when there is no floor one pixel past its leading edge:
|
|
|
|
```c excerpt=examples/sidescroller/actors.c
|
|
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
|
data->facing = -data->facing;
|
|
```
|
|
|
|
### Per-actor data
|
|
|
|
`akgl_Actor::actorData` is a `void *` the library never reads or frees, and it is where the
|
|
blob's patrol direction and the moth's flight phase live. It points into a fixed table:
|
|
|
|
```c excerpt=examples/sidescroller/actors.c
|
|
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
|
|
```
|
|
|
|
A table rather than an allocation, for the same reason libakgl has pools rather than a
|
|
`malloc`: the level places a known number of things, and a game that cannot run out of
|
|
memory at runtime is one fewer failure mode. See [Chapter 5](05-the-heap.md).
|
|
|
|
## Picking things up
|
|
|
|
Collecting a coin is a rectangle test from [Chapter 18](18-utilities.md) and a pool release:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
|
|
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
|
if ( hit == true ) {
|
|
```
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
|
|
ss_game.coins[i] = NULL;
|
|
ss_game.coins_taken += 1;
|
|
```
|
|
|
|
**There is no despawn call.** Giving the pool slot back is what clears the registry entry
|
|
and stops the actor being drawn, and it is safe to do from inside `akgl_game_update`'s actor
|
|
sweep: that loop re-reads `refcount` at the top of every iteration and skips a slot that has
|
|
gone free. Releasing an actor does not touch its character, so the other coins are
|
|
unaffected.
|
|
|
|
This runs in the player's `updatefunc` rather than its `movementlogicfunc`, and the split is
|
|
the frame's own order. `akgl_game_update` calls every actor's `updatefunc`, *then* steps the
|
|
physics, *then* draws. Game logic that is not movement belongs in the first pass; anything
|
|
that has to happen inside the physics step has only the one hook.
|
|
|
|
## The frame
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
|
```
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
PASS(errctx, akgl_game_update(NULL));
|
|
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 or present, so the frame is bracketed by the backend's own calls — see
|
|
[Chapter 7](07-the-game-and-the-frame.md) and [Chapter 8](08-rendering.md).
|
|
|
|
Note the failure contract. **`akgl_game_update` returns holding the game state lock on every
|
|
one of its failure paths.** SDL mutexes are recursive so a single-threaded loop does not
|
|
deadlock on the next frame, but a frame that failed has left the world half-stepped: some
|
|
actors advanced and some not. Treat it as terminal, which is what `PASS` does here.
|
|
|
|
Events go through `akgl_controller_handle_event` unconditionally — an event that no control
|
|
map binds is not an error, it is a call that did nothing, which is what lets a host pump
|
|
everything through one path.
|
|
|
|
## The camera
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
|
|
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
|
|
```
|
|
|
|
`akgl_camera` is a plain `SDL_FRect` in map pixels that `akgl_tilemap_draw` and
|
|
`akgl_actor_render` both read. Moving it is the whole of scrolling; there is no camera
|
|
object and no follow behaviour to configure. It is floored to a whole pixel afterwards
|
|
because `akgl_tilemap_draw` truncates it when working out how much of the edge tiles to
|
|
show, and a camera that is fractionally different every frame makes the tile grid shimmer.
|
|
|
|
The camera is updated before `akgl_game_update` and therefore uses the player's position
|
|
from the end of the *previous* step. Correcting that would mean splitting the update from
|
|
the draw, which `akgl_game_update` does not offer.
|
|
|
|
## Teardown
|
|
|
|
```c excerpt=examples/sidescroller/main.c
|
|
IGNORE(akgl_text_unloadallfonts());
|
|
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
|
if ( akgl_heap_actors[i].refcount > 0 ) {
|
|
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
|
|
}
|
|
}
|
|
if ( akgl_gamemap != NULL ) {
|
|
IGNORE(akgl_tilemap_release(akgl_gamemap));
|
|
}
|
|
TTF_Quit();
|
|
MIX_Quit();
|
|
SDL_Quit();
|
|
```
|
|
|
|
The `NULL` guard on the map is not defensive padding. This function is called from the
|
|
program's `CLEANUP` block, which is reached whether startup succeeded or not — and a bad
|
|
`--assets` path fails before `akgl_game_init` has pointed `akgl_gamemap` at anything.
|
|
|
|
**There is no `akgl_game_shutdown`.** Teardown belongs to the application, and the order
|
|
matters in one place: `akgl_text_unloadallfonts` has to run before `TTF_Quit` or `SDL_Quit`,
|
|
because those destroy the fonts underneath the registry that still points at them
|
|
([Chapter 16](16-text-and-fonts.md)). This game loads no fonts and calls it anyway, because
|
|
the ordering is the thing worth copying.
|
|
|
|
What this does **not** do is unwind the sprite, spritesheet and character pools. They are
|
|
static storage in a process that is exiting and the objects still reference each other; a
|
|
game that loads a second level has to do it properly, and this one does not pretend to. Two
|
|
things to know before you write that code:
|
|
|
|
- `akgl_heap_release_character` calls `akgl_character_state_sprites_iterate` on every
|
|
release, and that function is an `SDL_EnumerateProperties` callback ending in
|
|
`FINISH_NORETURN` — **an error inside it exits the process**. This is an ordinary path,
|
|
not an unusual one. [Chapter 11](11-characters.md) and [Chapter 4](04-errors.md).
|
|
- `akgl_tilemap_release` does not release the actors an object layer created. Those are
|
|
yours, which is what the loop above is for.
|
|
|
|
## Known defects you will see running this
|
|
|
|
Each is cross-referenced to `TODO.md`. None is worked around, because in each case the
|
|
workaround would be worse than the symptom.
|
|
|
|
**The leftmost column of tiles is drawn from the wrong part of the tileset when the camera
|
|
is within one tile of x=0.** `akgl_tilemap_draw` special-cases the first visible column to
|
|
show a partial tile, and writes `src.x += (int)viewport->x % map->tilewidth` — a `+=` onto
|
|
whatever `src.x` was left holding by the last tile of the previous row, rather than an
|
|
assignment onto that tile's own offset (`src/tilemap.c:766`). At `viewport->x = 0` the
|
|
added term is zero and the stale value is used unchanged. The visible result in this game is
|
|
a stray tile at the bottom-left of the screen, which disappears the moment the camera scrolls
|
|
away from the origin and comes back when the player walks home. `TODO.md`, "Performance"
|
|
item 5, records it alongside the tileset scan it lives in; the same shape applies to `src.y`
|
|
and the top row.
|
|
|
|
**An actor on layer 16 or higher is never drawn.** `akgl_Actor::layer` is a `uint32_t` and
|
|
nothing range-checks it, but `akgl_render_2d_draw_world` stops at `AKGL_TILEMAP_MAX_LAYERS`.
|
|
This level's object group is layer index 2, so it does not bite here — but a map with
|
|
seventeen layers loses its actors silently. [Chapter 12](12-actors.md).
|
|
|
|
**A wrong name in an asset file can kill the process rather than raise.** The six
|
|
`SDL_EnumerateProperties` callbacks in libakgl end in `FINISH_NORETURN`, and libakerror's
|
|
default unhandled-error handler calls `akerr_exit()`. `akgl_game_update` does not go through
|
|
one — it sweeps the pool directly and propagates — so this game reaches it only through
|
|
`akgl_heap_release_character`. Install your own `akerr_handler_unhandled_error` if a game
|
|
should survive it, and call `akerr_exit()` from it. [Chapter 4](04-errors.md).
|
|
|
|
**`akgl_tilemap_load` releases nothing on a failed load.** Textures already uploaded and
|
|
actors already created stay where they are and the destination holds a half-built map. This
|
|
game treats a failed load as fatal, which is the only honest thing to do with one map.
|
|
[Chapter 13](13-tilemaps.md).
|
|
|
|
## The smoke test
|
|
|
|
The game is registered as a CTest case, so a change that breaks it turns the suite red
|
|
rather than waiting for a reader to notice:
|
|
|
|
```cmake
|
|
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
|
|
set_tests_properties(example_sidescroller PROPERTIES
|
|
TIMEOUT 120
|
|
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
|
)
|
|
```
|
|
|
|
Four seconds of scripted play under the headless drivers. `--autoplay` holds *right* and
|
|
taps jump every forty-five frames, and it does it by pushing synthetic events through
|
|
`akgl_controller_handle_event` rather than by calling the handlers:
|
|
|
|
```c excerpt=examples/sidescroller/player.c
|
|
if ( frame == 1 ) {
|
|
synthetic.type = SDL_EVENT_KEY_DOWN;
|
|
synthetic.key.which = SS_AUTOPLAY_KBID;
|
|
synthetic.key.key = SDLK_RIGHT;
|
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
|
|
FAIL_ZERO_RETURN(
|
|
errctx,
|
|
AKGL_BITMASK_HAS(ss_game.player->state, AKGL_ACTOR_STATE_MOVING_RIGHT),
|
|
AKGL_ERR_BEHAVIOR,
|
|
"the right arrow did not reach the player; the control map matched nothing"
|
|
);
|
|
}
|
|
```
|
|
|
|
**That distinction is the whole value of the smoke test, and the first version got it
|
|
wrong.** Calling the handlers directly is easier and it works — but it skips the binding
|
|
table, which is the part with something to get wrong. This game shipped with a control map
|
|
that matched no keyboard on earth, and a smoke test that called the handlers passed anyway.
|
|
A test that cannot fail is not a test, so the script drives the real dispatch and then
|
|
asserts the press actually arrived. Break the binding and the run exits non-zero naming the
|
|
reason.
|
|
|
|
`SS_AUTOPLAY_KBID` is deliberately not `0`: the map binds keyboard `0` meaning *any*
|
|
keyboard, and driving it from a non-zero device id is what proves that. In four seconds the
|
|
script walks the level, jumps, collides with terrain, collects a coin, falls in the pit and
|
|
respawns — so the smoke test exercises the input path, the collision and the pickup, not
|
|
just the startup.
|
|
|
|
A run ends with a line naming what happened, which is worth reading when a change moves the
|
|
feel:
|
|
|
|
```text
|
|
sidescroller: 240 frames, 1 of 4 coins, 1 deaths
|
|
```
|
|
|
|
The two counts are not fixed. `dt` is measured from the wall clock rather than passed in
|
|
([Chapter 14](14-physics.md)), so a busier machine takes slightly different steps and the
|
|
script lands in slightly different places. That is why the smoke test asserts a clean exit
|
|
rather than a score — an assertion on the numbers would be a test of the scheduler.
|
|
|
|
## Where to look next
|
|
|
|
- [Chapter 14](14-physics.md) — thrust, environmental velocity, the speed ellipse, and the
|
|
full list of what is not implemented.
|
|
- [Chapter 12](12-actors.md) — the state mask, the six hooks, and the control handlers.
|
|
- [Chapter 13](13-tilemaps.md) — what the map loader accepts, and the three extensions to
|
|
Tiled this level uses.
|
|
- [Chapter 15](15-input.md) — control maps, and why a binding that never fires is usually
|
|
the wrong device id.
|
|
- [Chapter 20](20-tutorial-jrpg.md) — the same library from the other end: a top-down game
|
|
with no gravity, where the content pipeline is the interesting part.
|