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:
2026-08-01 20:59:00 -04:00
parent b938460127
commit 88aaa184e4
72 changed files with 8946 additions and 0 deletions

View File

@@ -0,0 +1,787 @@
# 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
```
Arrow keys to run, space to jump, and holding space longer jumps higher. 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
requests a jump every forty-five frames by calling the handlers a keyboard would have
called:
```c excerpt=examples/sidescroller/player.c
if ( frame == 1 ) {
PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
}
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
ss_game.jump_requested = true;
}
```
That works because a control-map handler takes the actor and an event, and the keyboard
handlers do not read the event beyond requiring one to be there. 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 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.

675
docs/20-tutorial-jrpg.md Normal file
View 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 |
|---|---|---|
| `ALIVEFACE_DOWN` | 17 | `jrpg_player_idle_down` |
| `ALIVEFACE_DOWNMOVING_DOWN` | 1041 | `jrpg_player_walk_down` |
| `ALIVEFACE_LEFT` | 18 | `jrpg_player_idle_left` |
| `ALIVEFACE_LEFTMOVING_LEFT` | 146 | `jrpg_player_walk_left` |
| `ALIVEFACE_RIGHT` | 20 | `jrpg_player_idle_right` |
| `ALIVEFACE_RIGHTMOVING_RIGHT` | 276 | `jrpg_player_walk_right` |
| `ALIVEFACE_UP` | 24 | `jrpg_player_idle_up` |
| `ALIVEFACE_UPMOVING_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.

View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View File

@@ -0,0 +1,21 @@
Music Jingles
by Kenney Vleugels (Kenney.nl)
------------------------------
License (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You may use these assets in personal and commercial projects.
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Follow on Twitter for updates:
@KenneyNL

View File

@@ -0,0 +1,22 @@
Pixel Line Platformer (1.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 15-08-2021
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:

View File

@@ -0,0 +1,23 @@
RPG Urban Pack 1.0
Created/distributed by Kenney (www.kenney.nl)
Creation date: 05-01-2019
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:

View File

@@ -0,0 +1,151 @@
# Provenance of the tutorial assets
Everything under `docs/tutorials/assets/` is **CC0 1.0** or was written for this
repository. Nothing here carries an attribution requirement, a share-alike
clause, or a field-of-use restriction, which is the whole point: a reader who
copies a tutorial into their own game inherits whatever obligation these assets
carry, and CC0 carries none. "Free to download" is not the same thing and was
not accepted -- each pack's licence was read on its Kenney page *and* in the
`License.txt` it ships, and `scripts/fetch_tutorial_assets.sh` re-checks both on
every refresh and refuses to vendor a pack that fails either.
`LICENSE` is the CC0 1.0 legal code. The three `LICENSE.kenney_*.txt` files are
the licence statements the packs themselves ship, kept beside the art in the
same way `tests/assets/akgl_test_mono.LICENSE.txt` sits beside its font.
The two tables below account for all 56 asset files: 14 carry upstream bytes and
42 were written here. This file and `README.md` are the remaining two, and are
documentation of the same kind as the rest of `docs/`.
## Upstream packs
| Pack | URL | Licence | Licence file in this tree |
|-----------------------|------------------------------------------------|---------|--------------------------------------------|
| Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `LICENSE.kenney_pixel-line-platformer.txt` |
| RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `LICENSE.kenney_rpg-urban-pack.txt` |
| Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `LICENSE.kenney_music-jingles.txt` |
Kenney asks for credit and does not require it. Credit is given here and in the
tutorial chapters.
## Files carrying upstream bytes
| File | Pack | Source URL | Licence | Cropped / repacked into |
|--------------------------------------------|-----------------------|-----------------------------------------------------------------|---------|----------------------------------------------------------------------------------------------------|
| `LICENSE` | creativecommons.org | https://creativecommons.org/publicdomain/zero/1.0/legalcode.txt | CC0 1.0 | The CC0 1.0 Universal legal code, fetched verbatim |
| `LICENSE.kenney_pixel-line-platformer.txt` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
| `LICENSE.kenney_rpg-urban-pack.txt` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
| `LICENSE.kenney_music-jingles.txt` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
| `sidescroller/tiles.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 160x96, 10x6 tiles of 16x16, no spacing, no margin |
| `sidescroller/player.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 40, 41, 42 and their horizontal mirrors, bottom-centred in six 32x32 cells |
| `sidescroller/coin.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tile 44, bottom-centred in one 32x32 cell |
| `sidescroller/hazard.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 55, 56, 51, 52, bottom-centred in four 32x32 cells |
| `sidescroller/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/8-Bit jingles/jingles_NES00.ogg`, copied verbatim (1.8 s) |
| `jrpg/tiles.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 432x288, 27x18 tiles of 16x16, no spacing, no margin |
| `jrpg/player.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 0 (tileset columns 23-26, rows 0-2) regrouped into twelve 32x32 cells |
| `jrpg/npc_shopkeeper.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 3 (tileset columns 23-26, rows 9-11) regrouped into twelve 32x32 cells |
| `jrpg/npc_elder.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 2 (tileset columns 23-26, rows 6-8) regrouped into twelve 32x32 cells |
| `jrpg/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/Pizzicato jingles/jingles_PIZZI07.ogg`, copied verbatim (1.3 s) |
## Files written for this repository
These contain no upstream content. They are libakgl source in JSON form: they
name the art above, they do not embed it.
| File | Kind | What it is |
|-------------------------------------------------|-----------|---------------------------------------------------------------------------------|
| `jrpg/character_jrpg_elder.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `jrpg/character_jrpg_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `jrpg/character_jrpg_shopkeeper.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `jrpg/sprite_jrpg_elder_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/town.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 30x20 cells, ground + decoration + actors |
| `sidescroller/character_ss_coin.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/character_ss_hazard_blob.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/character_ss_hazard_moth.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/character_ss_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/level1.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 40x15 cells, background + terrain + actors |
| `sidescroller/sprite_ss_coin.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_hazard_blob.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_hazard_moth.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_jump_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_jump_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_run_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_run_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
## How the repacking works, and why
**Tilesets are copied verbatim.** Both packs already ship a 16x16 grid with zero
spacing and zero margin, which is the only geometry
`akgl_tilemap_compute_tileset_offsets` gets right -- it adds `spacing` to the
tile pitch but sets the *first* row's y offset to `spacing` rather than zero,
and it ignores `margin` completely. A packed sheet with a 1 px gutter, which is
what most of Kenney's older packs ship, would render every tile in row 0 one
pixel low. That ruled several otherwise-suitable packs out.
**Character sheets are repacked into one row of 32x32 cells.** The asset
contract for these tutorials is 16x16 tiles and 32x32 character frames, and
neither pack ships 32x32 art, so each 16x16 source tile is composited at (8, 16)
inside its cell -- horizontally centred, sitting on the cell's bottom edge. The
art is not resampled: a 2x upscale would put 2x2 pixel blocks next to 1x1 tiles,
and every sprite in the repository would stop matching the source pixel for
pixel. The cost is that three quarters of each cell is empty, and the benefit is
one rule the tutorials can rely on: **an actor drawn into a 32x32 destination
rectangle has its feet on the bottom edge of that rectangle and its body
centred**, whichever sheet it came from.
**Left-facing frames are stored, not mirrored at draw time.**
`akgl_actor_render` calls `draw_texture` with `SDL_FLIP_NONE` hard-coded
(`src/actor.c`), so there is no way to ask for a mirrored blit. The sidescroller
sheet therefore carries frames 0-2 facing right and frames 3-5 as their
horizontal mirrors. The JRPG sheets need no mirroring: the RPG Urban Pack draws
both side views.
**One frame is used twice.** Pixel Line Platformer has no dedicated jump pose.
Frame 1 is the passing position of the run cycle, which is the airborne one, and
`ss_player_jump_right` reuses it. Said here rather than left for a reader to
notice.
**The coin does not animate.** Pixel Line Platformer ships one gold pickup tile
and no rotation frames, so `sprite_ss_coin.json` is a single frame. Nothing was
invented to pad it out.
## Reproducing this
```sh
scripts/fetch_tutorial_assets.sh
```
Fetches all three packs, checks each page and each shipped `License.txt` says
CC0, verifies the source images are the size the frame arithmetic assumes,
repacks into a temporary directory, checks every staged file's dimensions, and
only then moves anything into place. A failure at any step leaves the tracked
copies untouched and exits non-zero. The output is byte-for-byte reproducible --
ImageMagick's wall-clock `date:create` and `date:modify` chunks are stripped, so
a refresh that changes nothing produces no diff.
The script does not touch the JSON or the TMJ maps. Those are hand-maintained.

View File

@@ -0,0 +1,162 @@
# The tutorial asset contract
What `examples/sidescroller/` and `examples/jrpg/` are drawing, and the exact
numbers they have to use. Licensing is in `PROVENANCE.md`; refreshing the art
from upstream is `scripts/fetch_tutorial_assets.sh`.
Every claim below was checked against `src/`, not against header prose, and the
whole set was loaded through the real library -- `akgl_sprite_load_json`,
`akgl_character_load_json`, `akgl_tilemap_load` -- before being committed.
## Geometry
| Thing | Value | Why it is that value |
|-------------------------|--------|---------------------------------------------------------------------------|
| Tile | 16x16 | Both tilesets ship on a 16x16 grid |
| Tileset spacing, margin | 0, 0 | `akgl_tilemap_compute_tileset_offsets` gets nothing else right; see below |
| Character frame | 32x32 | The fixed contract for these tutorials |
| Art inside a frame | 16x16 | Composited at (8, 16): centred, sitting on the cell's bottom edge |
| Frames per sheet row | all | Every sheet is a single row, so `coords_for_frame` never has to wrap |
| Frames per animation | max 16 | `AKGL_SPRITE_MAX_FRAMES`; a 17th is `AKERR_OUTOFBOUNDS` at load |
| Frame id | 0..255 | `uint8_t`; a larger id is `AKERR_OUTOFBOUNDS` at load |
**The one alignment rule.** `akgl_actor_render` draws into a rectangle of
`sprite->width` by `sprite->height` with its top-left at the actor's `x`, `y`.
Because every frame's art is bottom-centred in its cell, an actor at `(x, y)`
has its feet at `y + 32` and its 16 px body spanning `x + 8` to `x + 23`. Ground
contact, pickup tests and hitboxes should use that inner rectangle, not the
32x32 cell.
## Sidescroller sheets
`sidescroller/player.png` -- 192x32, six frames.
| Frame | Facing | Pose | Used by |
|-------|--------|---------------------------------|-----------------------------------------------|
| 0 | right | contact | `ss_player_idle_right`, `ss_player_run_right` |
| 1 | right | passing, airborne | `ss_player_run_right`, `ss_player_jump_right` |
| 2 | right | opposite contact | `ss_player_run_right` |
| 3 | left | contact (mirror of 0) | `ss_player_idle_left`, `ss_player_run_left` |
| 4 | left | passing, airborne (mirror of 1) | `ss_player_run_left`, `ss_player_jump_left` |
| 5 | left | opposite contact (mirror of 2) | `ss_player_run_left` |
The left-facing frames exist as their own art because `akgl_actor_render` passes
`SDL_FLIP_NONE` to `draw_texture` unconditionally (`src/actor.c`). There is no
way to ask the library for a mirrored blit.
`sidescroller/coin.png` -- 32x32, one frame. The pack ships no rotation frames.
`sidescroller/hazard.png` -- 128x32, four frames.
| Frame | What | Used by |
|-------|--------------------------|------------------|
| 0 | red blob, ground, pose A | `ss_hazard_blob` |
| 1 | red blob, ground, pose B | `ss_hazard_blob` |
| 2 | moth, flying, wings up | `ss_hazard_moth` |
| 3 | moth, flying, wings down | `ss_hazard_moth` |
## JRPG sheets
`jrpg/player.png`, `jrpg/npc_shopkeeper.png` and `jrpg/npc_elder.png` are
384x32, twelve frames, and share one layout.
| Frames | Facing | Poses | Idle sprite uses | Walk sprite uses |
|--------|--------|-----------------------|------------------|------------------|
| 0-2 | down | stand, step A, step B | 0 | 1, 0, 2, 0 |
| 3-5 | left | stand, step A, step B | 3 | 4, 3, 5, 3 |
| 6-8 | right | stand, step A, step B | 6 | 7, 6, 8, 6 |
| 9-11 | up | stand, step A, step B | 9 | 10, 9, 11, 9 |
The stand frame between the two steps is what makes it read as a walk rather
than a shuffle; it is the same three-frame cycle the source art was drawn for.
## States
A character's sprite map is keyed by `SDL_itoa(state)` and looked up with
`SDL_GetPointerProperty` (`akgl_character_sprite_get`), so **the match is on the
whole integer, not on a mask test**. A state the character has no exact entry
for makes the actor invisible for that frame -- `actor_visible` treats
`AKERR_KEY` as "nothing to draw", which is an answer, not an error.
These are the values the library's own input handlers produce.
`akgl_actor_cmhf_<dir>_on` clears every `FACE_*` and `MOVING_*` bit before
setting its own pair, and `_off` clears only its `MOVING_*` bit, so the facing
survives the key release and no two `MOVING_*` bits are ever set at once.
| State | Bits | Sidescroller sprite | JRPG sprite |
|-------|-------------------------------------|---------------------|------------------|
| 16 | ALIVE | (coin, hazards) | -- |
| 17 | ALIVE, FACE_DOWN | `..._idle_right` | `..._idle_down` |
| 18 | ALIVE, FACE_LEFT | `..._idle_left` | `..._idle_left` |
| 20 | ALIVE, FACE_RIGHT | `..._idle_right` | `..._idle_right` |
| 24 | ALIVE, FACE_UP | -- | `..._idle_up` |
| 146 | ALIVE, FACE_LEFT, MOVING_LEFT | `..._run_left` | `..._walk_left` |
| 276 | ALIVE, FACE_RIGHT, MOVING_RIGHT | `..._run_right` | `..._walk_right` |
| 530 | ALIVE, FACE_LEFT, MOVING_UP | `..._jump_left` | -- |
| 532 | ALIVE, FACE_RIGHT, MOVING_UP | `..._jump_right` | -- |
| 536 | ALIVE, FACE_UP, MOVING_UP | `..._jump_right` | `..._walk_up` |
| 658 | ALIVE, FACE_LEFT, MOVING_LEFT, UP | `..._jump_left` | -- |
| 788 | ALIVE, FACE_RIGHT, MOVING_RIGHT, UP | `..._jump_right` | -- |
| 1041 | ALIVE, FACE_DOWN, MOVING_DOWN | -- | `..._walk_down` |
The four jump states are there because a sidescroller that routes its jump
through `akgl_actor_cmhf_up_on` lands on 536 -- that handler clears `FACE_RIGHT`
and sets `FACE_UP`, which in a side view is not a facing at all. A game that
sets `ey` directly instead never reaches those states, and nothing breaks
either way.
State names in character JSON are the **prefixed** spellings from
`src/actor_state_string_names.c`: `AKGL_ACTOR_STATE_ALIVE`, not
`ACTOR_STATE_ALIVE`. `util/assets/littleguy.json` uses the old unprefixed names
and `velocity_x`, and does not load against the current library. Do not copy it.
## Maps
Both maps are Tiled 1.8 TMJ with the tileset **embedded**.
`akgl_tilemap_load` reads `columns`, `firstgid`, `tilecount`, `image`,
`imagewidth`, `imageheight`, `margin`, `spacing`, `tilewidth`, `tileheight` and
`name` straight out of each element of the map's `tilesets` array
(`akgl_tilemap_load_tilesets_each`). The string `source` appears nowhere in
`src/tilemap.c` and nothing in the library ever opens a `.tsj`, so an external
tileset reference fails with `AKERR_KEY` on the missing `columns`. `README.md`
in the repository root says the opposite; it is wrong.
| Map | Cells | Layers | Objects |
|---------------------------|-------|-------------------------------------------|---------|
| `sidescroller/level1.tmj` | 40x15 | background (tile), terrain (tile), actors | 7 |
| `jrpg/town.tmj` | 30x20 | ground (tile), decoration (tile), actors | 3 |
Global tile ids are `(row * columns) + column + 1`; `0` means an empty cell and
is skipped. `sidescroller/tiles.png` has ten columns and 60 tiles;
`jrpg/tiles.png` has twenty-seven columns and 486 tiles, the last four columns
of which are the character art the sheets above were cut from. Those cells are
never referenced by `town.tmj`.
Other things the loader insists on, all of them checked:
- **Every object needs a `type` string**, including ones that are not actors. A
missing `type` fails the whole load, not just that object.
- An actor object needs a non-empty `name`, a `character` **string** property
and a `state` **int** property. The string-array form of `state` works in
character JSON and is not accepted here.
- The `character` named must already be in `AKGL_REGISTRY_CHARACTER`, which
means every sprite and character JSON has to be loaded before the map.
- A map's `properties` are optional; if present, `physics.model` must name a
backend that exists (`null` or `arcade`) or the load fails. Gravity and drag
keys are `float`.
- Tileset image paths resolve through `akgl_path_relative` against the map's own
directory. Image-layer paths do not -- those go through a plain `"%s/%s"`
join, so an absolute path there does not work. Keep every path relative.
## Physics
The sidescroller map asks for the `arcade` backend with `physics.gravity.y`
900.0 and `physics.drag.y` 1.5. There is no terminal velocity in the backend;
`ey` approaches `gravity_y / drag_y`, so those two numbers set it to 600 px/s.
The JRPG map asks for `arcade` with both gravity components at 0.0.
`ss_player` has `speed_y` and `acceleration_y` of 0.0 on purpose: a zero top
speed on an axis means `akgl_physics_simulate` zeroes that axis's thrust
outright, so holding a vertical direction cannot make the player fly. A jump
belongs in `ey`.

View File

@@ -0,0 +1,70 @@
{
"name": "jrpg_elder",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_elder_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_elder_walk_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "jrpg_elder_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "jrpg_elder_walk_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "jrpg_elder_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "jrpg_elder_walk_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP"
],
"sprite": "jrpg_elder_idle_up"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "jrpg_elder_walk_up"
}
]
}

