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