Files
libakgl/docs/tutorials/assets/README.md
Andrew Kesterson 88aaa184e4 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>
2026-08-01 20:59:00 -04:00

9.4 KiB

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.