View File

@@ -0,0 +1,70 @@
{
"name": "jrpg_player",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_player_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_player_walk_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "jrpg_player_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "jrpg_player_walk_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "jrpg_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "jrpg_player_walk_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP"
],
"sprite": "jrpg_player_idle_up"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "jrpg_player_walk_up"
}
]
}

View File

@@ -0,0 +1,70 @@
{
"name": "jrpg_shopkeeper",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_shopkeeper_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_shopkeeper_walk_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "jrpg_shopkeeper_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "jrpg_shopkeeper_walk_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "jrpg_shopkeeper_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "jrpg_shopkeeper_walk_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP"
],
"sprite": "jrpg_shopkeeper_idle_up"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "jrpg_shopkeeper_walk_up"
}
]
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_down",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
6
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_up",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
9
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
1,
0,
2,
0
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_left",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
4,
3,
5,
3
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_right",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
7,
6,
8,
6
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_up",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
10,
9,
11,
9
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_down",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
6
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_up",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
9
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
1,
0,
2,
0
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_left",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
4,
3,
5,
3
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_right",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
7,
6,
8,
6
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_up",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
10,
9,
11,
9
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_down",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
6
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_up",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
9
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
1,
0,
2,
0
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_left",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
4,
3,
5,
3
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_right",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
7,
6,
8,
6
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_up",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
10,
9,
11,
9
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"name": "ss_coin",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "ss_coin"
}
]
}

View File

@@ -0,0 +1,46 @@
{
"name": "ss_hazard_blob",
"speedtime": 180,
"speed_x": 24.0,
"speed_y": 0.0,
"acceleration_x": 200.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_hazard_blob"
}
]
}

View File

@@ -0,0 +1,46 @@
{
"name": "ss_hazard_moth",
"speedtime": 120,
"speed_x": 40.0,
"speed_y": 40.0,
"acceleration_x": 300.0,
"acceleration_y": 300.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_hazard_moth"
}
]
}

View File

@@ -0,0 +1,89 @@
{
"name": "ss_player",
"speedtime": 120,
"speed_x": 90.0,
"speed_y": 0.0,
"acceleration_x": 600.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_player_run_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "ss_player_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "ss_player_run_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "ss_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_left"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "coin.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_coin",
"width": 32,
"height": 32,
"speed": 200,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,17 @@
{
"spritesheet": {
"filename": "hazard.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_hazard_blob",
"width": 32,
"height": 32,
"speed": 180,
"loop": true,
"loopReverse": false,
"frames": [
0,
1
]
}

View File

@@ -0,0 +1,17 @@
{
"spritesheet": {
"filename": "hazard.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_hazard_moth",
"width": 32,
"height": 32,
"speed": 120,
"loop": true,
"loopReverse": false,
"frames": [
2,
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_jump_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
4
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_jump_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
1
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_run_left",
"width": 32,
"height": 32,
"speed": 90,
"loop": true,
"loopReverse": false,
"frames": [
3,
4,
5,
4
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_run_right",
"width": 32,
"height": 32,
"speed": 90,
"loop": true,
"loopReverse": false,
"frames": [
0,
1,
2,
1
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,72 @@
# The top-down JRPG tutorial game, quoted by docs/20-tutorial-jrpg.md.
#
# It is a real target built by `all`, not a snippet: a tutorial whose program
# does not compile is the failure this whole documentation effort exists to
# stop, and the chapter's `c excerpt=` blocks fail the moment this source moves
# under them.
add_executable(jrpg
jrpg.c
world.c
textbox.c
)
target_link_libraries(jrpg
PRIVATE akstdlib::akstdlib akerror::akerror akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer
jansson::jansson -lm)
target_include_directories(jrpg PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_compile_options(jrpg PRIVATE ${AKGL_WARNING_FLAGS})
# The game finds its data through two absolute paths baked in here. A game that
# ships would install its assets and resolve them against SDL_GetBasePath(); a
# tutorial has to run from the build tree, from the source tree, and from
# whatever working directory CTest hands it, so the paths are compiled in.
get_filename_component(JRPG_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
target_compile_definitions(jrpg PRIVATE
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
# tests/assets/akgl_test_mono.ttf, which ships beside its licence file.
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
)
# The vendored SDL satellite libraries land in per-project subdirectories that
# are not on the loader's default search path, exactly as they do for the test
# suites. AKGL_VENDORED_RPATH is set by the top-level CMakeLists.txt and is only
# defined when something was actually vendored.
if(AKGL_VENDORED_DEPENDENCIES)
set_target_properties(jrpg PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
endif()
# The headless smoke run. `--demo` drives the arrow keys from a script and steps
# the physics clock by a fixed 1/60 s, so the run is deterministic and finishes
# in well under a second; `--frames` bounds it. Between them the run walks the
# player into a wall, opens the text box, and exercises the follower's render
# hook -- rather than just proving that main() returns.
#
# add_test() reaches the real command here: the top-level CMakeLists.txt shadows
# it to suppress the vendored projects' registrations and lifts the suppression
# again long before examples/ is added.
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"
)
# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has
# to go in the same property rather than a second one. Only needed when the
# dependencies were vendored; an installed build resolves them normally.
if(AKGL_VENDORED_DEPENDENCIES)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
set(JRPG_TEST_ENV_MOD "")
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
endforeach()
set_tests_properties(example_jrpg
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
else()
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_tests_properties(example_jrpg PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
)
endif()
endif()

369
examples/jrpg/jrpg.c Normal file
View File

@@ -0,0 +1,369 @@
/**
* @file jrpg.c
* @brief A small top-down JRPG: startup, the frame loop, and teardown.
*
* Chapter 20 of the manual quotes this program rather than restating it, so
* what is here is what the chapter teaches. Run it with no arguments for a
* window you can walk around in:
*
* ./examples/jrpg/jrpg
*
* Arrow keys walk, space talks to whoever is standing next to you and dismisses
* the box again. `--frames N` stops after N frames, which is what makes this
* runnable as a headless smoke test; `--demo` drives the arrow keys from a
* script and steps the physics clock by a fixed interval so the run is
* deterministic and takes no wall-clock time.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/controller.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/iterator.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include "jrpg.h"
/** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */
#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
#define JRPG_FRAME_BUDGET_MS 16
/*
* The scripted walk `--demo` drives. The player starts at (224, 256) and the
* elder stands at (272, 112), so this is: right for a second, up for two and a
* bit, talk, try to walk while frozen, dismiss, walk away. Every one of those
* is a path the interactive game has and a headless run would otherwise never
* touch.
*
* Frame Key Down What it is for
*/
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 }
};
#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0]))
static long frame_limit = 0;
static bool demo = false;
static bool running = true;
static int exitstatus = 0;
static bool lowfps_warned = false;
/**
* @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second.
*
* akgl_game.fps is a completed-second average, so it reads 0 for the first
* second of every process -- which is under the threshold, so the default hook
* logs a line on every frame until the first second is up. The hook exists to
* be replaced; a real game sheds work here rather than talking about it.
*/
static void lowfps_quiet(void)
{
if ( lowfps_warned == false ) {
lowfps_warned = true;
SDL_Log("Frame rate is under 30 and this game does nothing about it");
}
}
/**
* @brief Read `--frames N` and `--demo` off the command line.
*
* @param argc Argument count, from `main`.
* @param argv Argument vector, from `main`. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p argv is `NULL`.
* @throws AKERR_VALUE If `--frames` is last, or its argument is not a number.
*/
static akerr_ErrorContext *parse_args(int argc, char *argv[])
{
PREPARE_ERROR(errctx);
int i = 0;
int frames = 0;
FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv");
for ( i = 1; i < argc; i++ ) {
if ( strcmp(argv[i], "--demo") == 0 ) {
demo = true;
} else if ( strcmp(argv[i], "--frames") == 0 ) {
if ( (i + 1) >= argc ) {
FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count");
}
i += 1;
PASS(errctx, aksl_atoi(argv[i], &frames));
frame_limit = frames;
} else {
FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]");
}
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Bring the library up, load the town, and bind the controls.
*
* The order below is the one game.h documents and the only one that works.
* Three things in it are not obvious and are what chapter 20 spends its time
* on: the properties have to be set between akgl_game_init and
* akgl_render_2d_init, because that is what reads them; the physics backend has
* to be initialized by hand, because akgl_game_init does not; and the assets
* have to be loaded sprites-then-characters-then-map.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
akgl_Control talk;
// Required before akgl_game_init, which refuses to run without all three:
// the window title, SDL's application metadata and the savegame
// compatibility check are built from them.
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.jrpg", sizeof(akgl_game.uri) - 1));
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));
// akgl_game_init points akgl_physics at akgl_default_physics and stops
// there. 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 segmentation fault on frame one rather
// than an error context.
//
// town.tmj happens to declare its own physics, and jrpg_world_load switches
// to the backend that builds -- so removing this line alone leaves *this*
// program working, and removing it along with that switch is a SIGSEGV on
// frame one. Measured, by removing both. This line is what makes the
// program correct for a map that declares no physics, which is most of
// them. Nothing calls the factory for you either way. A top-down game wants
// the arcade backend with no gravity, which is what the property defaults
// already give.
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE));
PASS(errctx, jrpg_world_load());
PASS(errctx, jrpg_world_populate());
// The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in
// one call. Keyboard id 0 and gamepad id 0 mean "the first of each".
PASS(errctx, akgl_controller_default(0, "player", 0, 0));
// And one binding of our own on the same map. A binding is a struct, copied
// in, so a stack local is fine. handler_off has to be non-NULL --
// akgl_controller_handle_event does not check it before calling it -- and
// the button is set to something no gamepad reports, because the gamepad
// and keyboard arms of the match are evaluated together.
PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk)));
talk.event_on = SDL_EVENT_KEY_DOWN;
talk.event_off = SDL_EVENT_KEY_UP;
talk.key = SDLK_SPACE;
talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID;
talk.handler_on = &jrpg_cmhf_talk;
talk.handler_off = &jrpg_cmhf_ignore;
PASS(errctx, akgl_controller_pushmap(0, &talk));
SUCCEED_RETURN(errctx);
}
/**
* @brief Feed the scripted keystrokes due on this frame into the control maps.
*
* Synthesized `SDL_Event`s rather than direct calls to the handlers, so the
* demo goes through akgl_controller_handle_event, the control map lookup and
* the binding -- the same path a real key takes. A test that skipped that would
* not be testing the thing that breaks.
*
* @param frameno The frame about to be drawn.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *demo_step(long frameno)
{
PREPARE_ERROR(errctx);
SDL_Event event;
size_t i = 0;
for ( i = 0; i < JRPG_DEMO_STEPS; i++ ) {
if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) {
continue;
}
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
if ( JRPG_DEMO_SCRIPT[i].down ) {
event.type = SDL_EVENT_KEY_DOWN;
} else {
event.type = SDL_EVENT_KEY_UP;
}
event.key.which = 0;
event.key.key = JRPG_DEMO_SCRIPT[i].key;
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
SUCCEED_RETURN(errctx);
}
/**
* @brief One frame: events, camera, the library's own update, the box, present.
*
* akgl_game_update is update-every-actor, step the physics, draw the world. It
* does *not* clear or present the target, 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.
*
* @param frameno The frame number, for the demo script.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *frame(long frameno)
{
PREPARE_ERROR(errctx);
SDL_Event event;
akgl_Iterator opflags = {
.flags = AKGL_ITERATOR_OP_UPDATE,
.layerid = 0
};
while ( SDL_PollEvent(&event) ) {
if ( event.type == SDL_EVENT_QUIT ) {
running = false;
}
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
if ( demo ) {
PASS(errctx, demo_step(frameno));
// Drive the clock rather than sleeping on it. akgl_physics_simulate
// measures dt from gravity_time, which is a public field, so setting it
// one fixed step into the past makes every frame worth exactly 1/60 of
// a second of simulated time no matter how fast the loop actually runs.
// tests/physics_sim.c does the same, for the same reason: a simulated
// second should not cost a real one.
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
}
PASS(errctx, jrpg_camera_follow());
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
// 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 -- but it would be running
// on top of a frame that did not finish. Treat a failed frame as terminal.
PASS(errctx, akgl_game_update(&opflags));
PASS(errctx, jrpg_textbox_draw());
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx);
}
/**
* @brief Give back what has to be given back, in the order that works.
*
* There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three
* lines here are load-bearing and the third is a deliberate omission:
*
* - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept 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.
* - akgl_tilemap_release is **not** called. 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. `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.
*/
static void teardown(void)
{
IGNORE(akgl_text_unloadallfonts());
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
}
int main(int argc, char *argv[])
{
PREPARE_ERROR(errctx);
akgl_Actor *player = NULL;
long frameno = 0;
uint64_t started = 0;
uint64_t spent = 0;
ATTEMPT {
CATCH(errctx, parse_args(argc, argv));
CATCH(errctx, startup());
// Taken here rather than after FINISH: the registry is an SDL property
// set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards
// finds nothing. The actor itself is in a static pool and outlives both.
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
// The loop is the last thing in this ATTEMPT block on purpose. CATCH
// reports failure by `break`ing, and a `break` inside a loop binds to
// the loop rather than to the block -- so a failing frame leaves the
// loop and falls straight into CLEANUP, which is what is wanted. Put
// anything after this loop and it would run after a failure instead.
// src/tilemap.c carries the same note over the same shape.
while ( running ) {
started = SDL_GetTicks();
CATCH(errctx, frame(frameno));
frameno += 1;
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
running = false;
}
if ( demo == false ) {
spent = SDL_GetTicks() - started;
if ( spent < JRPG_FRAME_BUDGET_MS ) {
SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent));
}
}
}
} CLEANUP {
teardown();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish");
// Set a flag and act on it after FINISH. A bare `return` from inside a
// HANDLE block leaves before RELEASE_ERROR and leaks the context's pool
// slot; AGENTS.md spells that out, and an example is a bad place to
// teach it wrong.
exitstatus = 1;
} FINISH_NORETURN(errctx);
// Enough for the smoke run to be read rather than merely passed. A run that
// exits 0 having drawn nothing looks exactly like one that worked.
if ( player != NULL ) {
printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
} else {
printf("jrpg: %ld frames, no player\n", frameno);
}
return exitstatus;
}

188
examples/jrpg/jrpg.h Normal file
View File

@@ -0,0 +1,188 @@
/**
* @file jrpg.h
* @brief Shared declarations for the JRPG tutorial game.
*
* Every function with external linkage in this program is declared here, the
* way AGENTS.md requires of the library itself. It is a three-translation-unit
* program and it would compile without the header; declaring them anyway is
* what keeps a signature from drifting between the definition and the call.
*
* Everything this program exports carries the `jrpg_` prefix. `static` helpers
* drop it, because the prefix exists only to avoid collisions with libakgl,
* SDL and libc -- the same rule, for the same reason.
*/
#ifndef _JRPG_JRPG_H_
#define _JRPG_JRPG_H_
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/types.h>
/*
* Where the game's data lives. Both are absolute paths baked in by
* examples/jrpg/CMakeLists.txt, because the tutorial has to run from the build
* tree, from the source tree, and from wherever CTest happens to put its
* working directory. The fallbacks are what a reader compiling this by hand
* from the repository root would want.
*/
#ifndef JRPG_ASSET_DIR
#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg"
#endif
#ifndef JRPG_FONT_FILE
#define JRPG_FONT_FILE "tests/assets/akgl_test_mono.ttf"
#endif
/*
* The screen size is written as text because that is the only form
* akgl_set_property takes, and akgl_render_2d_init copies it onto `akgl_camera`
* on the way past. Everything below reads the camera rather than a second copy
* of the same two numbers.
*/
#define JRPG_SCREEN_WIDTH "320"
#define JRPG_SCREEN_HEIGHT "240"
/** @brief Registry name of the one font this game loads. */
#define JRPG_FONT_NAME "jrpg"
/** @brief Point size the font is rasterized at. A size is baked into the handle. */
#define JRPG_FONT_SIZE 12
/**
* @brief Index of the tile layer this game treats as solid.
*
* An index, not a name, because akgl_TilemapLayer has no `name` member: the
* loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data,
* and drops the name Tiled wrote. A map cannot say "the layer called
* collision", so the game and the map agree on a number. See chapter 20.
*/
#define JRPG_LAYER_SOLID 1
/** @brief Longest line an NPC can say, terminator included. */
#define JRPG_TEXTBOX_MAX_TEXT 256
/** @brief Registry name of the party member this game attaches to the player. */
#define JRPG_FOLLOWER_NAME "companion"
/**
* @brief One entry in the scripted demo the headless smoke run drives.
*
* The keystrokes go in through akgl_controller_handle_event like any other
* event, so the smoke run exercises the same binding, state and physics path a
* player does rather than a separate one written to be testable.
*/
typedef struct {
long frame; /**< Frame number this step fires on. */
SDL_Keycode key; /**< Key to synthesize. */
bool down; /**< True for a press, false for a release. */
} jrpg_ScriptStep;
/**
* @brief Load every sprite, then every character, then the town map.
*
* In that order, and the order is not a preference: akgl_character_load_json
* resolves each sprite name through #AKGL_REGISTRY_SPRITE, and
* akgl_tilemap_load resolves each `character` property through
* #AKGL_REGISTRY_CHARACTER while it spawns the map's actor objects.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
/**
* @brief Fix up the actors the map spawned, and attach the party member.
*
* Clears `movement_controls_face` on everything -- without which the default
* `facefunc` erases the facing bit the map set and every NPC stops being drawn
* on frame one -- installs the blocking movement logic on the player, and
* builds the follower as a child actor.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_REGISTRY If the map spawned no actor named "player".
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
/**
* @brief Centre the camera on the player, clamped to the edges of the map.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_REGISTRY If there is no actor named "player".
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
/**
* @brief The player's `movementlogicfunc`: the library default, plus walls.
*
* libakgl implements no collision at all -- akgl_physics_arcade_collide raises
* AKERR_API, akgl_physics_simulate never calls `collide`, and
* akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall
* writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open,
* which is the documented way to tell the simulator to skip an actor's tick.
*
* @param obj The actor to compute acceleration for. Required, along with its
* `basechar`.
* @param dt Seconds this step covers.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
* @throws AKGL_ERR_LOGICINTERRUPT While a conversation is open.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt);
/**
* @brief The follower's `renderfunc`: akgl_actor_render with the parent detached.
*
* The guard for a defect. akgl_physics_simulate writes a child's `x` as
* `parent->x + vx` -- an absolute world position -- and akgl_actor_render then
* draws it at `parent->x + obj->x`, adding the parent's position a second time.
* Nulling `parent` for the duration of the draw takes the branch that does not
* add it. See chapter 20.
*
* @param obj The child actor to draw. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj);
/**
* @brief Control handler: talk to whoever is standing nearby, or close the box.
* @param obj The actor the control map drives -- the player. Required.
* @param event The event that triggered this. Required, but not read.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event);
/**
* @brief Control handler that does nothing, for the release half of a binding.
*
* akgl_controller_handle_event does not check a matched binding's handler
* pointer before calling it, so a binding with a `NULL` `handler_off` is a
* crash rather than an error. Every binding gets both halves.
*
* @param obj The actor the control map drives. Required.
* @param event The event that triggered this. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event);
/**
* @brief Put a line of dialogue on screen and freeze the world.
* @param text The line to show. Required. Truncated at #JRPG_TEXTBOX_MAX_TEXT.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p text is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text);
/** @brief Dismiss the text box. */
void jrpg_textbox_close(void);
/** @brief True while a line of dialogue is on screen. */
bool jrpg_textbox_showing(void);
/**
* @brief Draw the panel and its text, if there is anything to draw.
*
* Called after akgl_game_update, so it lands on top of the world rather than
* under it, and in screen coordinates rather than world ones --
* akgl_text_rendertextat does not go through the camera, which is what a HUD
* wants.
*
* @return `NULL` on success -- including when the box is closed -- otherwise an
* error context owned by the caller.
* @throws AKGL_ERR_REGISTRY If #JRPG_FONT_NAME is not a loaded font.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void);
#endif // _JRPG_JRPG_H_

125
examples/jrpg/textbox.c Normal file
View File

@@ -0,0 +1,125 @@
/**
* @file textbox.c
* @brief The dialogue panel: two rectangles and a string, drawn over the world.
*
* Text in libakgl is immediate mode. Every akgl_text_rendertextat call
* rasterizes the string through SDL_ttf, uploads it as a texture, blits it and
* destroys the texture again -- there is no cached glyph atlas and no text
* object to hold on to. That is the wrong shape for a page of static prose
* redrawn sixty times a second and exactly the right shape for this: one short
* line, on screen only while somebody is talking.
*/
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/text.h>
#include "jrpg.h"
/*
* Colour R G B A
*/
static const SDL_Color TEXTBOX_FILL = { 24, 20, 37, 235 };
static const SDL_Color TEXTBOX_EDGE = { 240, 236, 214, 255 };
static const SDL_Color TEXTBOX_INK = { 240, 236, 214, 255 };
/** @brief Pixels between the panel and the edges of the screen. */
#define TEXTBOX_MARGIN 8.0f
/** @brief Panel height in pixels. Two lines of 12pt monospace, plus padding. */
#define TEXTBOX_HEIGHT 56.0f
/** @brief Pixels between the panel's edge and the text inside it. */
#define TEXTBOX_PADDING 8.0f
static char textbox_text[JRPG_TEXTBOX_MAX_TEXT];
static bool textbox_visible = false;
bool jrpg_textbox_showing(void)
{
return textbox_visible;
}
void jrpg_textbox_close(void)
{
textbox_visible = false;
}
akerr_ErrorContext *jrpg_textbox_open(char *text)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
// aksl_strncpy, not strncpy: this is a fixed-width field, and strncpy at
// exactly the field width leaves it unterminated. Bounded at one less than
// the field so an over-long line truncates rather than being refused.
PASS(errctx,
aksl_strncpy(
textbox_text,
sizeof(textbox_text),
text,
sizeof(textbox_text) - 1
));
textbox_visible = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_textbox_draw(void)
{
PREPARE_ERROR(errctx);
TTF_Font *font = NULL;
SDL_FRect panel;
if ( textbox_visible == false ) {
SUCCEED_RETURN(errctx);
}
// akgl_text_rendertextat refuses the empty string -- SDL_ttf reports "Text
// has zero width" and the library passes that on as AKERR_NULLPOINTER,
// while akgl_text_measure accepts it. The two disagree, so anything drawing
// a line that might be empty checks for it. TODO.md, "Known and still
// open".
if ( textbox_text[0] == '\0' ) {
SUCCEED_RETURN(errctx);
}
// Fonts live in a registry under a name, not in an akgl type. There is no
// handle to keep, and no reference counting either: whoever calls
// akgl_text_unloadfont invalidates every pointer anybody else fetched.
// Fetching it per frame is the cheap way to stay honest about that.
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
FAIL_ZERO_RETURN(
errctx,
font,
AKGL_ERR_REGISTRY,
"No font registered as \"%s\"",
JRPG_FONT_NAME
);
// Screen coordinates, from the camera's size rather than from a second copy
// of the window dimensions. akgl_render_2d_init put them there.
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));
PASS(errctx,
akgl_text_rendertextat(
font,
textbox_text,
TEXTBOX_INK,
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
(int)(panel.x + TEXTBOX_PADDING),
(int)(panel.y + TEXTBOX_PADDING)
));
SUCCEED_RETURN(errctx);
}

458
examples/jrpg/world.c Normal file
View File

@@ -0,0 +1,458 @@
/**
* @file world.c
* @brief The content pipeline: sprites, characters, the town map, and what the
* map's actors do once they exist.
*
* Almost nothing here is game logic. It is the four steps between a directory
* of JSON and a world with people standing in it, in the one order that works,
* plus the two hooks -- a `movementlogicfunc` and a `renderfunc` -- that a game
* has to supply because the library does not.
*/
#include <limits.h>
#include <math.h>
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/sprite.h>
#include <akgl/tilemap.h>
#include "jrpg.h"
/*
* The three character templates and the twenty-four sprites that dress them.
* The file names are a product of these three tables rather than a list of
* twenty-four strings, because that is what the naming convention *is*: one
* sprite per character, per motion, per facing. A missing combination shows up
* as a load failure naming the file, not as art that silently never appears.
*/
/* Character template Sprite name prefix */
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" };
#define CAST_COUNT (sizeof(CAST) / sizeof(CAST[0]))
#define MOTION_COUNT (sizeof(MOTIONS) / sizeof(MOTIONS[0]))
#define FACING_COUNT (sizeof(FACINGS) / sizeof(FACINGS[0]))
/** @brief One person in the town who has something to say. */
typedef struct {
char *actor; /**< Registry key, which is the `name` of the map object that spawned them. */
char *line; /**< What they say. */
} jrpg_Townsfolk;
/*
* Actor (the map object's name) What they say
*/
static jrpg_Townsfolk TOWNSFOLK[] = {
{ "elder", "ELDER: The old road north is closed. Nothing to be done about it,\nand nothing beyond it worth the walk." },
{ "shopkeeper", "SHOPKEEPER: Nothing in stock but tiles and good intentions.\nCome back when I have inventory." }
};
#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
/** @brief How close the player has to stand, in pixels between sprite centres. */
#define TALK_RANGE 64.0f
/*
* The player's footprint, in pixels from the top-left of a 32x32 frame. A
* character stands on the bottom third of their sprite, so that is the part
* that has to fit through a doorway -- testing the whole frame would make every
* corridor two tiles wide.
*
* 0 32
* +------------------------------+ 0
* | |
* | (art) |
* | |
* | +----------------+ | 20 FEET_TOP
* | | footprint | |
* +------+----------------+------+ 30 FEET_TOP + FEET_H
* 6 26
* FEET_X FEET_X + FEET_W
*/
#define FEET_X 6.0f
#define FEET_W 20.0f
#define FEET_TOP 20.0f
#define FEET_H 10.0f
/** @brief Where the party member walks, in pixels relative to the player. */
#define FOLLOWER_OFFSET_X (-14.0f)
#define FOLLOWER_OFFSET_Y (10.0f)
/**
* @brief Load one sprite definition by its three naming components.
*
* @param cast Character stem, e.g. `"player"`.
* @param motion `"idle"` or `"walk"`.
* @param facing `"up"`, `"down"`, `"left"` or `"right"`.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *sprite_load(char *cast, char *motion, char *facing)
{
PREPARE_ERROR(errctx);
char path[PATH_MAX];
int written = 0;
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
FAIL_ZERO_RETURN(errctx, motion, AKERR_NULLPOINTER, "motion");
FAIL_ZERO_RETURN(errctx, facing, AKERR_NULLPOINTER, "facing");
PASS(errctx,
aksl_snprintf(
&written,
path,
sizeof(path),
"%s/sprite_jrpg_%s_%s_%s.json",
JRPG_ASSET_DIR,
cast,
motion,
facing
));
PASS(errctx, akgl_sprite_load_json(path));
SUCCEED_RETURN(errctx);
}
/**
* @brief Load one character definition by its stem.
* @param cast Character stem, e.g. `"player"`.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *character_load(char *cast)
{
PREPARE_ERROR(errctx);
char path[PATH_MAX];
int written = 0;
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
PASS(errctx,
aksl_snprintf(
&written,
path,
sizeof(path),
"%s/character_jrpg_%s.json",
JRPG_ASSET_DIR,
cast
));
PASS(errctx, akgl_character_load_json(path));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_world_load(void)
{
PREPARE_ERROR(errctx);
char path[PATH_MAX];
int written = 0;
size_t c = 0;
size_t m = 0;
size_t f = 0;
// Sprites first. A character JSON names its sprites by registry key and
// akgl_character_load_json refuses one it cannot find, so loading the two
// the other way round fails on every mapping in the file.
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]));
}
// And the map last: loading it creates an actor for every `actor` object in
// its object layer, and each of those resolves a character by name.
PASS(errctx,
aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR));
PASS(errctx, akgl_tilemap_load(path, akgl_gamemap));
// The map declares its own physics -- `physics.model` and zero gravity on
// both axes -- and akgl_tilemap_load builds a backend for it and sets
// use_own_physics. It does not *switch* to it: akgl_game_update simulates
// through the global akgl_physics, whatever that points at. Honouring the
// map is the caller's job, and this is it.
if ( akgl_gamemap->use_own_physics ) {
akgl_physics = &akgl_gamemap->physics;
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_world_populate(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *player = NULL;
akgl_Actor *follower = NULL;
int i = 0;
// Every actor the map spawned, including the player.
//
// akgl_actor_initialize sets movement_controls_face, and the default
// facefunc it installs clears every facing bit and then sets the one
// matching a *movement* bit. An NPC has no movement bits, so on the first
// frame its state falls from ALIVE|FACE_DOWN (17) to ALIVE (16), which is
// a state combination no character JSON maps a sprite to -- and an actor
// with no sprite for its state is skipped rather than reported. Every NPC
// in the town disappears on frame one, silently, and so does the player as
// soon as they stop walking.
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;
}
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
FAIL_ZERO_RETURN(
errctx,
player,
AKGL_ERR_REGISTRY,
"town.tmj spawned no actor named \"player\""
);
player->movementlogicfunc = &jrpg_actor_logic_walk;
// The party member. Nothing in the map creates it, and nothing has to: an
// actor is a pool object with a name, and the character it instantiates is
// already registered. This one borrows the elder's template, which is the
// whole point of splitting instance from template.
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));
// A child's velocity fields are read as a fixed offset from the parent
// rather than as a velocity: akgl_physics_simulate snaps a child to
// `parent->x + vx` and never simulates it. Set after addchild, because
// addchild is what makes them mean an offset.
follower->vx = FOLLOWER_OFFSET_X;
follower->vy = FOLLOWER_OFFSET_Y;
SUCCEED_RETURN(errctx);
}
/**
* @brief Is the map cell containing this pixel one the player cannot enter?
*
* Two rules, and both of them are the game's rather than the library's. The
* outer ring of the map is solid, because nothing else stops an actor walking
* off the edge of the world. Everything else is the decoration layer: a cell
* with a tile in it is a building, a tree or a lamp post, and a cell with 0 in
* it is open ground.
*
* @param px Position in map pixels along x.
* @param py Position in map pixels along y.
* @return True when the cell blocks movement.
*/
static bool cell_solid(float32_t px, float32_t py)
{
int tx = 0;
int ty = 0;
tx = (int)(px / (float32_t)akgl_gamemap->tilewidth);
ty = (int)(py / (float32_t)akgl_gamemap->tileheight);
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);
}
/**
* @brief Would an actor whose frame is at (x, y) have its feet in a wall?
*
* Tests the four corners of the footprint. Four corners is enough only because
* the footprint is smaller than a tile in both directions; a bigger one would
* step over a single-cell wall between two of its corners.
*
* @param x Candidate frame position along x, in map pixels.
* @param y Candidate frame position along y, in map pixels.
* @return True when any corner of the footprint lands in a solid cell.
*/
static bool feet_blocked(float32_t x, float32_t y)
{
float32_t left = x + FEET_X;
float32_t right = x + FEET_X + FEET_W;
float32_t top = y + FEET_TOP;
float32_t bottom = y + FEET_TOP + FEET_H;
return (cell_solid(left, top) ||
cell_solid(right, top) ||
cell_solid(left, bottom) ||
cell_solid(right, bottom));
}
akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "obj->basechar");
// A conversation freezes the world. AKGL_ERR_LOGICINTERRUPT raised from a
// movementlogicfunc means "skip the rest of this tick for this actor", and
// akgl_physics_simulate swallows it in a HANDLE block -- so gravity, drag,
// the velocity recompute and the move are all skipped and the frame carries
// on. Thrust has already been integrated by the time this runs, so it is
// zeroed here; leaving it would let it accumulate while frozen and lurch on
// the first step after the box closes. The movement bits go too, so the
// walk animation stops rather than marching on the spot -- which does mean
// a direction held across the close has to be pressed again.
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
);
}
// Everything the default hook does -- re-copy the character's speed limits,
// turn the movement bits into signed acceleration -- is still wanted. This
// hook adds to it rather than replacing it.
PASS(errctx, akgl_actor_logic_movement(obj, dt));
// Where this step would put us. akgl_physics_simulate has already
// integrated thrust for this step and capped it against the character's
// speed ellipse, and it computes velocity as `e + t` -- and with the town's
// zero gravity and zero drag, `e` stays zero, so `v` is `t`. That is what
// makes the prediction exact rather than approximate. A map with gravity
// would have to account for `ey` here as well.
//
// Axis at a time, so walking into a wall diagonally slides along it rather
// than stopping dead.
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;
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_follower_render(akgl_Actor *obj)
{
PREPARE_ERROR(errctx);
akgl_Actor *parent = NULL;
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
// The guard. akgl_physics_simulate has already written this actor's x and y
// as `parent->x + vx` and `parent->y + vy`, which is an absolute world
// position; akgl_actor_render adds the parent's position to it a second
// time. Detaching the parent for the duration of the draw takes the branch
// that does not. CLEANUP puts it back on every path, including the failing
// one -- an actor left with a NULL parent would be simulated as a free
// agent on the very next step.
parent = obj->parent;
obj->parent = NULL;
ATTEMPT {
CATCH(errctx, akgl_actor_render(obj));
} CLEANUP {
obj->parent = parent;
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_camera_follow(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *player = NULL;
float32_t mapw = 0.0f;
float32_t maph = 0.0f;
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
FAIL_ZERO_RETURN(errctx, player, AKGL_ERR_REGISTRY, "No actor named \"player\"");
mapw = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth);
maph = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight);
akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f);
akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f);
if ( akgl_camera->x < 0.0f ) {
akgl_camera->x = 0.0f;
}
if ( akgl_camera->y < 0.0f ) {
akgl_camera->y = 0.0f;
}
if ( akgl_camera->x > (mapw - akgl_camera->w) ) {
akgl_camera->x = mapw - akgl_camera->w;
}
if ( akgl_camera->y > (maph - akgl_camera->h) ) {
akgl_camera->y = maph - akgl_camera->h;
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
akgl_Actor *npc = NULL;
size_t i = 0;
float32_t dx = 0.0f;
float32_t dy = 0.0f;
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
if ( jrpg_textbox_showing() ) {
jrpg_textbox_close();
SUCCEED_RETURN(errctx);
}
for ( i = 0; i < TOWNSFOLK_COUNT; i++ ) {
npc = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, TOWNSFOLK[i].actor, NULL);
if ( npc == NULL ) {
continue;
}
dx = npc->x - obj->x;
dy = npc->y - obj->y;
if ( sqrtf((dx * dx) + (dy * dy)) > TALK_RANGE ) {
continue;
}
PASS(errctx, jrpg_textbox_open(TOWNSFOLK[i].line));
SUCCEED_RETURN(errctx);
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_cmhf_ignore(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");
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,48 @@
# The sidescroller tutorial game.
#
# A real target, built by default, so docs/19-tutorial-sidescroller.md cannot
# quote a program that does not compile -- and registered as a headless smoke
# test, so it cannot quote one that does not run.
get_filename_component(SS_ASSET_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../../docs/tutorials/assets/sidescroller" ABSOLUTE)
add_executable(sidescroller
main.c
collision.c
player.c
actors.c
)
target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_compile_options(sidescroller PRIVATE ${AKGL_WARNING_FLAGS})
# Baked in rather than looked up at runtime, so the smoke test can be launched
# from any working directory. `--assets DIR` overrides it for a reader who has
# moved the art somewhere else.
target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"")
target_link_libraries(sidescroller
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf
SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
# A vendored build leaves the SDL satellite libraries in per-project
# subdirectories that are not on the loader's default search path, which is the
# same problem AKGL_VENDORED_RPATH solves for the test executables.
if(AKGL_VENDORED_DEPENDENCIES)
set_target_properties(sidescroller PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
endif()
# Four seconds of scripted play under the headless drivers: the level loads, the
# player walks and jumps, the swept collision runs against real terrain, and the
# program tears down and exits 0. A tutorial that stops working fails here rather
# than in front of a reader.
#
# add_test() rather than _add_test(): the root CMakeLists.txt shadows it only
# while this project is top-level, and by the time examples/ is added the
# suppression has already been lifted. An embedded build gets the builtin.
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"
)

View File

@@ -0,0 +1,183 @@
/**
* @file actors.c
* @brief Everything level1.tmj places that is not the player.
*
* None of these are created here. The map's object layer creates all seven
* actors, registers them under the names Tiled gave them, and binds each one to
* the character its `character` property names -- so this file's whole job is to
* find them again by name and give them behaviour.
*
* Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is 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 for
* that actor this step, and akgl_physics_simulate carries on to the next one.
* That is exactly what an actor that has just placed itself wants.
*/
#include <math.h>
#include <akstdlib.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include "sidescroller.h"
/** @brief The blob's collision box, offset into its 32x32 frame. */
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
/**
* @brief Per-actor data for everything in this file, one slot per actor.
*
* A fixed table rather than an allocation, for the same reason the library 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.
*/
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
/**
* @brief Look an actor up by the name its Tiled object carried.
*
* A miss here means the map and the code disagree about what the level
* contains, which is worth failing on rather than working around.
*/
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
SUCCEED_RETURN(errctx);
}
/**
* @brief A `movementlogicfunc` for something that does not move at all.
*
* The coins need this. Their character declares no speed and no acceleration, so
* they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity`
* accumulates into `ey` for every actor it is handed, so a coin left to the
* default logic falls out of the level along with everything else.
*
* Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its
* step. It is not an error and nothing logs it.
*/
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);
}
/**
* @brief The blob: walk, and turn around at a wall or a ledge.
*
* This one stays inside the physics step -- it walks on the ground under the
* map's gravity like the player does, and uses the same swept resolution.
*/
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
{
ss_ActorData *data = NULL;
ss_Contact contact;
float32_t probe_x = 0.0f;
float32_t probe_y = 0.0f;
bool floor_ahead = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
data = (ss_ActorData *)obj->actorData;
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
if ( data->facing < 0.0f ) {
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
} else {
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
}
/* Signs the acceleration from the movement bits just set. */
PASS(errctx, akgl_actor_logic_movement(obj, dt));
PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
/* One pixel past the leading edge of the box and one pixel below its feet:
* no floor there means the next step walks off. */
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
if ( data->facing < 0.0f ) {
probe_x = obj->x + ss_blob_body.x - 1.0f;
} else {
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
}
PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
data->facing = -data->facing;
/* The resolution already zeroed `tx` on a blocked axis; zero it on the
* ledge path too, or the blob keeps its old momentum through the turn. */
obj->tx = 0.0f;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief The moth: a figure-eight around where the map put it.
*
* It flies, so it wants no gravity -- and the map's physics has gravity, because
* the player needs it. Rather than fighting the backend the moth writes its own
* position and then opts out of the rest of the step, which is the second thing
* #AKGL_ERR_LOGICINTERRUPT is for.
*/
static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt)
{
ss_ActorData *data = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
data = (ss_ActorData *)obj->actorData;
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);
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL);
if ( cosf(data->phase) < 0.0f ) {
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT);
} else {
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT);
}
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
}
akerr_ErrorContext *ss_actors_bind(void)
{
char name[32];
int count = 0;
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
}
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
ss_hazard_data[0].facing = -1.0f;
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
ss_hazard_data[1].facing = 1.0f;
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,383 @@
/**
* @file collision.c
* @brief The collision libakgl does not have.
*
* This file exists because of a gap, and the gap is worth stating exactly:
*
* - `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 the only
* hook that runs inside the physics step is the actor's own `movementlogicfunc`.
* That is where this game's collision lives, and everything here is called from
* there.
*
* The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
* drag, the velocity recomputation and `move`, so it cannot look at where the
* actor ended up -- the step has not happened yet. ss_collide_predict therefore
* repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
* resolves against that predicted position. Get the prediction wrong and the
* actor is resolved against a step it never takes.
*/
#include <math.h>
#include <akstdlib.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include "sidescroller.h"
/**
* @brief Half a pixel-thousandth, taken off the far edge of a box before it is
* turned into tile indices.
*
* A box whose right edge sits exactly on a tile boundary does not overlap the
* tile on the far side of it. Without this, an actor standing flush against a
* wall reads as inside it, and the sweep snaps it back a tile every frame.
*/
#define SS_COLLIDE_EPSILON 0.001f
/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
#define SS_COLLIDE_SETTLE_TILES 4
static akgl_TilemapLayer *ss_terrain = NULL;
/**
* @brief Is this map cell solid?
*
* Off the left or right edge of the map is solid, so the level has walls at its
* ends. Off the top or the bottom is not: the sky is open and the pit in the
* middle of level1.tmj has to be fallable-into, which is the whole point of it.
*/
static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
*dest = true;
} else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
*dest = false;
} else {
/* A tile layer's data is global tile ids in row-major order, and 0 is
* the empty cell. Any tile at all on the terrain layer is solid: the
* level says what is solid by which layer the tile is drawn on. */
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
{
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
ss_terrain = NULL;
for ( i = 0; i < map->numlayers; i++ ) {
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
ss_terrain = &map->layers[i];
}
}
FAIL_ZERO_RETURN(
errctx,
ss_terrain,
AKERR_KEY,
"Map has no tile layer with id %d to collide against",
SS_TERRAIN_LAYER_ID
);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
{
int x0 = 0;
int x1 = 0;
int y0 = 0;
int y1 = 0;
int tilex = 0;
int tiley = 0;
bool solid = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_NONZERO_RETURN(
errctx,
((box->w <= 0.0f) || (box->h <= 0.0f)),
AKERR_VALUE,
"Collision box is %fx%f; both sides must be positive",
box->w,
box->h
);
*dest = false;
x0 = (int)floorf(box->x / SS_TILE_SIZE);
x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
y0 = (int)floorf(box->y / SS_TILE_SIZE);
y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
for ( tiley = y0; tiley <= y1; tiley++ ) {
for ( tilex = x0; tilex <= x1; tilex++ ) {
PASS(errctx, solid_tile(tilex, tiley, &solid));
if ( solid == true ) {
*dest = true;
SUCCEED_RETURN(errctx);
}
}
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
{
float32_t ex = 0.0f;
float32_t ey = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
/*
* Everything below is akgl_physics_simulate's own arithmetic, in its own
* order: gravity onto the environmental term, then drag off it, then
* velocity as environmental plus thrust, then position plus velocity times
* dt. The `!= 0` guards are the library's too -- it skips each axis whose
* constant is zero rather than multiplying by it -- and are repeated here
* because dropping them would make a zero-gravity axis pick up drag.
*
* This mirrors the *arcade* backend. Against the null backend gravity does
* nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
* right.
*/
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;
SUCCEED_RETURN(errctx);
}
/**
* @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
*
* Each axis is tested on its own, which is what lets an actor slide along a wall
* instead of sticking to it, and the sub-step is capped at half a tile so a
* fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
* the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
* step, which is nearly two tiles -- so this is not a theoretical concern.
*
* On a blocked axis the box is snapped to the tile boundary it was about to
* cross rather than simply not moved, so an actor lands flush on a floor at
* whatever speed it arrives.
*/
static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
{
SDL_FRect trial;
float32_t span = 0.0f;
float32_t stepx = 0.0f;
float32_t stepy = 0.0f;
int steps = 0;
int i = 0;
bool solid = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
span = fabsf(dx);
if ( fabsf(dy) > span ) {
span = fabsf(dy);
}
steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
stepx = dx / (float32_t)steps;
stepy = dy / (float32_t)steps;
for ( i = 0; i < steps; i++ ) {
if ( stepx != 0.0f ) {
trial = *box;
trial.x += stepx;
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
if ( solid == true ) {
if ( stepx > 0.0f ) {
box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
} else {
box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
}
stepx = 0.0f;
dest->blocked_x = true;
} else {
box->x = trial.x;
}
}
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;
}
}
if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
break;
}
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
{
SDL_FRect box;
SDL_FRect probe;
float32_t dx = 0.0f;
float32_t dy = 0.0f;
bool solid = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
dest->blocked_x = false;
dest->blocked_y = false;
dest->grounded = false;
PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
box.x = obj->x + body->x;
box.y = obj->y + body->y;
box.w = body->w;
box.h = body->h;
PASS(errctx, sweep(&box, dx, dy, dest));
/*
* Only a blocked axis is written back. The free axis is left for
* akgl_physics_arcade_move to advance by exactly the amount this function
* predicted -- writing it here as well would move the actor twice.
*
* `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
* effort; a blocked axis has to give up both, or the actor keeps pressing
* into the wall and the next step's prediction is wrong by everything it
* accumulated while stuck.
*
* The environmental term is not set to zero, it is set to *minus one step of
* gravity*, and that is not a nicety. Zeroing it leaves the step about to add
* `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
* quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
* still fatal: the box now overlaps the floor tile, so the *horizontal*
* sweep on the next step finds itself blocked wherever it is, and the actor
* is snapped back a whole tile every time it tries to walk. Pre-loading the
* cancellation leaves the actor resting exactly on the surface instead.
*/
if ( dest->blocked_x == true ) {
obj->x = box.x - body->x;
obj->ex = (float32_t)akgl_physics->gravity_x * dt;
obj->tx = 0.0f;
}
if ( dest->blocked_y == true ) {
obj->y = box.y - body->y;
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
obj->ty = 0.0f;
}
/*
* Standing on a floor is not a state the library records, so it is measured:
* one pixel below where the box will be at the end of this step. Note that
* zeroing `ey` above does not stop gravity -- the step still adds
* `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
* fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
* it straight back off, so a standing actor rests within a pixel of the
* surface forever rather than sinking through it.
*/
probe = box;
probe.y += 1.0f;
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
dest->grounded = solid;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
{
SDL_FRect box;
bool solid = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
/*
* A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
* actor can start out with its box partly inside a step -- level1.tmj puts
* the player at x=32 and a block at tiles (3,11), which is under the right
* half of the player's frame.
*
* The swept resolution cannot fix that. 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 already
* blocked wherever it goes. So a spawn point gets lifted clear once, before
* the first step, a tile at a time.
*/
for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
box.x = obj->x + body->x;
box.y = obj->y + body->y;
box.w = body->w;
box.h = body->h;
PASS(errctx, ss_collide_box_blocked(&box, &solid));
if ( solid == false ) {
SUCCEED_RETURN(errctx);
}
obj->y -= (float32_t)SS_TILE_SIZE;
SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
}
FAIL_RETURN(
errctx,
AKERR_VALUE,
"%s spawned inside more than %d tiles of terrain at %f, %f",
(char *)obj->name,
SS_COLLIDE_SETTLE_TILES,
obj->x,
obj->y
);
}

View File

@@ -0,0 +1,456 @@
/**
* @file main.c
* @brief Startup, the frame loop, and teardown for the sidescroller tutorial.
*
* The order everything happens in is the point of this file. libakgl has one
* startup sequence that works, documented at the top of `include/akgl/game.h`,
* and three of its steps are ones a reader gets wrong the first time:
*
* - the configuration properties have to be set *before* the renderer and the
* physics backend are initialized, because both read them;
* - `akgl_game_init` does **not** choose a physics backend, so the application
* has to;
* - characters name sprites and maps name characters, so the assets load
* sprites, then characters, then the map -- and the map creates the actors.
*/
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include "sidescroller.h"
/** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */
#ifndef SS_ASSET_DIR
#define SS_ASSET_DIR "."
#endif
/** @brief Longest asset path this program will build. */
#define SS_PATH_MAX 1024
ss_Game ss_game;
/**
* @brief The sprites, loaded in this order.
*
* Sprites first and characters second 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 with
* `AKERR_NULLPOINTER` on the first sprite it cannot find.
*/
static char *ss_sprite_files[] = {
"sprite_ss_player_idle_left.json", /* one frame, held */
"sprite_ss_player_idle_right.json",
"sprite_ss_player_run_left.json", /* four frames at 90 ms */
"sprite_ss_player_run_right.json",
"sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */
"sprite_ss_player_jump_right.json",
"sprite_ss_coin.json",
"sprite_ss_hazard_blob.json",
"sprite_ss_hazard_moth.json",
NULL
};
/** @brief The characters. Each one binds state bitmasks to the sprites above. */
static char *ss_character_files[] = {
"character_ss_player.json",
"character_ss_coin.json",
"character_ss_hazard_blob.json",
"character_ss_hazard_moth.json",
NULL
};
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
static int ss_failed = 0;
/**
* @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame.
*
* The default is `akgl_game_lowfps`, and it fires on **every frame** the frame
* rate is under 30 -- including every frame of the first second of the process,
* because `akgl_game.fps` is a completed-second average and reads 0 until the
* first second is up. The hook exists to be replaced; the point of it is that a
* game can shed work rather than log about it. This one has nothing to shed.
*/
static void ss_lowfps(void)
{
}
/**
* @brief Join the asset directory and a file name into @p dest.
*
* `aksl_snprintf` rather than `snprintf`, because a path that does not fit has
* to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it
* reports itself later as a missing file with a name nobody wrote.
*/
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
{
int count = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
SUCCEED_RETURN(errctx);
}
/**
* @brief Bring the library up and open the window.
*
* `akgl_game.name`, `.version` and `.uri` are filled in first because
* `akgl_game_init` refuses to run without all three: the window title, SDL's
* application metadata and the savegame compatibility check are built from them.
*/
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.name,
sizeof(akgl_game.name),
"libakgl sidescroller tutorial",
sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.version,
sizeof(akgl_game.version),
"1.0.0",
sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.uri,
sizeof(akgl_game.uri),
"net.aklabs.libakgl.sidescroller",
sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
akgl_game.lowfpsfunc = &ss_lowfps;
/* Properties before the renderer: akgl_render_2d_init reads both of these
* out of the registry, and an unset one defaults to the string "0", which
* asks SDL for a zero-sized window. */
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
PASS(errctx, akgl_set_property("game.screenheight", "480"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
/*
* libakgl draws in map pixels and has no scale factor of its own -- the only
* scaling it applies is the tilemap's perspective band, which this map does
* not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more
* than that. SDL's logical presentation is the answer: the game renders a
* 480x240 view and SDL scales it up by whole multiples to fill the window.
*/
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()
);
/* akgl_render_2d_init sized the camera from the window. The view is what the
* camera looks through, so it has to say the same thing. */
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = (float32_t)SS_VIEW_WIDTH;
akgl_camera->h = (float32_t)SS_VIEW_HEIGHT;
/*
* akgl_game_init does NOT choose a physics backend. It points akgl_physics
* at akgl_default_physics, which is zeroed storage -- all four of its method
* pointers are NULL -- and never initializes it. There is no
* `physics.engine` property either, whatever physics.h says. Skip this call
* and the first akgl_game_update calls through a NULL `simulate`.
*
* The map replaces this backend below with one of its own. It is still done
* here, because a game that loads a map without physics properties gets a
* working backend rather than a crash.
*/
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
SUCCEED_RETURN(errctx);
}
/**
* @brief Load the sprites, the characters and the level, in that order.
*/
static akerr_ErrorContext *load_level(char *assetdir)
{
char path[SS_PATH_MAX];
akgl_Actor *player = NULL;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
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));
}
/*
* `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static
* storage in the library. That is not an accident and it 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.
*
* Loading the map creates every `actor` object in its object layers, binds
* each to the character its properties name, and publishes it in
* AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first.
*/
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
/*
* The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`,
* so the loader built a backend of its own 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. Honouring it is this
* line, and it is what lets a swimming level and a walking level differ by
* data rather than by code.
*/
if ( akgl_gamemap->use_own_physics == true ) {
akgl_physics = &akgl_gamemap->physics;
SDL_Log(
"Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s",
akgl_physics->gravity_y,
akgl_physics->drag_y,
(akgl_physics->gravity_y / akgl_physics->drag_y));
}
PASS(errctx, ss_collide_bind(akgl_gamemap));
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
PASS(errctx, ss_player_bind(player));
PASS(errctx, ss_actors_bind());
PASS(errctx, ss_player_controls(0, "player"));
/*
* Re-stamp the clock the simulation measures against. Everything above --
* nine sprite files, four characters, a map and its tileset image -- happened
* between the backend being created and the first step, and dt is measured
* from `gravity_time`. The bound on `physics.max_timestep` would catch it,
* at the cost of one visibly slow-motion frame.
*/
akgl_physics->gravity_time = SDL_GetTicksNS();
SUCCEED_RETURN(errctx);
}
/**
* @brief Centre the camera on the player, clamped to the level.
*
* The camera is a plain `SDL_FRect` in map pixels that the library reads; moving
* it is the whole of scrolling. It is floored to a whole pixel because
* `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles
* to show, and a camera that is fractionally different every frame makes the
* tile grid shimmer.
*/
static akerr_ErrorContext *update_camera(void)
{
float32_t limit = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera");
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);
if ( akgl_camera->x > limit ) {
akgl_camera->x = limit;
}
if ( akgl_camera->x < 0.0f ) {
akgl_camera->x = 0.0f;
}
akgl_camera->x = (float32_t)((int)akgl_camera->x);
akgl_camera->y = 0.0f;
SUCCEED_RETURN(errctx);
}
/**
* @brief One frame: events, then the camera, then the library's own tick.
*
* `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
* `frame_start` and `frame_end` here.
*/
static akerr_ErrorContext *frame(bool *running)
{
SDL_Event event;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
while ( SDL_PollEvent(&event) == true ) {
if ( event.type == SDL_EVENT_QUIT ) {
*running = false;
}
/* Every event, unconditionally: one that no control map binds is not an
* error, it is a call that did nothing. */
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
ss_game.frame += 1;
if ( ss_game.autoplay == true ) {
PASS(errctx, ss_player_autoplay(ss_game.frame));
}
PASS(errctx, update_camera());
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
/*
* Note the failure contract: akgl_game_update takes the game state lock and
* every one of its failure paths returns with the lock still held. 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.
* Treat it as terminal, which is what PASS does here.
*/
PASS(errctx, akgl_game_update(NULL));
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *run(int frames)
{
bool running = true;
PREPARE_ERROR(errctx);
while ( running == true ) {
PASS(errctx, frame(&running));
if ( (frames > 0) && (ss_game.frame >= frames) ) {
running = false;
}
/* A crude frame limiter. A game with a window on a real display should
* ask SDL for vsync instead; this one has to work under the dummy video
* driver, where there is nothing to sync to. */
SDL_Delay(16);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Give back what the process is holding.
*
* **There is no `akgl_game_shutdown`.** Teardown is the application's, 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.
*
* The pools are not walked beyond the actors. They are static storage in a
* process that is exiting, and the sprites and characters are still referenced
* by each other; a game that loads a second level has to unwind that properly,
* and this one does not pretend to.
*/
static void shutdown_game(void)
{
int i = 0;
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]));
}
}
/* Guarded: this runs from CLEANUP, which is reached even when startup failed
* before akgl_game_init pointed akgl_gamemap at anything. */
if ( akgl_gamemap != NULL ) {
IGNORE(akgl_tilemap_release(akgl_gamemap));
}
TTF_Quit();
MIX_Quit();
SDL_Quit();
}
static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames)
{
const char *env = NULL;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES");
if ( env != NULL ) {
PASS(errctx, aksl_atoi((char *)env, frames));
}
for ( i = 1; i < argc; i++ ) {
if ( strcmp(argv[i], "--autoplay") == 0 ) {
ss_game.autoplay = true;
} else if ( strcmp(argv[i], "--frames") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
PASS(errctx, aksl_atoi(argv[i], frames));
} else if ( strcmp(argv[i], "--assets") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
*assetdir = argv[i];
} else {
FAIL_RETURN(
errctx,
AKERR_VALUE,
"usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
);
}
}
SUCCEED_RETURN(errctx);
}
int main(int argc, char *argv[])
{
char *assetdir = SS_ASSET_DIR;
int frames = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, parse_args(argc, argv, &assetdir, &frames));
CATCH(errctx, startup());
CATCH(errctx, load_level(assetdir));
CATCH(errctx, run(frames));
} CLEANUP {
shutdown_game();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
/*
* Set a flag rather than returning: leaving a HANDLE block early skips
* FINISH's RELEASE_ERROR and leaks the context's pool slot, and the
* 129th such call aborts the process. AGENTS.md spells it out.
*/
ss_failed = 1;
/*
* FINISH_NORETURN rather than FINISH: FINISH expands a
* `return __err_context` that an int-returning function cannot compile,
* even on a branch that cannot be reached.
*/
} FINISH_NORETURN(errctx);
SDL_Log(
"sidescroller: %d frames, %d of %d coins, %d deaths",
ss_game.frame,
ss_game.coins_taken,
SS_COIN_COUNT,
ss_game.deaths);
return ss_failed;
}

View File

@@ -0,0 +1,436 @@
/**
* @file player.c
* @brief The player's two hooks and its control bindings.
*
* Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the
* split between them is the frame's own order. `akgl_game_update` calls every
* actor's `updatefunc`, then steps the physics, then draws -- so per-frame game
* logic that is not movement (picking a coin up, falling in the pit) goes in
* `updatefunc`, and anything that has to happen inside the physics step goes in
* `movementlogicfunc`, which is the only hook the step calls.
*
* Two of the library's documented gaps are worked around here rather than
* papered over. Both are in `TODO.md` under "Arcade physics feel".
*/
#include <math.h>
#include <akstdlib.h>
#include <akgl/controller.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/util.h>
#include "sidescroller.h"
/** @brief The player's collision box, as an offset into its 32x32 sprite frame. */
static SDL_FRect ss_player_body = {
.x = SS_PLAYER_BOX_X,
.y = SS_PLAYER_BOX_Y,
.w = SS_PLAYER_BOX_W,
.h = SS_PLAYER_BOX_H
};
/** @brief Where the map put the player, so a death can put it back. */
static ss_ActorData ss_player_data;
/**
* @brief The rectangle an actor is tested against, given a 32x32 sprite frame.
*
* Smaller than the frame on purpose. Sprite art does not reach the edges, and a
* hazard box the full size of the frame kills a player who is visibly nowhere
* near it.
*/
static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
dest->x = obj->x + inset;
dest->y = obj->y + inset;
dest->w = 32.0f - (inset * 2.0f);
dest->h = 32.0f - (inset * 2.0f);
SUCCEED_RETURN(errctx);
}
/**
* @brief Put the player back where the map placed it, at rest.
*
* Everything the simulation carries between steps has to be cleared, not just
* the position: `ey` is where gravity has been accumulating, and a player who
* respawns still holding a full-speed fall lands dead again immediately.
*/
static akerr_ErrorContext *respawn(akgl_Actor *obj)
{
ss_ActorData *data = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
data = (ss_ActorData *)obj->actorData;
obj->x = data->home_x;
obj->y = data->home_y;
obj->ex = 0.0f;
obj->ey = 0.0f;
obj->tx = 0.0f;
obj->ty = 0.0f;
obj->vx = 0.0f;
obj->vy = 0.0f;
ss_game.deaths += 1;
SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y);
SUCCEED_RETURN(errctx);
}
/**
* @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
*
* Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
* drag, the velocity recomputation and `move`.
*/
static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
{
ss_Contact contact;
float32_t friction = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
/* The default logic still has to run: it is what copies the character's
* speeds onto the actor and signs its acceleration by the movement bits. */
PASS(errctx, akgl_actor_logic_movement(obj, dt));
/*
* Workaround 1: there is no friction anywhere in the arcade backend.
*
* akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction
* stops the actor dead inside one frame -- correct for a top-down Zelda,
* wrong for a sidescroller. This game binds its own `_off` handlers that
* leave `tx` alone (see below) and decays it here instead, faster on the
* ground than in the air. Below a pixel per second it is snapped to zero,
* because an exponential decay never actually arrives.
*/
if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) &&
AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
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;
}
}
/*
* A jump is an impulse straight into the environmental term, because `ey` is
* the axis gravity accumulates on and the two have to cancel for the arc to
* come back down. Thrust would not: `ty` is capped against the character's
* `speed_y`, which is 0 for this character, so a jump written as thrust is
* scaled to nothing by the ellipse cap in akgl_physics_simulate.
*
* `ss_game.grounded` is last step's verdict, one frame stale. That is the
* ordering again -- this hook runs before the step it is deciding about --
* and one frame of coyote time is not a thing a player can feel.
*/
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
obj->ey = -SS_JUMP_SPEED;
}
ss_game.jump_requested = false;
PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact));
ss_game.grounded = contact.grounded;
/*
* The character maps MOVING_UP to the jump sprites, so the state word says
* "in the air" whether the actor is rising or falling. Nothing else reads
* the bit here: `speed_y` and `acceleration_y` are both 0 in
* character_ss_player.json, so the thrust it would authorise is capped to
* nothing.
*/
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);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief The player's `updatefunc`: the animation, then what it is touching.
*
* Runs in `akgl_game_update`'s actor sweep, before the physics step and before
* anything is drawn.
*/
static akerr_ErrorContext *ss_player_update(akgl_Actor *obj)
{
SDL_FRect player;
SDL_FRect other;
bool hit = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
/* Facing, then the animation frame. Replacing a hook does not mean
* reimplementing it. */
PASS(errctx, akgl_actor_update(obj));
/* Off the bottom of the world. Nothing in the library stops an actor
* leaving the map -- akgl_physics_arcade_move does not clamp -- so falling
* out of the level is a thing the game has to notice for itself. */
if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) {
PASS(errctx, respawn(obj));
SUCCEED_RETURN(errctx);
}
PASS(errctx, hitbox(obj, 8.0f, &player));
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
if ( ss_game.coins[i] == NULL ) {
continue;
}
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
if ( hit == true ) {
/*
* Giving the pool slot back is what unregisters the actor and stops
* it being drawn; there is no "despawn" call. Releasing another
* actor from inside this sweep is safe -- akgl_game_update re-reads
* `refcount` at the top of every iteration and skips a slot that has
* gone free.
*/
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
ss_game.coins[i] = NULL;
ss_game.coins_taken += 1;
SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT);
}
}
for ( i = 0; i < SS_HAZARD_COUNT; i++ ) {
if ( ss_game.hazards[i] == NULL ) {
continue;
}
PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other));
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
if ( hit == true ) {
PASS(errctx, respawn(obj));
SUCCEED_RETURN(errctx);
}
}
SUCCEED_RETURN(errctx);
}
/*
* Workaround 2, the release half.
*
* akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax`
* *and* zero `tx`. That last one is the "stops dead" behaviour; these two do
* everything else it does and leave `tx` for ss_player_movement to decay.
*/
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);
}
static akerr_ErrorContext *ss_control_right_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_RIGHT);
SUCCEED_RETURN(errctx);
}
/** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */
static akerr_ErrorContext *ss_control_jump_on(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");
ss_game.jump_requested = true;
SUCCEED_RETURN(errctx);
}
/**
* @brief Cut a rising jump short when the button is let go.
*
* Variable jump height for four lines, and it works because `ey` is an ordinary
* field that nothing else owns between steps.
*/
static akerr_ErrorContext *ss_control_jump_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");
if ( obj->ey < 0.0f ) {
obj->ey *= 0.4f;
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_player_bind(akgl_Actor *obj)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
ss_game.player = obj;
/* Lift the spawn point clear of the step it overlaps *before* recording it,
* so a respawn does not put the player back inside the geometry. */
PASS(errctx, ss_collide_settle(obj, &ss_player_body));
ss_player_data.home_x = obj->x;
ss_player_data.home_y = obj->y;
obj->actorData = (void *)&ss_player_data;
/*
* The default `facefunc` 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 character_ss_player.json has no sprite for
* that. The actor becomes invisible while standing still.
*
* Clearing this is the documented way out: akgl_actor_automatic_face leaves
* an actor alone entirely when it is clear, and the facing bits stay
* wherever the control handlers last put them.
*/
obj->movement_controls_face = false;
/* Replace the hooks after akgl_actor_initialize, never before -- it
* overwrites all six. The tilemap loader has already run it here. */
obj->movementlogicfunc = &ss_player_movement;
obj->updatefunc = &ss_player_update;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
{
akgl_ControlMap *controlmap = NULL;
akgl_Control control;
SDL_KeyboardID *keyboards = NULL;
SDL_JoystickID *gamepads = NULL;
int count = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname");
/* akgl_controller_pushmap checks the upper bound and not the lower one, so
* a negative id indexes before the start of akgl_controlmaps. TODO.md,
* "Known and still open" item 11. */
FAIL_NONZERO_RETURN(
errctx,
((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)),
AKERR_OUTOFBOUNDS,
"Control map id %d is outside 0..%d",
controlmapid,
(AKGL_MAX_CONTROL_MAPS - 1)
);
PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
controlmap = &akgl_controlmaps[controlmapid];
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
FAIL_ZERO_RETURN(
errctx,
controlmap->target,
AKGL_ERR_REGISTRY,
"Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded",
actorname
);
/*
* A control map listens to exactly one keyboard and one gamepad, matched by
* id, and a binding whose id does not match the event's is not consulted.
* That is what keeps two local players on two keyboards apart -- and it is
* also why "the arrow keys do nothing" is usually the wrong id rather than
* the wrong key.
*/
keyboards = SDL_GetKeyboards(&count);
if ( keyboards != NULL ) {
if ( count > 0 ) {
controlmap->kbid = keyboards[0];
}
SDL_free(keyboards);
}
gamepads = SDL_GetGamepads(&count);
if ( gamepads != NULL ) {
if ( count > 0 ) {
controlmap->jsid = gamepads[0];
}
SDL_free(gamepads);
}
/* ---- keyboard ---- */
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_LEFT;
control.handler_on = &akgl_actor_cmhf_left_on;
control.handler_off = &ss_control_left_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
control.key = SDLK_RIGHT;
control.handler_on = &akgl_actor_cmhf_right_on;
control.handler_off = &ss_control_right_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
control.key = SDLK_SPACE;
control.handler_on = &ss_control_jump_on;
control.handler_off = &ss_control_jump_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
/* ---- gamepad ---- */
/* Clear the keycode first: a keyboard event is matched on `key` whatever
* else the binding carries, and 0 is a keycode like any other. */
control.key = 0;
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
control.handler_on = &akgl_actor_cmhf_left_on;
control.handler_off = &ss_control_left_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT;
control.handler_on = &akgl_actor_cmhf_right_on;
control.handler_off = &ss_control_right_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
control.button = SDL_GAMEPAD_BUTTON_SOUTH;
control.handler_on = &ss_control_jump_on;
control.handler_off = &ss_control_jump_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *ss_player_autoplay(int frame)
{
SDL_Event synthetic;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event)));
synthetic.type = SDL_EVENT_KEY_DOWN;
/*
* The headless smoke run has no keyboard, so it calls the handlers a
* keyboard would have called. They take the actor and an event, and the
* keyboard handlers do not read the event beyond requiring one -- which is
* what makes a scripted run possible at all.
*/
if ( frame == 1 ) {
PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
}
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
ss_game.jump_requested = true;
}
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,135 @@
/**
* @file sidescroller.h
* @brief Shared declarations for the sidescroller tutorial game.
*
* The game is four translation units and this is the seam between them:
*
* main.c startup, asset loading, the frame loop, teardown
* collision.c the tile queries and the swept resolution libakgl does not have
* player.c the player's hooks and its control bindings
* actors.c the coins, the patrolling blob, the flying moth
*
* Nothing here is prefixed `akgl_`. That prefix belongs to the library; a game
* built on it takes its own, and this one is `ss_`.
*/
#ifndef _SIDESCROLLER_H_
#define _SIDESCROLLER_H_
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/tilemap.h>
#include <akgl/types.h>
/*
* The level's geometry, in the units the map is authored in. level1.tmj is 40x15
* tiles of 16 pixels, so the world is 640x240 pixels and the view is a 480x240
* window onto it that scrolls horizontally.
*/
#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */
#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */
#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
/*
* Which layer of the map is solid.
*
* akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the
* layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`,
* `y` and `type`, and nothing else. So a game cannot ask for "the layer called
* terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2.
*/
#define SS_TERRAIN_LAYER_ID 2
/* The player's collision box, as an offset into its 32x32 sprite frame. The art
* does not fill the frame edge to edge, and a box the full width of the frame
* catches on doorways the character visibly clears. */
#define SS_PLAYER_BOX_X 8.0f
#define SS_PLAYER_BOX_Y 0.0f
#define SS_PLAYER_BOX_W 16.0f
#define SS_PLAYER_BOX_H 32.0f
/* Upward velocity a jump installs directly into the actor's environmental term,
* in pixels per second. Under the map's 900 px/s^2 gravity this peaks a little
* under 100 px -- six tiles -- which is what the platform heights are cut to. */
#define SS_JUMP_SPEED 420.0f
/* How fast thrust bleeds away when no direction is held, as a fraction per
* second. The library has no friction at all: see ss_player_friction. */
#define SS_FRICTION_GROUND 12.0f
#define SS_FRICTION_AIR 1.5f
/* How many of each kind of thing level1.tmj places. */
#define SS_COIN_COUNT 4
#define SS_HAZARD_COUNT 2
/* How far the autoplay script waits between jumps, in frames. */
#define SS_AUTOPLAY_JUMP_PERIOD 45
/**
* @brief What one call to ss_collide_resolve found.
*
* `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this
* step; `grounded` says there is solid terrain one pixel under where the actor
* will be when the step finishes, which is the test a jump is gated on.
*/
typedef struct {
bool blocked_x;
bool blocked_y;
bool grounded;
} ss_Contact;
/**
* @brief Per-actor game data, hung off akgl_Actor::actorData.
*
* The library never reads or frees `actorData`, so it is the place a game puts
* what the library has no field for. These live in a fixed table in actors.c
* rather than being allocated, which is the same discipline the library's own
* pools follow.
*/
typedef struct {
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
float32_t home_y;
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
} ss_ActorData;
/** @brief The whole game's state. One player, one level, no menus. */
typedef struct {
akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */
akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */
akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
int coins_taken;
int deaths;
bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
bool grounded; /**< Last step's verdict; what gates the next jump. */
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
int frame; /**< Frames drawn so far. */
} ss_Game;
extern ss_Game ss_game;
/* collision.c -- everything akgl_physics_arcade_collide would have done. */
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map);
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest);
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest);
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy);
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest);
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body);
/* player.c */
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
/* actors.c */
akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
#endif // _SIDESCROLLER_H_

348
scripts/fetch_tutorial_assets.sh Executable file
View File

@@ -0,0 +1,348 @@
#!/bin/bash
#
# Refresh the tutorial art in docs/tutorials/assets/ from Kenney.nl.
#
# Those PNGs are tracked on purpose. They are what the two tutorial games in
# examples/ draw, what docs/tutorials/PROVENANCE.md accounts for, and what a
# reader copies into their own project -- so the tree has to build and the
# tutorials have to run with no network access at all. This script therefore
# has the same rule mkcontrollermappings.sh was fixed into for 0.5.0: **it must
# never replace a good tracked asset with a worse one**. Everything is fetched
# and repacked in a temporary directory, every step is checked, and the staged
# files are moved into place only once all of them have come out right. Any
# failure leaves the tracked copies exactly as they were and exits non-zero.
#
# Nothing in the build runs this. It is a deliberate, standalone refresh, and
# its output should land in its own commit so the diff is reviewable.
#
# Two of the eight images are verbatim copies of a Kenney tileset. The other six
# are repacked: 16x16 source art composited bottom-centre into 32x32 cells, one
# row, left to right, which is the layout akgl_spritesheet_coords_for_frame
# indexes. The repack lives here rather than in a second script so that the
# tracked bytes are reproducible from one command.
#
# The JSON (sprites, characters) and the TMJ maps are hand-authored source, not
# generated. This script does not touch them.
#
# Usage: scripts/fetch_tutorial_assets.sh [repository-root]
set -euo pipefail
AKGL_KENNEY_ASSET_URL="${AKGL_KENNEY_ASSET_URL:-https://kenney.nl/assets}"
# A floor, not a target. Each pack page carries its own licence line; a page
# that does not say CC0 is either the wrong page or a relicensed pack, and
# either way its bytes must not land in this repository.
AKGL_KENNEY_LICENSE_TEXT="${AKGL_KENNEY_LICENSE_TEXT:-Creative Commons CC0}"
function die()
{
echo "fetch_tutorial_assets: $*" >&2
exit 1
}
function note()
{
echo "fetch_tutorial_assets: $*"
}
rootdir="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
assetdir="${rootdir}/docs/tutorials/assets"
if [[ ! -d "${assetdir}/sidescroller" ]] || [[ ! -d "${assetdir}/jrpg" ]]; then
die "no such directory: ${assetdir}/{sidescroller,jrpg}"
fi
command -v curl >/dev/null 2>&1 || die "curl is not available"
command -v unzip >/dev/null 2>&1 || die "unzip is not available"
command -v convert >/dev/null 2>&1 || die "ImageMagick 'convert' is not available"
command -v identify >/dev/null 2>&1 || die "ImageMagick 'identify' is not available"
workdir="$(mktemp -d)"
function cleanup()
{
rm -rf "${workdir}"
}
trap cleanup EXIT
staging="${workdir}/staging"
mkdir -p "${staging}/sidescroller" "${staging}/jrpg"
##
## Fetching
##
# Download one pack, check its page says CC0, and unpack it under ${workdir}/<slug>.
function fetch_pack()
{
local slug="$1"
local page="${workdir}/${slug}.html"
local zip="${workdir}/${slug}.zip"
local dest="${workdir}/${slug}"
local url=""
# --fail so an HTTP error status is an exit status rather than an error page
# in the body. Not a pipeline: the status being checked is curl's, and a
# pipeline reports the last command's.
if ! curl --fail --silent --show-error --location "${AKGL_KENNEY_ASSET_URL}/${slug}" --output "${page}"; then
die "could not fetch the pack page for ${slug}; tracked assets left as they were"
fi
if ! grep -q "${AKGL_KENNEY_LICENSE_TEXT}" "${page}"; then
die "the ${slug} page does not say '${AKGL_KENNEY_LICENSE_TEXT}'; refusing to vendor it"
fi
url="$(grep -o "https://[^'\"]*${slug}\.zip" "${page}" | head -1)"
if [[ -z "${url}" ]]; then
die "no download link found on the ${slug} page; tracked assets left as they were"
fi
if ! curl --fail --silent --show-error --location "${url}" --output "${zip}"; then
die "download failed from ${url}; tracked assets left as they were"
fi
if ! unzip -tq "${zip}" >/dev/null 2>&1; then
die "${slug}.zip did not survive an integrity check; tracked assets left as they were"
fi
mkdir -p "${dest}"
if ! unzip -qo "${zip}" -d "${dest}"; then
die "could not unpack ${slug}.zip; tracked assets left as they were"
fi
if ! grep -q "Creative Commons Zero" "${dest}/License.txt"; then
die "${slug} does not carry a CC0 License.txt; refusing to vendor it"
fi
note "fetched ${slug} from ${url}"
}
# Refuse to publish an audio file that is not actually Ogg. `file(1)` is not
# guaranteed to be installed; the container magic is four bytes at offset zero.
function require_ogg()
{
local file="$1"
local minbytes="$2"
[[ -f "${file}" ]] || die "expected file is missing: ${file}"
if [[ "$(head -c 4 "${file}")" != "OggS" ]]; then
die "${file} is not an Ogg stream; tracked assets left as they were"
fi
if [[ "$(wc -c < "${file}")" -lt "${minbytes}" ]]; then
die "${file} is under ${minbytes} bytes; tracked assets left as they were"
fi
}
# Refuse to work from an image that is not the size the frame arithmetic assumes.
function require_size()
{
local file="$1"
local want="$2"
local got=""
[[ -f "${file}" ]] || die "expected file is missing: ${file}"
got="$(identify -format '%wx%h' "${file}")"
if [[ "${got}" != "${want}" ]]; then
die "${file} is ${got}, expected ${want}; the pack layout has changed and the frame offsets no longer hold"
fi
}
##
## Repacking
##
## A sheet is one row of 32x32 cells. Each cell holds one 16x16 source tile
## composited at (8, 16) -- horizontally centred, sitting on the cell's bottom
## edge -- so an actor drawn into a 32x32 destination rectangle has its feet on
## the bottom of that rectangle whichever sheet it came from.
##
AKGL_SHEET_CELL=32
AKGL_SOURCE_TILE=16
AKGL_CELL_OFFSET_X=8
AKGL_CELL_OFFSET_Y=16
# build_sheet <source-png> <output-png> <spec>...
#
# Each spec is "X,Y" or "X,Y,flop": the source pixel offset of a 16x16 tile, and
# whether to mirror it horizontally. Specs are laid into cells left to right, so
# the position of a spec in the argument list *is* its frame id.
function build_sheet()
{
local src="$1"
local out="$2"
shift 2
local -a args=()
local count=$#
local idx=0
local spec=""
local sx=""
local sy=""
local flop=""
args+=( -size "$((count * AKGL_SHEET_CELL))x${AKGL_SHEET_CELL}" xc:none )
for spec in "$@"; do
IFS=',' read -r sx sy flop <<< "${spec}"
args+=( '(' "${src}" -crop "${AKGL_SOURCE_TILE}x${AKGL_SOURCE_TILE}+${sx}+${sy}" +repage )
if [[ "${flop:-}" == "flop" ]]; then
args+=( -flop )
fi
args+=( ')' -geometry "+$(( (idx * AKGL_SHEET_CELL) + AKGL_CELL_OFFSET_X ))+${AKGL_CELL_OFFSET_Y}" -composite )
idx=$((idx + 1))
done
# -strip, and the two date properties by name, because ImageMagick writes
# date:create/date:modify tEXt chunks from the wall clock. Without this a
# refresh that changed nothing at all still produced eight different files,
# and the diff would say the art had changed when it had not.
args+=( -strip +set date:create +set date:modify +set date:timestamp )
if ! convert "${args[@]}" "${out}"; then
die "could not repack ${out}; tracked assets left as they were"
fi
}
# rpg_urban_character <character-index> <output-png>
#
# The RPG Urban Pack lays each character out as a 4-column by 3-row block in the
# last four columns of the tileset: columns are left, down, up, right; rows are
# stand, step A, step B. This regroups one block into the twelve-frame order the
# tutorial sprite JSON expects -- down, left, right, up, three frames each.
function rpg_urban_character()
{
local charidx="$1"
local out="$2"
local src="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
local left=368
local down=384
local up=400
local right=416
local y0=$(( charidx * 3 * 16 ))
local y1=$(( y0 + 16 ))
local y2=$(( y0 + 32 ))
build_sheet "${src}" "${out}" \
"${down},${y0}" "${down},${y1}" "${down},${y2}" \
"${left},${y0}" "${left},${y1}" "${left},${y2}" \
"${right},${y0}" "${right},${y1}" "${right},${y2}" \
"${up},${y0}" "${up},${y1}" "${up},${y2}"
}
##
## Do the work
##
fetch_pack "pixel-line-platformer"
fetch_pack "rpg-urban-pack"
fetch_pack "music-jingles"
plp="${workdir}/pixel-line-platformer/Tilemap/tilemap_packed.png"
rup="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
require_size "${plp}" "160x96"
require_size "${rup}" "432x288"
# Tilesets are copied verbatim. Both are already 16x16 with zero spacing and
# zero margin, which is the only geometry akgl_tilemap_compute_tileset_offsets
# gets right: it adds `spacing` to the tile pitch but sets the first row's y
# offset to `spacing` rather than zero, and it ignores `margin` entirely.
cp "${plp}" "${staging}/sidescroller/tiles.png"
cp "${rup}" "${staging}/jrpg/tiles.png"
# Pixel Line Platformer tile ids, ten columns: id -> ((id % 10) * 16, (id / 10) * 16)
# 40, 41, 42 the armed rabbit: contact, passing (also the jump pose), contact
# 44 the gold pickup
# 51, 52 the moth, wings up and down
# 55, 56 the red blob, two frames
#
# akgl_actor_render always draws with SDL_FLIP_NONE, so a left-facing run has to
# exist in the sheet as its own frames rather than being mirrored at draw time.
build_sheet "${plp}" "${staging}/sidescroller/player.png" \
"0,64" "16,64" "32,64" \
"0,64,flop" "16,64,flop" "32,64,flop"
build_sheet "${plp}" "${staging}/sidescroller/coin.png" \
"64,64"
build_sheet "${plp}" "${staging}/sidescroller/hazard.png" \
"80,80" "96,80" "16,80" "32,80"
rpg_urban_character 0 "${staging}/jrpg/player.png"
rpg_urban_character 3 "${staging}/jrpg/npc_shopkeeper.png"
rpg_urban_character 2 "${staging}/jrpg/npc_elder.png"
# One jingle per game, copied unchanged. akgl_load_start_bgm() is the library's
# only file-audio entry point -- akgl_audio_* is a synthesiser, and there is no
# sound-effect loader at all -- so a jingle is what a tutorial can actually
# play. Both are stings of under two seconds, and the loop request in
# akgl_load_start_bgm does not take effect, so each plays once.
cp "${workdir}/music-jingles/Audio/8-Bit jingles/jingles_NES00.ogg" \
"${staging}/sidescroller/jingle_start.ogg"
cp "${workdir}/music-jingles/Audio/Pizzicato jingles/jingles_PIZZI07.ogg" \
"${staging}/jrpg/jingle_start.ogg"
cp "${workdir}/pixel-line-platformer/License.txt" "${staging}/LICENSE.kenney_pixel-line-platformer.txt"
cp "${workdir}/rpg-urban-pack/License.txt" "${staging}/LICENSE.kenney_rpg-urban-pack.txt"
cp "${workdir}/music-jingles/License.txt" "${staging}/LICENSE.kenney_music-jingles.txt"
##
## Check the staging area before anything is allowed near the tracked copies
##
# name expected size
AKGL_TUTORIAL_ASSETS=(
"sidescroller/tiles.png 160x96"
"sidescroller/player.png 192x32"
"sidescroller/coin.png 32x32"
"sidescroller/hazard.png 128x32"
"jrpg/tiles.png 432x288"
"jrpg/player.png 384x32"
"jrpg/npc_shopkeeper.png 384x32"
"jrpg/npc_elder.png 384x32"
)
staged=0
for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
read -r name want <<< "${entry}"
require_size "${staging}/${name}" "${want}"
if [[ ! -s "${staging}/${name}" ]]; then
die "staged ${name} is empty; tracked assets left as they were"
fi
staged=$((staged + 1))
done
if [[ "${staged}" -ne "${#AKGL_TUTORIAL_ASSETS[@]}" ]]; then
die "staged ${staged} images, expected ${#AKGL_TUTORIAL_ASSETS[@]}; tracked assets left as they were"
fi
AKGL_TUTORIAL_AUDIO=(
"sidescroller/jingle_start.ogg"
"jrpg/jingle_start.ogg"
)
for name in "${AKGL_TUTORIAL_AUDIO[@]}"; do
require_ogg "${staging}/${name}" 4096
done
AKGL_TUTORIAL_LICENSES=(
"LICENSE.kenney_pixel-line-platformer.txt"
"LICENSE.kenney_rpg-urban-pack.txt"
"LICENSE.kenney_music-jingles.txt"
)
for name in "${AKGL_TUTORIAL_LICENSES[@]}"; do
if [[ ! -s "${staging}/${name}" ]]; then
die "staged ${name} is empty; tracked assets left as they were"
fi
done
##
## Publish
##
for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
read -r name want <<< "${entry}"
mv "${staging}/${name}" "${assetdir}/${name}"
done
for name in "${AKGL_TUTORIAL_AUDIO[@]}" "${AKGL_TUTORIAL_LICENSES[@]}"; do
mv "${staging}/${name}" "${assetdir}/${name}"
done
note "wrote ${staged} images, ${#AKGL_TUTORIAL_AUDIO[@]} jingles and ${#AKGL_TUTORIAL_LICENSES[@]} upstream licence files to ${assetdir}"