From 88aaa184e45414264f593ec31983f43dcde45926 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sat, 1 Aug 2026 20:59:00 -0400 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5 (1M context) --- docs/19-tutorial-sidescroller.md | 787 +++++++++ docs/20-tutorial-jrpg.md | 675 ++++++++ docs/tutorials/assets/LICENSE | 121 ++ .../assets/LICENSE.kenney_music-jingles.txt | 21 + .../LICENSE.kenney_pixel-line-platformer.txt | 22 + .../assets/LICENSE.kenney_rpg-urban-pack.txt | 23 + docs/tutorials/assets/PROVENANCE.md | 151 ++ docs/tutorials/assets/README.md | 162 ++ .../assets/jrpg/character_jrpg_elder.json | 70 + .../assets/jrpg/character_jrpg_player.json | 70 + .../jrpg/character_jrpg_shopkeeper.json | 70 + docs/tutorials/assets/jrpg/jingle_start.ogg | Bin 0 -> 19137 bytes docs/tutorials/assets/jrpg/npc_elder.png | Bin 0 -> 729 bytes docs/tutorials/assets/jrpg/npc_shopkeeper.png | Bin 0 -> 783 bytes docs/tutorials/assets/jrpg/player.png | Bin 0 -> 640 bytes .../jrpg/sprite_jrpg_elder_idle_down.json | 16 + .../jrpg/sprite_jrpg_elder_idle_left.json | 16 + .../jrpg/sprite_jrpg_elder_idle_right.json | 16 + .../jrpg/sprite_jrpg_elder_idle_up.json | 16 + .../jrpg/sprite_jrpg_elder_walk_down.json | 19 + .../jrpg/sprite_jrpg_elder_walk_left.json | 19 + .../jrpg/sprite_jrpg_elder_walk_right.json | 19 + .../jrpg/sprite_jrpg_elder_walk_up.json | 19 + .../jrpg/sprite_jrpg_player_idle_down.json | 16 + .../jrpg/sprite_jrpg_player_idle_left.json | 16 + .../jrpg/sprite_jrpg_player_idle_right.json | 16 + .../jrpg/sprite_jrpg_player_idle_up.json | 16 + .../jrpg/sprite_jrpg_player_walk_down.json | 19 + .../jrpg/sprite_jrpg_player_walk_left.json | 19 + .../jrpg/sprite_jrpg_player_walk_right.json | 19 + .../jrpg/sprite_jrpg_player_walk_up.json | 19 + .../sprite_jrpg_shopkeeper_idle_down.json | 16 + .../sprite_jrpg_shopkeeper_idle_left.json | 16 + .../sprite_jrpg_shopkeeper_idle_right.json | 16 + .../jrpg/sprite_jrpg_shopkeeper_idle_up.json | 16 + .../sprite_jrpg_shopkeeper_walk_down.json | 19 + .../sprite_jrpg_shopkeeper_walk_left.json | 19 + .../sprite_jrpg_shopkeeper_walk_right.json | 19 + .../jrpg/sprite_jrpg_shopkeeper_walk_up.json | 19 + docs/tutorials/assets/jrpg/tiles.png | Bin 0 -> 17300 bytes docs/tutorials/assets/jrpg/town.tmj | 1356 +++++++++++++++ .../sidescroller/character_ss_coin.json | 16 + .../character_ss_hazard_blob.json | 46 + .../character_ss_hazard_moth.json | 46 + .../sidescroller/character_ss_player.json | 89 + docs/tutorials/assets/sidescroller/coin.png | Bin 0 -> 122 bytes docs/tutorials/assets/sidescroller/hazard.png | Bin 0 -> 246 bytes .../assets/sidescroller/jingle_start.ogg | Bin 0 -> 39948 bytes docs/tutorials/assets/sidescroller/level1.tmj | 1448 +++++++++++++++++ docs/tutorials/assets/sidescroller/player.png | Bin 0 -> 383 bytes .../assets/sidescroller/sprite_ss_coin.json | 16 + .../sidescroller/sprite_ss_hazard_blob.json | 17 + .../sidescroller/sprite_ss_hazard_moth.json | 17 + .../sprite_ss_player_idle_left.json | 16 + .../sprite_ss_player_idle_right.json | 16 + .../sprite_ss_player_jump_left.json | 16 + .../sprite_ss_player_jump_right.json | 16 + .../sprite_ss_player_run_left.json | 19 + .../sprite_ss_player_run_right.json | 19 + docs/tutorials/assets/sidescroller/tiles.png | Bin 0 -> 1414 bytes examples/jrpg/CMakeLists.txt | 72 + examples/jrpg/jrpg.c | 369 +++++ examples/jrpg/jrpg.h | 188 +++ examples/jrpg/textbox.c | 125 ++ examples/jrpg/world.c | 458 ++++++ examples/sidescroller/CMakeLists.txt | 48 + examples/sidescroller/actors.c | 183 +++ examples/sidescroller/collision.c | 383 +++++ examples/sidescroller/main.c | 456 ++++++ examples/sidescroller/player.c | 436 +++++ examples/sidescroller/sidescroller.h | 135 ++ scripts/fetch_tutorial_assets.sh | 348 ++++ 72 files changed, 8946 insertions(+) create mode 100644 docs/19-tutorial-sidescroller.md create mode 100644 docs/20-tutorial-jrpg.md create mode 100644 docs/tutorials/assets/LICENSE create mode 100644 docs/tutorials/assets/LICENSE.kenney_music-jingles.txt create mode 100644 docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt create mode 100644 docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt create mode 100644 docs/tutorials/assets/PROVENANCE.md create mode 100644 docs/tutorials/assets/README.md create mode 100644 docs/tutorials/assets/jrpg/character_jrpg_elder.json create mode 100644 docs/tutorials/assets/jrpg/character_jrpg_player.json create mode 100644 docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json create mode 100644 docs/tutorials/assets/jrpg/jingle_start.ogg create mode 100644 docs/tutorials/assets/jrpg/npc_elder.png create mode 100644 docs/tutorials/assets/jrpg/npc_shopkeeper.png create mode 100644 docs/tutorials/assets/jrpg/player.png create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json create mode 100644 docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json create mode 100644 docs/tutorials/assets/jrpg/tiles.png create mode 100644 docs/tutorials/assets/jrpg/town.tmj create mode 100644 docs/tutorials/assets/sidescroller/character_ss_coin.json create mode 100644 docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json create mode 100644 docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json create mode 100644 docs/tutorials/assets/sidescroller/character_ss_player.json create mode 100644 docs/tutorials/assets/sidescroller/coin.png create mode 100644 docs/tutorials/assets/sidescroller/hazard.png create mode 100644 docs/tutorials/assets/sidescroller/jingle_start.ogg create mode 100644 docs/tutorials/assets/sidescroller/level1.tmj create mode 100644 docs/tutorials/assets/sidescroller/player.png create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_coin.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json create mode 100644 docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json create mode 100644 docs/tutorials/assets/sidescroller/tiles.png create mode 100644 examples/jrpg/CMakeLists.txt create mode 100644 examples/jrpg/jrpg.c create mode 100644 examples/jrpg/jrpg.h create mode 100644 examples/jrpg/textbox.c create mode 100644 examples/jrpg/world.c create mode 100644 examples/sidescroller/CMakeLists.txt create mode 100644 examples/sidescroller/actors.c create mode 100644 examples/sidescroller/collision.c create mode 100644 examples/sidescroller/main.c create mode 100644 examples/sidescroller/player.c create mode 100644 examples/sidescroller/sidescroller.h create mode 100755 scripts/fetch_tutorial_assets.sh diff --git a/docs/19-tutorial-sidescroller.md b/docs/19-tutorial-sidescroller.md new file mode 100644 index 0000000..b760f6a --- /dev/null +++ b/docs/19-tutorial-sidescroller.md @@ -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. diff --git a/docs/20-tutorial-jrpg.md b/docs/20-tutorial-jrpg.md new file mode 100644 index 0000000..fd63e16 --- /dev/null +++ b/docs/20-tutorial-jrpg.md @@ -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 | +|---|---|---| +| `ALIVE|FACE_DOWN` | 17 | `jrpg_player_idle_down` | +| `ALIVE|FACE_DOWN|MOVING_DOWN` | 1041 | `jrpg_player_walk_down` | +| `ALIVE|FACE_LEFT` | 18 | `jrpg_player_idle_left` | +| `ALIVE|FACE_LEFT|MOVING_LEFT` | 146 | `jrpg_player_walk_left` | +| `ALIVE|FACE_RIGHT` | 20 | `jrpg_player_idle_right` | +| `ALIVE|FACE_RIGHT|MOVING_RIGHT` | 276 | `jrpg_player_walk_right` | +| `ALIVE|FACE_UP` | 24 | `jrpg_player_idle_up` | +| `ALIVE|FACE_UP|MOVING_UP` | 536 | `jrpg_player_walk_up` | + +There is no diagonal row and there does not need to be one. The default arrow +bindings clear *every* facing and movement bit before setting their own +(`akgl_actor_cmhf_left_on` and its five siblings all do), so holding two +directions moves in whichever was pressed last rather than diagonally. That is +documented in [Chapter 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. diff --git a/docs/tutorials/assets/LICENSE b/docs/tutorials/assets/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/docs/tutorials/assets/LICENSE @@ -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. diff --git a/docs/tutorials/assets/LICENSE.kenney_music-jingles.txt b/docs/tutorials/assets/LICENSE.kenney_music-jingles.txt new file mode 100644 index 0000000..52934bc --- /dev/null +++ b/docs/tutorials/assets/LICENSE.kenney_music-jingles.txt @@ -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 \ No newline at end of file diff --git a/docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt b/docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt new file mode 100644 index 0000000..d6a39de --- /dev/null +++ b/docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt @@ -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: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt b/docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt new file mode 100644 index 0000000..4116e89 --- /dev/null +++ b/docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt @@ -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: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/docs/tutorials/assets/PROVENANCE.md b/docs/tutorials/assets/PROVENANCE.md new file mode 100644 index 0000000..6094209 --- /dev/null +++ b/docs/tutorials/assets/PROVENANCE.md @@ -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. diff --git a/docs/tutorials/assets/README.md b/docs/tutorials/assets/README.md new file mode 100644 index 0000000..92ccbdf --- /dev/null +++ b/docs/tutorials/assets/README.md @@ -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__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`. diff --git a/docs/tutorials/assets/jrpg/character_jrpg_elder.json b/docs/tutorials/assets/jrpg/character_jrpg_elder.json new file mode 100644 index 0000000..f0dbe9a --- /dev/null +++ b/docs/tutorials/assets/jrpg/character_jrpg_elder.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/jrpg/character_jrpg_player.json b/docs/tutorials/assets/jrpg/character_jrpg_player.json new file mode 100644 index 0000000..8c0e2c2 --- /dev/null +++ b/docs/tutorials/assets/jrpg/character_jrpg_player.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json b/docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json new file mode 100644 index 0000000..217aaa8 --- /dev/null +++ b/docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/jrpg/jingle_start.ogg b/docs/tutorials/assets/jrpg/jingle_start.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e3f2c49c227328f4de9ba18a79953dff742db2cd GIT binary patch literal 19137 zcmeIacT`i$-!D7~3503_Nu;Tv2M9_K5Uc@1FNP8fhzg-dRRrOeae#*L740QHGtnqt!Yp0=>|JuH{A#1i-)osw1h3b320x02Tlr zOad4j%~kQOhoT0|r;GmF zBQrwIYHs>KaZxZCTxsRz z?!<&4dh6!B0r&Ps-`jiGGe3Sy^I^~C_${OHTffBnDkl87<_iMm^<5z0754SAi88((wH97B zd+5E{jyL~yT=b6+L}CYkMpfou8 z*nN^qI-D-f#;cX99bdZVnA~wbwMVyfX6`PTklm}7((Wf|ZN0lN({{AR+oLF~H4q>s^z8yUc*$y#e=|0=8T@?Ai3k zU>3~g@xADOS&qm=)XmeM*=C{shvo3e=}*nlHSDr9J+rj;=k7= z1sc~4LWb_KTKsPT0MMEt<1p_>TwD!?T}_5v4Vdoh{?`@*ONWh}?ixeJ<^Z4sz+`Fp z&;dDlc7}P9MG(fWQ?<)`^~<6X@6^N$4QsnMYHs~lRAc^ayLkN2Q?HOEQSK>e)&8~? z22I_rYEuFU==Lz+j1rK$j!<GzF`LtV<(!s)Bkx(|Mw05 zmjnM>4nP%0$iW}c@{F23l4FJdiO*6IpOWyhj=_V%^2P5I|0@;ohvE4Mto+8Hx>ahnT zP0;$pEO%;uF*zCHCt=@i%8ie77rc`jb8Fr+R_=6CF}bi@cxgFo=+^uY1;JfV;eWD? zo$$~LLvOrwapChM(Hn%q8fjSbuCWuWc@JG2R9Idigd+)UzT$B7*2O$go)C^MNKqx` z)_XYtoM8Cer5cKu`$AfYFBHDIRHJ_bKmc_=C%{vDLX@hf05H*t0HjvT>Rv%TM#ZUl z7T}fZ)d3IcLaMJlwM@oO-css}ucM`{n!l^7o^F|xrJh!ftA%Y@sjHE_L0PUVS}iBb zzJgTh>Z*$doF!)P$HK%XRH48Ji1z%l+5P6znwIv|=S5NSYGsNL-c;cIL>MPKiwbeF zvwd$q%+o3JhxLk%yRL?Lau9IrfmKSgT`gY}MM3;=5>`O~+5)$e!<-G}-RId&0Id@u zah=kE?6%7)-+xL6;?|I?(uJJDwYUCofB&gJJdWH7r6FRJ)(w%2DuhBQn7RuY6)(cO zLgDN7CK3PdMf;A6nE(1xE9850fKsz)_@+n$uqgrny;|SsxZ}ljt#F`2ipI-0Dn=J# zE^)O|tfj4Pe8zuOe*_0U!@>TUp_r!n{)z3D`uD8mf6QP0|BQ`#g;qCu0L6iLF`8#w z+R7@;lZ15f#me>a03!*dZo#9z(9F)VwHNJ=Qh{vyn)>rxC&vm&3^ zA}S57Hqo7ma;O{j@~djpE=kQ2rP&tT4{7`-`Gq6?NL{xP1HEQa1xw zITY%GhJRW`w-;0#$$-=}gW>F`0W_ekGXpd&MGGAzCegPEr&Ttz@B~?egzYQ-Q&5(j z?K=M~W*#ELsi+94%0u1Fc7@Ny!!{PMGh~PqY-7H_9!^>DmXZqv*;%k&*`ET~5Pbii z0@x6I&B7BfB7)#KqPn8U{JlaHgUN;aLO~g92xcy1i5h~*Kku7wDBD$UAqm~Dn8^ES z=oxf6udG>%w80xW(&lXz&d{(Dbqyn4m8p?^c6QYpt(BkiOGoV_)EZe10VIn)JNuJd zjIMY^e5zhq{_$MP8~Ls>Y<=pURH}{nl)L41Md-4*0Epp?6fk&NOpFXLquFL4ft0k& zk`5~xZMj-WD?h@~Oy{I202aLY)rUU_4FJ*VgJ=|*Jtl`mWcFi|cwHpw8D+9wxfrJl zop1>B+#a>P+{{@n=6$>m#l5TBl@94~c9YD;Fk~Dz8 zU=}8$qBI7xuv9`@GynX2XGOSnulDc2W!7}7--~`De~bV4jrjQS<8RceRYs;3)*CH< zgYO@|f1w*yH|}_FW1|kyIMv@P^j33TqBA9EpF?ASyhBE)8eo{HdsIPxlx981lNEYc zxpbNq55Bm42D_@O@D|%SnIGbk4G`JRx>{ZPzQl2j%iQeksE^j2A>F)LZ>m0uvXcNj z9ig$=J9b=CaW8ZC%A!>pvGc>a{0{=wW^)LI??+*H>`a7#^XZ6 z@k>{#PAh%cd!cF-VYJ5t3ZklxEWSio>_T_Pr>U!Rt?@L1RU$9hgRW_rcRfdf?JG1P zfYmRVZL5tAMrVasbHjd^{g>371i*udt(%CFao91$OjNm<*RUP0FDixW2J0JHdzgpF{HnKYU@k5=Gs~6uK zy!k15EwYoYUa51xuUO_WHQjwsY7`WG|LF_T2na;ZXUI{lHX}_07ycQ(W^0qsCg*Vvt75#bYalpYP2@30DcIRyO z+5T&5(>-6aCkl#RM2mZK2ryJ_Cr>&#&e`f+p~_pL>dNcl72J=RH#1-vd;)-*wtyef zis1qAjz?HL!rcPFVdMh$)Q-i3=MD-xbdPb-i611jm zv}VY8;*0eccQ(B~^sBUPq}ts5-jA5%q{vUXQf7#yJyZ4LyNDCkYz3G4x;^_sc7AA(9{Lw%r1P(`W$K(SQ`k|R|NS23x{ zLLvYoiYPpaJ|suQ5gb#JkRa|!tRZDnQ6`?~L+a#W?c3zlNosvN%(Ic%+%k&qur<{X z(7I5RDve|xGKa;!32Cz zVz@%uj4c5}pafAvTYR2wn!`;LP=>CDDqvgDs>k@+dPok5ruKqUz*%?{xmrr*xni*{2!}9v1p-q>Od6jysE82b3B1y*Xq6bGx~We|K9`Jq z>daLYuqlw41g-N!_kP#5>-g{IGAat0O1SszN-4D)d0w`;1;NvaDaH13NZ%bfgmsUO zJpTQ-TgUlKUWl5luppAXVes6sWl^0vO_!Wp%R|qnziVl^l>Pu;X|_InI^wh9QkGWL zy_Hw16dvI^RQ?tJ{zv=qjq+eyV`CYZ2y2ism-?0Sb4v7b*#MB=gMEyC$ID+`9Sg9?>^ z0vSDjF<(sF>A@O%S)FB5L2LM@S$d~+mv|bY{xVDBoi|oH-7ekzboGu9m(I`4Ha=DCgKd*PMYV}@oA(^K`Ki>O&I_&t$L(~tAkFy@B1zePz zQ9i6?&y7v{@Ee@c>1qqt_so5`@-(hv#ir!gagTb+sYO)De3h3z((N+vA8g|kS zA*1CZ*^Hz}FLl890{hnb?6EL04>(tAqf3yJ(JsKkO2X8~0^qCJT|QXSYGn*YPgxpC zwD{rRZdwsDx$VOW`uOBI{FuSifqUwWIyviC}bQ^h+= z8 zt;r@lB%hC2Ou3;I$B-lIQ#3o1>*I{#YGr72r9K716NPq$J%W+mEbCF7#3zINWM^h4 z8083ETo8!ZeM});GUzs2hWB3Kq)ryyg%D~e9{VM*ZJ*t`sq~C)qoCInGyX;CalDL) zzS8IRs#|Y$tG#Zl8!JfF8FFu`J+H;6a#YzixhmXqYQXs3qx(TAn~!UqU)OZ#m+!T~ zD{=IT9Le=}cL1`h%;y}L`qKf89y&LHUrUz7=krw~uS%7CX68Mb1YlGc0MhnPaVXA9 za*o)_x>zxVl`E;e{g5G&8AfiQ8c1L)4{C`iB#b&6>k^CPCkuH30bkJEEU3*c&Bx>g zbZK^WX_~RuF16ukkh(JY@;)?-A{HyIZaVIBToK8|@PdqyB%m0!M2aXaF1@|VLP+w$ zwfboA>lCrH{@uZLnQ9kmlsS7sqb}_EW>jCOajCMY_+i)Pj<<1_%0kE0FDII*U#O~_ z>~Ff&ReEau>Q6)KJxZtYgCcWAgfmS?`X}{EPFy*6WYgisaJR0!HQLiE7rx=Ua*$=w~ zR^eAvucc|MB(GgQrQ-H9efII=XRg<%T5mDGe?FwVP9mXtd#EU2?OrYDQbu>$l$@~w z(MQu{&6{>4ynW*$M_Jyx^8UyZv3s80Sv%^hJ0xnNNWff4$r3QYc6Uaq;lzvLzdpP( zJnY6Z&tYsgF)Gm1Yg==Fq){RLxB&Ief6F~*vjGW`&h=6sK<2El@4}o(^*|AFrq0e1 z%vxA42v;Y!FQ!HVndSEr8(Pi<)T)L=^sX=cdDtL!im}XxYuJMY#vGFVazuibS6!o^ z*o7)ZWDfhtj=e!i3!U%3OUAr(e;GD)!dTnakX1K)dhK-3z5KW~pDvH1ABsvIR*wj^ zdlbA3#6b#)>p=}NE_`}-)%K&>E_C`J8G_lg;z4rOuPv1(Nc(rkbP$K^*QIx@{}knl zn>+1#%&%_K&mwo!ubUwz>R45oI|wjR9D*M9y8UYM%HNGKh_k`|PMOni3zn__O#+TV69)1bE+e|a z*0J*Z*k;wvPUyh9^7qMG&%19Y{h3vRb*-G7jP_|T}M2QWOs>x7XpYS!tn#&YlHGp}-FiHGMrw89=-Pk7+{T)R>3 z%_sHi;lVubTHvYrkS zEWOX{_*tZS&tt7T?{-g(^4saj=BB5aw77ZfdJPG@X~#ASb)Qd6(2CTp`_oYvjd{XJ zuHkr)K>*pMQbHlus#A`JKmr*8p76|;7iD%x4xuJP(`sJ3d;N`B@py?veiMM8xIN4@e6o6RYp;gRPJ%!OD^MY9yuz*1&xK$P8auof- z2um^+c|MRYhm#?ef}6v+3pw1K}Q;LBSON{IUGv@?J#jpg7HOG$b9J!j&|fI|iLhLJmuyGbzt z-I2VT%pnnPzv{FG9mcY5RcG|1l8cjB7c3ZP0;BWJB`s37cnW#-AnE4hTyHTK(NHWF zQxppH8b+|%zv~8mG)W>^hGZ}F_$U{#Wn)*R^)3dm zeNpDo{V+3zb6{p;$m@38lcc#M96BWkCygoQDyHdi1j)-Q0^P$Yil_49Kfm!Dt2zFD zgKL0ozvcJOl<<4KbIU4>eu!pi>+R+FTDXJQRsuBTUogH83GH$#y3Mtr8s~u6OWqncG*pYYnH7ak}`}f#Lx_aLntrW3S|L0 zl1&^4F3}iq#-3N5TtMPDCFrVZGh0dUD;-|O_r@X@RxMf&%*$|o)j|YF?pk?e?EM=Zscog3!`m2Vf4%4Zm(&v-zLEiCoPQTy0}w;$gWNNK6YU`F(yRFEnH<6qP^wg<^pXYY#F20YowGTMo%ehB zNHV%pon@u@>S^4!u%;jl;o|{CDP~IV@FD4(NcZ>d*(LdAI41*9PCyVD&$JOiRivv6g zhwAL)rd4=bN@B|R6Fi0N#|Xkil9M1X}`}KPVkD^4~fL`eTrYd{M8a! z$LV4jd!^A5s-uB5#riBkrQX>2h~CcAJs9H>jlr98o9rG5aArl)sb52UNMH<0iDxgaFid=|AZKUDxdW9piq(aQI6rz>uaS#7>p3NEG0aOH84L}}qT zfXxw_c(~Js1Q5tcvFbc278z-*TtnTkkZq;ZeZFD$J-0 zFldZWGB=rxs*|f`Rrw8-=Y4H_uYzNOhGc;-{#%@@YmcP{?<;om$iIJVyI=IG_~kc>Y?~%&mY$I`ZdxI#;iDGqHg0u zJeH=<0>lenw0mNFG28gId>50}&#P3$P4{XJO&19NKF&^D(tdQtR&Z-2^QBIg+?u%G z^U_s7k(m0K2wGY0{1`{k?(UpvooVgw*A&s+H#YWJ8R)L8qIz_3y-GZ)>(pTo53S3J zOOA@Sx~S^wtBaGgW@YoFCF{T$_hhZL-0*R0tFzXL;#dnn_shT^ul{|&pHgJ<(;zZV zu-$aqaQw;ZE3RMRKaG0tQaM@QR6o-NuJF89;>tdH(2hi(j`|XOxaIYa$v6FbuZ zY?6L&dfNZmYB9teZPjnnKiBOLM1B7H@$=f{^*YfA`mTu~zwRR4;OAonE7Xm)oQOE# z9$2w>*$Z$5_wvNgiB0EH^(*GAOLu*a-Eh^Yrj#8Sw#X%*%#26bW>7@_>j!;@-N$Jw86Eh+2~w$Ggcl zFFYtcOnEe?aFST5`t8x{pm$~93gOB5pP0FcYmbs=?;k-Oir9Ymuwl0%!iN>s<>9n3 zc2KhFg~3jf{;^d{THdZKX(;gON@ka!d1r?8_{m(p zOZCPbIpgIY&VtvBXcib7mHm2napaJG_<>G*j>=`VmUouv2g;m|Rwb{7u%w5rU*;dhg-ag4artmG2UQstU4!OoUF&z7pGB1I4p{|hPD3ggZuyb z>tx)JXT+`e@z=zjhYt#0@A)bdfumpB8HFOQk5T2n@Hc$(<=q3tiJuJxXZLQsSCQJC zj6hR74xLhhh-9kcqxtg#GG)_NeEh!?f6oIFaloRRI0@)b|((=1v!9W+ZzdR&Jec_WC>^i=P9 zl8jaiacFZzSYZcs^-M880kv$d z>uy^~8XeGqj9Hw7oy94$%~q(vSGPkD*e=kB&L@=%IJ{yD8B-Rv8cE``wQ5)SBy%~l z(?%CAC%xz->ouWo->Y6#v2AMqlJMt+`tdj|vetHlOT@dRkx6qnC}6A4KRt`r zS%-F98N<={W*$D88+2@N%<$Y^)I5|pmW}ls($%6R9BA9~Ew``DA~h*#NvG;RSb}U2 z0{BE|)C@lcfg(h9OL5`(#yC)wU2D7^M-VTI<8pEMT(+5N3c^^Z$K!E$-)uDgc&tKm|o zM%2~4D^5%1)ei*i%{y17hznM6T`$vfvgZRGxG&q)XcACuDx4uQpS|_pY_;_a-J+rL zZ`>YmN)X=`zMWm|-q1DZqT?52~b;D5-#oxIzBC^i!IX~kWoioO+d7A+kz-W4RL8H72Lzm$9`Q- z`3p8J?&o6iaM3^*Z7ps}@8rImv~Z1a?VAex=Xm+kw#yB)Y)NmTGk$cI_SR%1^yj** zx%)~-4%sP8Iq>^iOrKg48LVeneGWZ6m@E1FW0>K;;DY*tInkd(*F37JpV+?iJna;I^xT_$m;1O!_xB^` zRd*3+^pxPZUm&VxkaF!E9aHn~Tfnuy{6AtX4NO6TLZ38(6^@X?bP@ANQ+7u%s!S7x z@h&tL#ENm2+{lV%lp@>#!uig%%X2wAA=BZ6JK$e^+Ozgn*m1FHD+CtMHy~{E@n@Wx z&ZV7gSGem_V6zP>mO6S|2%-H;VMe6z3*jUAtE9a$%gChW2PJ`9{DAO}c3 zNwT-MPv%AHiX|Z9a76rZc_a?4QmrH|#TN*iw1s+7L$?Z#@EVn6II^cLyX)Cu^iJ`T zI{#%CQ!gI~@CvzOV3hM$*w;bLE$ffot5(sz|GsJD(aXk9-^BQK@<;c*(IOr-=*Q3W z;@ytZ?Krio3PO&zWs|D61xgoZUDUR5+GhJ>*Lan8s@j^|4~NFb-|A0~+hRS*0HVOy z9ZFZpvoeH7bs9=*Z@jRI*&&#nU*k9eX|j5#H|l0yD(}aJuGYB=)!#qALq~5K8mlUh zBq3;sMk%CFPr$4JHgOuH&P?p_r}lQWPvzxZUAgWV0iJ57vW*cIxgR{S*a*dIWR#>Z zxo{-BRtibxCq##Gkz@{`TEGRfuk@b0AcVP<@`_e>DNAFJT&|I!a^-uu@avC`J%0bz z`1`gOMx~xF&T3`duf%Swqhy}>`^{o4Pe)z9Yr9(h>b&s%l-JD*3crcUa9)km$mlxW zp)%(qb=@S-Y%LevI`?h6;pDG7+~1+7P2ERY7g3)BQV zE%aB{(SgsjVtO)da%;{Q9MdkOsE)qeTaP=b4=77NrUf6s;F?mdAMz_)Y8m&2sk72H z(kPV*tg95;cpfrOR~{|dW_h8S67}t~XXB_*en+g~floz<1y}w&7gl^w?_+5Md)FgclA*1|t<^7tiX&3g2r~N1hyI0e_WsQWt zlT{YPi!vNsm)B&+{bL@2zfE_BhE_Jl{SYrp*#I21d*f*j_8yIN_*UqcB^}fJ=GSEI z-^05aM|*mypox8!=r#_P}^HaiBQ#=N7+qU(IJ?9Bld@#tEKgc61SCv0)P#+OC z;njM*hIw3}TPPT*C0pWRI*JW zW^)mNfJYGmI?X3+ZSDM6(gc^f`$AzplEhl&P6I9`U05`hU{X~{5=YU`ii-gs)&)bC z4tIAeg1#3GYd~aDyb#oh@jK=mIP~}-V#Zp(YwTDQii@*lW?v{hwdAK*Q)RvmFES zldmUVKAw2;^zFOXZ)e_3zMgon{>9CJw@%cp76s#Yyu{Uu{a1C%^~q5uxK9@W8m&)o z)-`WV9Qa6lercHGW{yoU=i%1CLt3K12sm$nMrc?MsdppcR!cq+CxfN~4wqq}-iaeV zF;un3VDu?n0i9$rb*Q#1_&ABHC{Qd1dC?dfT3EB$<&sHO;;Yi>KXtJc@P(6s@7&qN=kpkOeu#r)amKLQx}RMXLuMMizT5i-pZ~P z3`|`Z<_b906va9OJ~UOa4#f^Fk;kH?t^{LH`z3T4&HMuY^Kmblv8sLSl#;z%qS5S!Z*-Ki$7$oB{Az&c*C_aY=Hx&60 z8sXyt&=rDwtd=*jkCD#jkvC`G(!zr$AJL_|&9WI8yKED`RSA>I5PY7GQz(tm#irGw zu~p>NR_N|;GpOrn+qdAim`1&Q72K=--qp)?GfM)VQA4|@AYNa>6g$sh?tl9})?=Qx z=I;XsZvH@N=B+zj{UU(I9B4tG|25ar;=QZNplS7I{hvq9x-|qb5m8g4Vvm~3`VL3; zdEuX1ZM^-j_&kJyMCNnRJ}KKZAty__y(C+U(!q_?L@8O4pCsR8@sK>gF7ag-mXq~D zZd$wH+)-T(fnHP`vlV#N3Xt%$R8Shu`bs^0`ui1?0J-S^?T33(wq+VE+ zyHIv4wX(zR)(ORBg~#6jhOcfaLldVvKFv<9AA>vJ zJgbp|MdV`}-!EQ|if8#>y`J>J41e^=##u37OS5h)mrHNg1|ZKf0>p0A6CDoNi`Ce> zpuK<>bWy%eF+~}L*U$G*SaZ+~&p_ybPQy2EWMoiu7kI$IMPCZt%HdkFvh>q*EaxZ$ zZJB^pY(gz?&=(4jJg*B}UOt<|8O_SR=wPu`{m|8op@tYP&-c2?)oRn;xaal*W(r=~ zdM~CNoE4r3axRaplDv#;G#)mcw0W~aHRs{SX{p3HjkwKf-Yeqy8xEd6u=rJ9 zC$MsCwyxYCVe-Pv0biV+>Vos@^6HclD4KXwNhKo_6akmi$K|>Lci?Sjfyk|m)5_-R zFL$D;s=9~QvYB0Y8vUGCS-SzIIG@L?+bIZ(*gm8<@NT@?(;)O?iqH=sp*yWt`BE|T zsK@KWQ(KRwsBTsbn3no*($mby*WQ{Qy5(nnWK(rzhypVpDRs-@O|qMC0~s4dfGBow z@3u@NRiCPS79#;6uom~2y4Oqj-WKceDz#1asAX0SceJvOrZstX%!5VffRY8$wVMzU z652!s4Q+U`qiuma`brf*E&u{a4mqTXiQu4VGz_n~IXeTNhseVc-FfQK?v?e@ohrfg6>BAR<5J?%v+MO3OHcfc{qB1=m^f}c^mfBQ%L<$F0Af$CK-0NR<@Qm2cgmaBneAoqL^(YpA&q<30Q(l0}9t3pcXhf9 z-Pcg*UigujywD5HmzdX1EHQ!(W77G(lvYP zy|D=!!yf?jp#&8bvg>JqjpZ@`Ixf!QA#niBQBH2AwG-N9KfAww9xvIO-sRyKT|8xY za8OS`sFUJxSXCr|5ehxrflH=828~E$rAj}oF#BS=f?V7o<1O?L zlUB6J6?I2ejRjTj^>z_keG>C^{dbI=u1@&V`mevIkG|caAaP(LN2l=Ov5~~|kp0U0 ziBHZvWij{iRAmqNu6`Rclz*TyMrV(_^EJYec)5qqe!N_9LpWY{KhSKQ&a6zAJT};N zJt-y=i3HoqOFvf*7mP^03&&*ZR{RW!E{|?MsIo9W-2g?{8m{1otl%x-K{!|d4;~d1 zzys^>oNt;GhXRik(9nQ)atQ{*5q1H0CPR#%i6>>N;pJ1oX^fj325qM5;+s$_gC?t~ zVx**u;^aBp+5MC!v4DRY!6N{A*Kq3PV~;JbPTjE|@i*IX_&fLRlPkMc6^Li95Lb@H z$=Sd8jnroUoUV6hstYYY-e}N?iJ66zf zYF&Jnek`NTa%mQWQ7A5s6bDOl@gtQugRSU+kRXhM)azkhZVhsWi7^x{ z2J7jzs{lr5jRM5z5>?eC+#16=F=(gf6&JQJFyYeOELx{B+`|Bc0M)5nf9y8GI5ZgjUOF9vs6%>D zeNr9{>lIjEdUI4*9YNY+AAYa3LFc&ls!eyUU*C8(@8|g)<;RnxKCIQ;q@#UniNOk{ zu=Borfpbrg!CtpD@!8(X4c!Rs>wF?#rheKizK+;`i?Y}yu~X@wZT$t5LU5s>L{^}KP*VQvL=gWednb^*7t#|BX0|jX6c{!aD=kwfI@C*=f+qk z+ed?+GEUxnd$0TIH-b=AmueWqB+zIFlunpC_u^WjUPYA)zZ(s+&L%LGlF?w)wTgq}6vOwLs_BtAKGF_&EU5Lw1H@3F7d*^BQYJt<=>kx#_yR8g zWHsXzfHQ{yRKI_e zO*2L_S8j)!CALPjwpviz0TPniNj-nwTSM>B&BsHG|B`~J2p$I%LIlV$3IWTJljfP} zm=ZBxTU-PwQXV?y<8W++)Q)LZ0umG>?7pJ39^kO7Afhi*g!_uL)D=z)JOBxs<_^I# z8C$#*7?2%9a35O;nNhnF?i{d6VCUIO&_KLZ8ES$qk_MY&o>T@Ld0q!#!6UoraNWHd zfc>@{0j#;`7nkD`5~3X&j;t>S1s7LjW-Jz)1sF8TbWGD}i{t8w1&d#qls$Kx{HZBS2T~ z2nq`ft6o3{j<1Fa5vmQa%K%5{27vX1D_Gkq21uhQFwH@EBLNg9aDglW29SD4rjz0X zk|QprIVTB5lHtRmY;jnu_T=n0-E-Mah zqQN6HfvUa1f#a8yuPpm{C2os$zggDZ{-$V?eH*^*m%Pyb4@fFDMeGy{*0r8AO8X?&#Ej$`o&sQ5byj}ZiuZF^ZO@5qL*pT-KdiYfYP00i-mJD<*)o@LvIdYc34dj*c{+Cog z<%0q!sD(b&jYKps*FFnhbkxwkhL7?R}_=d?LKjAPc~1cox1@S!BN9kiK3{ zG|Y2Dwf=-tcBU-Y_XNRF$gP!{Fr;x;akwIRnQ3XY-kR4{P)AHf0G*c-TWezu_3?h4 zx_aTZS7?;!-@$mAxX)tF8@bLInbEQjh}eZ?1{84;z8nV87bqgRJ1Cz0CV6}O&iLb} z4<2Rw3J7~Jmi*`&efCI0VP9U|?oj;$uNB(94~FO+g_Sw0NTIKK!QT_=F&Z|V$WldL zA2yq6(+}Z<3@qkbON)-pa1<@7pv~2thacAou6p6opcDsPQfsERRvH0TBeC7RF|D~H zbV#2YViro$3jv;r0fwt5&W*8GxNFk)T1?B}-5nt6&AkwRXlq4DcE>p6TcfT#hGj&P62s)6?J0Uny zP>yT_VECm8kt92j2=V&#W>r|MH(-wu;}{q~YWbsg#1Vj2=K`|_MOwT>WOyf`76-YD zfhx%?QyKztzBUNzf$SB6YQ_Yv^$Y?jWd=qOz0yZGezorrt&0#rVc3S?Tn&?$xSAXE z>tN`v%ixCB1HUUtZEAqt^IrLt?aZ!o_926S1XVR`-!9K77s09h@4uL|r^>!Fs9Y;G zymG~_Yu$@rCs(_q?1JCyOsI1-Y>>$QxR@kQT516+wLnv}UXn4WeFkE7D?pM0oRhMA z=v8HLD1g<mJd*Xk(BM9w`?D_$cHLL(SG=xS~I4+-YcAjeq52)urAN<#Fvp!@mlV;KnetIIk@4+O8Sf~}G7sEb|M*fzk4Zo4rD;>5iB?p9jekpYV&?04 z2P4ZHy95qI;CxnNFcEreDmzd+wZF4pMjp~dzAwUh@Hi#Z?Yg>+?g%jBSP2E`2;~Mg z+k3~|g1#afesuE;ur5GSc>>5P5oLSeZ7Dd1dVv_hz$_{F%@oZK%(8W3Sbm^3{1nA7 zq!-b)hj#on;!bBmrzBX%vqa`R-FR#}c6qCqxP$87+h*@f-hH=1S2L2~@8f*n+T4=y zUAPst<&L>(h^2wvPh9(?j&Ykl{-_x6?ItWQtC}STt+{pj)sOjAEl}^gPLksV^5EYH zux;jd%^YWF{r&TH1qO?eG+Dr*dZfX<_B56i0yrK#2uB4?=-6po!W8`G3;wf=YL++H zGKPf2sK~U!@i|CjY}Jq$fieOp@^ulm3D6aZ@$dSG+ew0YBGw`d`hRrm=XP40sn60# z-}@Q>$Dd9ADmjZmB!dk3pwfyxp@`PDE8ia<{l4w%ny>V?%Xi(XIXV4vX2tH{pBJL( zS}l?4F;IIv7CGfEknB>W!3~iUu^kEc<2?Bc+w_b`}u{BD7=!|?+#qyp zM(`{*@baZq(H_rMO(?Nc#kC8?JNI4*-FW`{7TUhU56)uX zBIFf@w&?Rq8l_dPlZ}MaEXM^I@~vZ$fX9PAqRJ~20Xi9L5MVA2(hxyTv;mb(4bZM? zcgn(YCNwvm2;4xueUF)#15Is*#U}__013zM7fZ8kezoQN%;A*&6~^=M8WXmCQK3Z7OUnYk3RGE+9IQtsB9kKJdkEb|;P3qC!3R>2}@EUf&XL-gcH@<-zb zPP6jotZ|yQ5>R^Y%QEg#(gY?gHOza4&r(;!7X=2>tG`B9UxJ@>NpTokVpZ-g%BWpq zhFv4C;71Ka1O`H@gPI2XLt{<&LLS=08h%?$rRbFp8`7A#I;uqK;GpsDoBdN2bz!^ld7{`cewx^gXFA(S4AySnA0&Ad7ixsMaa_Np`*DnM|5P)4LJ>0000jP)t-s00017 zVt9IYq@bM2|H+y0u!&_sK#fvEiHxS)hEl4oy8p`h@CPxk{>{rK-wim_8Br;>729KG3rvx-+goD^36>M6B82? z6B82?6B82?lm9At6&?Sh|BjA%a%{fb!}IHXSVL~tcAo5HGZ)P5x=vohDZ#w$()l=Z zAIGLXl+(g{<>H=>xv*U%0-U*^8eev_%!F;%bu^q7+^;qcbrD8tSdk)|xuB4h^1g<-u+^zlsE=Z@0VXI%h0^3| z!~I#IG)5Dogu72)-ZDN%oDv><1Q*N^pW2dh^qR)zOB# z2_qvV_yP-y_VVN$3$<65?{R(bjtcfJrq6Nx?d2S^RDvt)GXt1lpM>!V^^Z>vzT+Jp z1Ki>(8vx+CXv0Z}1uPgh@dmnIN2Kmw02Z_sBk)Ll`~~@m{u_Xg+xT>E0PgS(zs2zy zFEEVbpLm4OiYvEg6=}-tnm(qQ9uVMCU5T7 z0;34@Y~B~Va02L zpQ7MC%;v3qgdh6k%_c3`q*h##H!MM&k(bTtk4y=w^8=a9TloA1(aA2`7qG|W00000 LNkvXXu0mjf+rw02 literal 0 HcmV?d00001 diff --git a/docs/tutorials/assets/jrpg/npc_shopkeeper.png b/docs/tutorials/assets/jrpg/npc_shopkeeper.png new file mode 100644 index 0000000000000000000000000000000000000000..5b0f9296d30cdb1020b35bd65c35003341ffeebd GIT binary patch literal 783 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∓V7`Zm(8 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

urW0yQ{r12)tk5X@S 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 M07*qoM6N<$f_km2z5oCK literal 0 HcmV?d00001 diff --git a/docs/tutorials/assets/jrpg/town.tmj b/docs/tutorials/assets/jrpg/town.tmj new file mode 100644 index 0000000..85e3901 --- /dev/null +++ b/docs/tutorials/assets/jrpg/town.tmj @@ -0,0 +1,1356 @@ +{ + "compressionlevel": -1, + "height": 20, + "infinite": false, + "layers": [ + { + "data": [ + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 3, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 90, + 91, + 91, + 91, + 91, + 91, + 91, + 91, + 91, + 92, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 171, + 172, + 172, + 172, + 173, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 198, + 199, + 199, + 199, + 200, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 198, + 199, + 199, + 199, + 200, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 198, + 199, + 199, + 199, + 200, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 225, + 226, + 226, + 226, + 227, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 117, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 118, + 119, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 144, + 145, + 145, + 145, + 145, + 145, + 145, + 145, + 145, + 146, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 28, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 29, + 30, + 55, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 56, + 57 + ], + "height": 20, + "id": 1, + "name": "ground", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 30, + "x": 0, + "y": 0 + }, + { + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 233, + 0, + 0, + 0, + 0, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 260, + 0, + 0, + 0, + 0, + 261, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 73, + 73, + 73, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 100, + 284, + 100, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 261, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 233, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 260, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 73, + 73, + 73, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 100, + 284, + 100, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 234, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 261, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 233, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 233, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 260, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 260, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "height": 20, + "id": 2, + "name": "decoration", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 30, + "x": 0, + "y": 0 + }, + { + "draworder": "topdown", + "id": 3, + "name": "actors", + "objects": [ + { + "height": 32, + "id": 1, + "name": "player", + "properties": [ + { + "name": "character", + "type": "string", + "value": "jrpg_player" + }, + { + "name": "state", + "type": "int", + "value": 17 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 224, + "y": 256 + }, + { + "height": 32, + "id": 2, + "name": "shopkeeper", + "properties": [ + { + "name": "character", + "type": "string", + "value": "jrpg_shopkeeper" + }, + { + "name": "state", + "type": "int", + "value": 17 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 80, + "y": 128 + }, + { + "height": 32, + "id": 3, + "name": "elder", + "properties": [ + { + "name": "character", + "type": "string", + "value": "jrpg_elder" + }, + { + "name": "state", + "type": "int", + "value": 17 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 272, + "y": 112 + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0 + } + ], + "nextlayerid": 4, + "nextobjectid": 4, + "orientation": "orthogonal", + "properties": [ + { + "name": "physics.model", + "type": "string", + "value": "arcade" + }, + { + "name": "physics.gravity.x", + "type": "float", + "value": 0.0 + }, + { + "name": "physics.gravity.y", + "type": "float", + "value": 0.0 + } + ], + "renderorder": "right-down", + "tiledversion": "1.8.2", + "tileheight": 16, + "tilesets": [ + { + "columns": 27, + "firstgid": 1, + "image": "tiles.png", + "imageheight": 288, + "imagewidth": 432, + "margin": 0, + "name": "rpg_urban", + "spacing": 0, + "tilecount": 486, + "tileheight": 16, + "tilewidth": 16 + } + ], + "tilewidth": 16, + "type": "map", + "version": "1.8", + "width": 30 +} diff --git a/docs/tutorials/assets/sidescroller/character_ss_coin.json b/docs/tutorials/assets/sidescroller/character_ss_coin.json new file mode 100644 index 0000000..cb5c598 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/character_ss_coin.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json b/docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json new file mode 100644 index 0000000..298adf3 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json b/docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json new file mode 100644 index 0000000..1e7d151 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/sidescroller/character_ss_player.json b/docs/tutorials/assets/sidescroller/character_ss_player.json new file mode 100644 index 0000000..81acf3f --- /dev/null +++ b/docs/tutorials/assets/sidescroller/character_ss_player.json @@ -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" + } + ] +} diff --git a/docs/tutorials/assets/sidescroller/coin.png b/docs/tutorials/assets/sidescroller/coin.png new file mode 100644 index 0000000000000000000000000000000000000000..b1ab9aa6987a7c3ea26ef5562385dd16e6c5585b GIT binary patch literal 122 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@&H$efS0Md=ufqTLj|=yE&IWQA zOM?7@862M7NCR@@JzX3_BrYc>NZe{LHMzogjDx{=N7!1Ybu+3t(Oy*gx Q094B0>FVdQ&MBb@0PBt+Z~y=R literal 0 HcmV?d00001 diff --git a/docs/tutorials/assets/sidescroller/hazard.png b/docs/tutorials/assets/sidescroller/hazard.png new file mode 100644 index 0000000000000000000000000000000000000000..72f6ebc5bb6f7758fbcf4657e39866c19a8377a5 GIT binary patch literal 246 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 literal 0 HcmV?d00001 diff --git a/docs/tutorials/assets/sidescroller/level1.tmj b/docs/tutorials/assets/sidescroller/level1.tmj new file mode 100644 index 0000000..6093bc1 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/level1.tmj @@ -0,0 +1,1448 @@ +{ + "compressionlevel": -1, + "height": 15, + "infinite": false, + "layers": [ + { + "data": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 12, + 13, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 3, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "height": 15, + "id": 1, + "name": "background", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 40, + "x": 0, + "y": 0 + }, + { + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 28, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 28, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 28, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 28, + 29, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 31, + 0, + 0, + 0, + 0, + 0, + 0, + 33, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 35, + 0, + 0, + 0, + 0, + 0, + 0, + 37, + 0, + 0, + 0, + 0, + 0, + 0, + 21, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 23, + 0, + 0, + 0, + 21, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 23, + 15, + 15, + 15, + 25, + 15, + 15, + 15, + 15, + 15, + 15, + 25, + 15, + 15, + 15, + 15, + 15, + 15, + 25, + 0, + 0, + 0, + 15, + 15, + 15, + 25, + 15, + 15, + 15, + 15, + 15, + 15, + 25, + 15, + 15, + 15, + 15, + 15, + 15, + 25, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 0, + 0, + 0, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15 + ], + "height": 15, + "id": 2, + "name": "terrain", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 40, + "x": 0, + "y": 0 + }, + { + "draworder": "topdown", + "id": 3, + "name": "actors", + "objects": [ + { + "height": 32, + "id": 1, + "name": "player", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_player" + }, + { + "name": "state", + "type": "int", + "value": 20 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 32, + "y": 160 + }, + { + "height": 32, + "id": 2, + "name": "coin1", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_coin" + }, + { + "name": "state", + "type": "int", + "value": 16 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 128, + "y": 112 + }, + { + "height": 32, + "id": 3, + "name": "coin2", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_coin" + }, + { + "name": "state", + "type": "int", + "value": 16 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 224, + "y": 80 + }, + { + "height": 32, + "id": 4, + "name": "coin3", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_coin" + }, + { + "name": "state", + "type": "int", + "value": 16 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 352, + "y": 96 + }, + { + "height": 32, + "id": 5, + "name": "coin4", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_coin" + }, + { + "name": "state", + "type": "int", + "value": 16 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 448, + "y": 64 + }, + { + "height": 32, + "id": 6, + "name": "blob1", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_hazard_blob" + }, + { + "name": "state", + "type": "int", + "value": 18 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 400, + "y": 160 + }, + { + "height": 32, + "id": 7, + "name": "moth1", + "properties": [ + { + "name": "character", + "type": "string", + "value": "ss_hazard_moth" + }, + { + "name": "state", + "type": "int", + "value": 18 + } + ], + "rotation": 0, + "type": "actor", + "visible": true, + "width": 32, + "x": 272, + "y": 80 + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0 + } + ], + "nextlayerid": 4, + "nextobjectid": 8, + "orientation": "orthogonal", + "properties": [ + { + "name": "physics.model", + "type": "string", + "value": "arcade" + }, + { + "name": "physics.gravity.y", + "type": "float", + "value": 900.0 + }, + { + "name": "physics.drag.y", + "type": "float", + "value": 1.5 + } + ], + "renderorder": "right-down", + "tiledversion": "1.8.2", + "tileheight": 16, + "tilesets": [ + { + "columns": 10, + "firstgid": 1, + "image": "tiles.png", + "imageheight": 96, + "imagewidth": 160, + "margin": 0, + "name": "pixel_line_platformer", + "spacing": 0, + "tilecount": 60, + "tileheight": 16, + "tilewidth": 16 + } + ], + "tilewidth": 16, + "type": "map", + "version": "1.8", + "width": 40 +} diff --git a/docs/tutorials/assets/sidescroller/player.png b/docs/tutorials/assets/sidescroller/player.png new file mode 100644 index 0000000000000000000000000000000000000000..26436350db626a3a369eb44c6b455c90b82a1e86 GIT binary patch literal 383 zcmeAS@N?(olHy`uVBq!ia0vp^2Y^_Cg&9b0+m^KrNQnmcgt!7}oul6vryq0nivN>g zeaFP|&;7Hav1wvJ3C5BjzhDN3XE)M-Yza>n#}JR>Z>KosH9PRQ_I7M~=3LA$r^LQR zZh>z*^O8ls@B9~;84~zpqiNo*S84y4GJnYJ&zDeG1qQ#&7dn66F3T%bQFc9rmoe@9 z_VO8w$IWXP)~!#^VW_ZcwD^9N?d@{aJymaCA8BE1e*XH1uv*1F-yFugBwhpIn{75h z(ceC|HCBCV6#F2w|M4H41iz#5`x!nRiC)81@Yz}OBkzU>pCY!WDb208EwAMM^W39N zb!;~cyytZJFJ{=In;o*;P4!*;K}Uu%whbN*r+-Lkvd>X003YF0{{R3XbEJh00004XF*Lt006O% z3;baP0000jP)t-s0002mq=L(VUjLYRLrPzWs}up3#@sYH|GgmmXgmM!<1EGY*`$KD z)ja&)%@y-wjQ{`u0d!JMQvg8b*k%9#1oufqK~z}7-I%{?N;8EGZ!;K*a`w=+BX==wmz4^ktqdvC-x3R&JyuCj8^1vVz zz}$G7yuE&}1B)Q&1eT&ezNFNbsTCLmLSl=OnwSc20T$sQz!>X5Ysdju7#s}j>cb*n z7WN*LlGL(#8CX=naL6b#-?CDA0u2grfrH^dv%)6;#kVgS^3#B$E9}gNeFvp29|l8! zE%~s3wK6g=7$qMTVZEeB0kY&8vh_S3aJ(&mMmi#}5UH?e&4<`nk8OKO9eI0JY1HGv z!2SwpR|exLEciy%l~v5Y?E9y$?vS&dz%GUKWM?|{<=Wv+D{EIsK%1q?#!JenQr_$5 zLjg>(Yzu#JLO#U(lReL}PgxwRK((Bfv3jgaYM^q=%jk~;NSxY5jj|O8_onwJmjI?D z8g+e~f#p+@Iy{!cQb{>+L*8JZvA@ig_5dDcUq4V=g55`vPKA25={g_&4-{JL>j`;$ zh01eqUOfjr9Vi~a0Z6_yfRC4tm$qx2`2gVOU%&rg;q4s93e07o?`uGd%H6yLNd5%y z?xRcuAXC}EwgGVsd?dh+^vcg46kr|vj&I*!~j_MccB^Ru+F1E_KaMn!tZ5Z zyp2(P(7f>PekXx0mzND7X0b9LY!LuVsaJqFq4SVGm|yt*Epf{wR)9N)19uKqfMp3@ z2^nu?AXiEP1CMru;0W+v$9ZsGW72)qTCNmQ@GfW2YMSi1LRKpSQ_x9{ z4XIWWKVJ^_r35B^yAqjK~sL}wej{i*IUMS2Ig<2nydU*`cP*Ywk8G+G# zbbknmw=)7^>7nWUl!Btjb-|0WZdyBf2oi@O6&Ot;cI$}VT9l8kG@#nf4nz@hMMHW8 zFMC&dtV%n!)J!1@Whh!l>srCF$2lI@Rij-yFw%e#06hulHQKcSE0DWDN1{9lm@Y)0 z$34BB2^iCSC^h`cU;3{O0d67-5b|NA>@>RIKq${lA%ij+dGyvCGUUTb+3Dt;@A@!3 z{M(D0nQ2f!*^~ko2=bo}r1_9ELh4b8c99e=}vGoydPpAjGnssj zW(7VEdA6w^XY_32^1BMt%jWv-^fh`u>}f4a&t?Jr3 +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "jrpg.h" + +/** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */ +#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60) +/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */ +#define JRPG_FRAME_BUDGET_MS 16 + +/* + * The scripted walk `--demo` drives. The player starts at (224, 256) and the + * elder stands at (272, 112), so this is: right for a second, up for two and a + * bit, talk, try to walk while frozen, dismiss, walk away. Every one of those + * is a path the interactive game has and a headless run would otherwise never + * touch. + * + * Frame Key Down What it is for + */ +static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = { + { 10, SDLK_RIGHT, true }, /* the per-facing walk animation */ + { 70, SDLK_RIGHT, false }, + { 75, SDLK_UP, true }, /* up the map, past the buildings */ + { 205, SDLK_UP, false }, + { 215, SDLK_SPACE, true }, /* the elder is in range: open the box */ + { 216, SDLK_SPACE, false }, + { 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */ + { 245, SDLK_LEFT, false }, + { 255, SDLK_SPACE, true }, /* dismiss */ + { 256, SDLK_SPACE, false }, + { 265, SDLK_DOWN, true }, /* and walk away */ + { 285, SDLK_DOWN, false } +}; + +#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0])) + +static long frame_limit = 0; +static bool demo = false; +static bool running = true; +static int exitstatus = 0; +static bool lowfps_warned = false; + +/** + * @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. + * + * akgl_game.fps is a completed-second average, so it reads 0 for the first + * second of every process -- which is under the threshold, so the default hook + * logs a line on every frame until the first second is up. The hook exists to + * be replaced; a real game sheds work here rather than talking about it. + */ +static void lowfps_quiet(void) +{ + if ( lowfps_warned == false ) { + lowfps_warned = true; + SDL_Log("Frame rate is under 30 and this game does nothing about it"); + } +} + +/** + * @brief Read `--frames N` and `--demo` off the command line. + * + * @param argc Argument count, from `main`. + * @param argv Argument vector, from `main`. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p argv is `NULL`. + * @throws AKERR_VALUE If `--frames` is last, or its argument is not a number. + */ +static akerr_ErrorContext *parse_args(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + int i = 0; + int frames = 0; + + FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv"); + + for ( i = 1; i < argc; i++ ) { + if ( strcmp(argv[i], "--demo") == 0 ) { + demo = true; + } else if ( strcmp(argv[i], "--frames") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count"); + } + i += 1; + PASS(errctx, aksl_atoi(argv[i], &frames)); + frame_limit = frames; + } else { + FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]"); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Bring the library up, load the town, and bind the controls. + * + * The order below is the one game.h documents and the only one that works. + * Three things in it are not obvious and are what chapter 20 spends its time + * on: the properties have to be set between akgl_game_init and + * akgl_render_2d_init, because that is what reads them; the physics backend has + * to be initialized by hand, because akgl_game_init does not; and the assets + * have to be loaded sprites-then-characters-then-map. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *startup(void) +{ + PREPARE_ERROR(errctx); + akgl_Control talk; + + // Required before akgl_game_init, which refuses to run without all three: + // the window title, SDL's application metadata and the savegame + // compatibility check are built from them. + PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", sizeof(akgl_game.name) - 1)); + PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1)); + PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.jrpg", sizeof(akgl_game.uri) - 1)); + + PASS(errctx, akgl_game_init()); + akgl_game.lowfpsfunc = &lowfps_quiet; + + PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH)); + PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT)); + PASS(errctx, akgl_render_2d_init(akgl_renderer)); + + // akgl_game_init points akgl_physics at akgl_default_physics and stops + // there. That storage is BSS, so all four of its method pointers are NULL, + // and akgl_game_update calls `akgl_physics->simulate(...)` without checking + // it: a NULL function pointer, and a segmentation fault on frame one rather + // than an error context. + // + // town.tmj happens to declare its own physics, and jrpg_world_load switches + // to the backend that builds -- so removing this line alone leaves *this* + // program working, and removing it along with that switch is a SIGSEGV on + // frame one. Measured, by removing both. This line is what makes the + // program correct for a map that declares no physics, which is most of + // them. Nothing calls the factory for you either way. A top-down game wants + // the arcade backend with no gravity, which is what the property defaults + // already give. + PASS(errctx, akgl_physics_init_arcade(akgl_physics)); + + PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE)); + + PASS(errctx, jrpg_world_load()); + PASS(errctx, jrpg_world_populate()); + + // The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in + // one call. Keyboard id 0 and gamepad id 0 mean "the first of each". + PASS(errctx, akgl_controller_default(0, "player", 0, 0)); + + // And one binding of our own on the same map. A binding is a struct, copied + // in, so a stack local is fine. handler_off has to be non-NULL -- + // akgl_controller_handle_event does not check it before calling it -- and + // the button is set to something no gamepad reports, because the gamepad + // and keyboard arms of the match are evaluated together. + PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk))); + talk.event_on = SDL_EVENT_KEY_DOWN; + talk.event_off = SDL_EVENT_KEY_UP; + talk.key = SDLK_SPACE; + talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID; + talk.handler_on = &jrpg_cmhf_talk; + talk.handler_off = &jrpg_cmhf_ignore; + PASS(errctx, akgl_controller_pushmap(0, &talk)); + + SUCCEED_RETURN(errctx); +} + +/** + * @brief Feed the scripted keystrokes due on this frame into the control maps. + * + * Synthesized `SDL_Event`s rather than direct calls to the handlers, so the + * demo goes through akgl_controller_handle_event, the control map lookup and + * the binding -- the same path a real key takes. A test that skipped that would + * not be testing the thing that breaks. + * + * @param frameno The frame about to be drawn. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *demo_step(long frameno) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + size_t i = 0; + + for ( i = 0; i < JRPG_DEMO_STEPS; i++ ) { + if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) { + continue; + } + PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event))); + if ( JRPG_DEMO_SCRIPT[i].down ) { + event.type = SDL_EVENT_KEY_DOWN; + } else { + event.type = SDL_EVENT_KEY_UP; + } + event.key.which = 0; + event.key.key = JRPG_DEMO_SCRIPT[i].key; + PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief One frame: events, camera, the library's own update, the box, present. + * + * akgl_game_update is update-every-actor, step the physics, draw the world. It + * does *not* clear or present the target, so the frame is bracketed by the + * backend's own frame_start and frame_end, and anything drawn between the + * update and frame_end lands on top of the world. + * + * @param frameno The frame number, for the demo script. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *frame(long frameno) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + akgl_Iterator opflags = { + .flags = AKGL_ITERATOR_OP_UPDATE, + .layerid = 0 + }; + + while ( SDL_PollEvent(&event) ) { + if ( event.type == SDL_EVENT_QUIT ) { + running = false; + } + PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); + } + if ( demo ) { + PASS(errctx, demo_step(frameno)); + // Drive the clock rather than sleeping on it. akgl_physics_simulate + // measures dt from gravity_time, which is a public field, so setting it + // one fixed step into the past makes every frame worth exactly 1/60 of + // a second of simulated time no matter how fast the loop actually runs. + // tests/physics_sim.c does the same, for the same reason: a simulated + // second should not cost a real one. + akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS; + } + + PASS(errctx, jrpg_camera_follow()); + + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + // Every failure path out of akgl_game_update returns with the game state + // mutex still held. SDL's mutexes are recursive and this loop is single + // threaded, so the next frame would not deadlock -- but it would be running + // on top of a frame that did not finish. Treat a failed frame as terminal. + PASS(errctx, akgl_game_update(&opflags)); + PASS(errctx, jrpg_textbox_draw()); + PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + + SUCCEED_RETURN(errctx); +} + +/** + * @brief Give back what has to be given back, in the order that works. + * + * There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three + * lines here are load-bearing and the third is a deliberate omission: + * + * - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept in + * an SDL property registry, and `SDL_Quit` destroys the registry -- taking + * the last reference to every font still in it with no way left to close + * them. + * - akgl_tilemap_release is **not** called. Its layer loop destroys + * `tilesets[i].texture` rather than `layers[i].texture`, so it double-frees + * every tileset texture and never frees an image layer's, and it NULLs + * nothing, so a second call is a use-after-free. TODO.md, "Known and still + * open" item 2. `SDL_Quit` reclaims the textures correctly; calling the + * function that is supposed to would be worse than not. + * - The pools are static storage. There is nothing to free and the process is + * about to exit. + */ +static void teardown(void) +{ + IGNORE(akgl_text_unloadallfonts()); + if ( akgl_window != NULL ) { + SDL_DestroyWindow(akgl_window); + akgl_window = NULL; + } + SDL_Quit(); +} + +int main(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + akgl_Actor *player = NULL; + long frameno = 0; + uint64_t started = 0; + uint64_t spent = 0; + + ATTEMPT { + CATCH(errctx, parse_args(argc, argv)); + CATCH(errctx, startup()); + // Taken here rather than after FINISH: the registry is an SDL property + // set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards + // finds nothing. The actor itself is in a static pool and outlives both. + player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); + + // The loop is the last thing in this ATTEMPT block on purpose. CATCH + // reports failure by `break`ing, and a `break` inside a loop binds to + // the loop rather than to the block -- so a failing frame leaves the + // loop and falls straight into CLEANUP, which is what is wanted. Put + // anything after this loop and it would run after a failure instead. + // src/tilemap.c carries the same note over the same shape. + while ( running ) { + started = SDL_GetTicks(); + CATCH(errctx, frame(frameno)); + frameno += 1; + if ( (frame_limit > 0) && (frameno >= frame_limit) ) { + running = false; + } + if ( demo == false ) { + spent = SDL_GetTicks() - started; + if ( spent < JRPG_FRAME_BUDGET_MS ) { + SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent)); + } + } + } + } CLEANUP { + teardown(); + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish"); + // Set a flag and act on it after FINISH. A bare `return` from inside a + // HANDLE block leaves before RELEASE_ERROR and leaks the context's pool + // slot; AGENTS.md spells that out, and an example is a bad place to + // teach it wrong. + exitstatus = 1; + } FINISH_NORETURN(errctx); + + // Enough for the smoke run to be read rather than merely passed. A run that + // exits 0 having drawn nothing looks exactly like one that worked. + if ( player != NULL ) { + printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y); + } else { + printf("jrpg: %ld frames, no player\n", frameno); + } + return exitstatus; +} diff --git a/examples/jrpg/jrpg.h b/examples/jrpg/jrpg.h new file mode 100644 index 0000000..1c708b0 --- /dev/null +++ b/examples/jrpg/jrpg.h @@ -0,0 +1,188 @@ +/** + * @file jrpg.h + * @brief Shared declarations for the JRPG tutorial game. + * + * Every function with external linkage in this program is declared here, the + * way AGENTS.md requires of the library itself. It is a three-translation-unit + * program and it would compile without the header; declaring them anyway is + * what keeps a signature from drifting between the definition and the call. + * + * Everything this program exports carries the `jrpg_` prefix. `static` helpers + * drop it, because the prefix exists only to avoid collisions with libakgl, + * SDL and libc -- the same rule, for the same reason. + */ + +#ifndef _JRPG_JRPG_H_ +#define _JRPG_JRPG_H_ + +#include +#include +#include +#include +#include + +/* + * Where the game's data lives. Both are absolute paths baked in by + * examples/jrpg/CMakeLists.txt, because the tutorial has to run from the build + * tree, from the source tree, and from wherever CTest happens to put its + * working directory. The fallbacks are what a reader compiling this by hand + * from the repository root would want. + */ +#ifndef JRPG_ASSET_DIR +#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg" +#endif +#ifndef JRPG_FONT_FILE +#define JRPG_FONT_FILE "tests/assets/akgl_test_mono.ttf" +#endif + +/* + * The screen size is written as text because that is the only form + * akgl_set_property takes, and akgl_render_2d_init copies it onto `akgl_camera` + * on the way past. Everything below reads the camera rather than a second copy + * of the same two numbers. + */ +#define JRPG_SCREEN_WIDTH "320" +#define JRPG_SCREEN_HEIGHT "240" + +/** @brief Registry name of the one font this game loads. */ +#define JRPG_FONT_NAME "jrpg" +/** @brief Point size the font is rasterized at. A size is baked into the handle. */ +#define JRPG_FONT_SIZE 12 + +/** + * @brief Index of the tile layer this game treats as solid. + * + * An index, not a name, because akgl_TilemapLayer has no `name` member: the + * loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data, + * and drops the name Tiled wrote. A map cannot say "the layer called + * collision", so the game and the map agree on a number. See chapter 20. + */ +#define JRPG_LAYER_SOLID 1 + +/** @brief Longest line an NPC can say, terminator included. */ +#define JRPG_TEXTBOX_MAX_TEXT 256 + +/** @brief Registry name of the party member this game attaches to the player. */ +#define JRPG_FOLLOWER_NAME "companion" + +/** + * @brief One entry in the scripted demo the headless smoke run drives. + * + * The keystrokes go in through akgl_controller_handle_event like any other + * event, so the smoke run exercises the same binding, state and physics path a + * player does rather than a separate one written to be testable. + */ +typedef struct { + long frame; /**< Frame number this step fires on. */ + SDL_Keycode key; /**< Key to synthesize. */ + bool down; /**< True for a press, false for a release. */ +} jrpg_ScriptStep; + +/** + * @brief Load every sprite, then every character, then the town map. + * + * In that order, and the order is not a preference: akgl_character_load_json + * resolves each sprite name through #AKGL_REGISTRY_SPRITE, and + * akgl_tilemap_load resolves each `character` property through + * #AKGL_REGISTRY_CHARACTER while it spawns the map's actor objects. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void); +/** + * @brief Fix up the actors the map spawned, and attach the party member. + * + * Clears `movement_controls_face` on everything -- without which the default + * `facefunc` erases the facing bit the map set and every NPC stops being drawn + * on frame one -- installs the blocking movement logic on the player, and + * builds the follower as a child actor. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_REGISTRY If the map spawned no actor named "player". + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void); +/** + * @brief Centre the camera on the player, clamped to the edges of the map. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_REGISTRY If there is no actor named "player". + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void); +/** + * @brief The player's `movementlogicfunc`: the library default, plus walls. + * + * libakgl implements no collision at all -- akgl_physics_arcade_collide raises + * AKERR_API, akgl_physics_simulate never calls `collide`, and + * akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall + * writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open, + * which is the documented way to tell the simulator to skip an actor's tick. + * + * @param obj The actor to compute acceleration for. Required, along with its + * `basechar`. + * @param dt Seconds this step covers. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`. + * @throws AKGL_ERR_LOGICINTERRUPT While a conversation is open. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt); +/** + * @brief The follower's `renderfunc`: akgl_actor_render with the parent detached. + * + * The guard for a defect. akgl_physics_simulate writes a child's `x` as + * `parent->x + vx` -- an absolute world position -- and akgl_actor_render then + * draws it at `parent->x + obj->x`, adding the parent's position a second time. + * Nulling `parent` for the duration of the draw takes the branch that does not + * add it. See chapter 20. + * + * @param obj The child actor to draw. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p obj is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj); +/** + * @brief Control handler: talk to whoever is standing nearby, or close the box. + * @param obj The actor the control map drives -- the player. Required. + * @param event The event that triggered this. Required, but not read. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event); +/** + * @brief Control handler that does nothing, for the release half of a binding. + * + * akgl_controller_handle_event does not check a matched binding's handler + * pointer before calling it, so a binding with a `NULL` `handler_off` is a + * crash rather than an error. Every binding gets both halves. + * + * @param obj The actor the control map drives. Required. + * @param event The event that triggered this. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event); + +/** + * @brief Put a line of dialogue on screen and freeze the world. + * @param text The line to show. Required. Truncated at #JRPG_TEXTBOX_MAX_TEXT. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p text is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text); +/** @brief Dismiss the text box. */ +void jrpg_textbox_close(void); +/** @brief True while a line of dialogue is on screen. */ +bool jrpg_textbox_showing(void); +/** + * @brief Draw the panel and its text, if there is anything to draw. + * + * Called after akgl_game_update, so it lands on top of the world rather than + * under it, and in screen coordinates rather than world ones -- + * akgl_text_rendertextat does not go through the camera, which is what a HUD + * wants. + * + * @return `NULL` on success -- including when the box is closed -- otherwise an + * error context owned by the caller. + * @throws AKGL_ERR_REGISTRY If #JRPG_FONT_NAME is not a loaded font. + */ +akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void); + +#endif // _JRPG_JRPG_H_ diff --git a/examples/jrpg/textbox.c b/examples/jrpg/textbox.c new file mode 100644 index 0000000..edac8de --- /dev/null +++ b/examples/jrpg/textbox.c @@ -0,0 +1,125 @@ +/** + * @file textbox.c + * @brief The dialogue panel: two rectangles and a string, drawn over the world. + * + * Text in libakgl is immediate mode. Every akgl_text_rendertextat call + * rasterizes the string through SDL_ttf, uploads it as a texture, blits it and + * destroys the texture again -- there is no cached glyph atlas and no text + * object to hold on to. That is the wrong shape for a page of static prose + * redrawn sixty times a second and exactly the right shape for this: one short + * line, on screen only while somebody is talking. + */ + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "jrpg.h" + +/* + * Colour R G B A + */ +static const SDL_Color TEXTBOX_FILL = { 24, 20, 37, 235 }; +static const SDL_Color TEXTBOX_EDGE = { 240, 236, 214, 255 }; +static const SDL_Color TEXTBOX_INK = { 240, 236, 214, 255 }; + +/** @brief Pixels between the panel and the edges of the screen. */ +#define TEXTBOX_MARGIN 8.0f +/** @brief Panel height in pixels. Two lines of 12pt monospace, plus padding. */ +#define TEXTBOX_HEIGHT 56.0f +/** @brief Pixels between the panel's edge and the text inside it. */ +#define TEXTBOX_PADDING 8.0f + +static char textbox_text[JRPG_TEXTBOX_MAX_TEXT]; +static bool textbox_visible = false; + +bool jrpg_textbox_showing(void) +{ + return textbox_visible; +} + +void jrpg_textbox_close(void) +{ + textbox_visible = false; +} + +akerr_ErrorContext *jrpg_textbox_open(char *text) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text"); + // aksl_strncpy, not strncpy: this is a fixed-width field, and strncpy at + // exactly the field width leaves it unterminated. Bounded at one less than + // the field so an over-long line truncates rather than being refused. + PASS(errctx, + aksl_strncpy( + textbox_text, + sizeof(textbox_text), + text, + sizeof(textbox_text) - 1 + )); + textbox_visible = true; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_textbox_draw(void) +{ + PREPARE_ERROR(errctx); + TTF_Font *font = NULL; + SDL_FRect panel; + + if ( textbox_visible == false ) { + SUCCEED_RETURN(errctx); + } + // akgl_text_rendertextat refuses the empty string -- SDL_ttf reports "Text + // has zero width" and the library passes that on as AKERR_NULLPOINTER, + // while akgl_text_measure accepts it. The two disagree, so anything drawing + // a line that might be empty checks for it. TODO.md, "Known and still + // open". + if ( textbox_text[0] == '\0' ) { + SUCCEED_RETURN(errctx); + } + + // Fonts live in a registry under a name, not in an akgl type. There is no + // handle to keep, and no reference counting either: whoever calls + // akgl_text_unloadfont invalidates every pointer anybody else fetched. + // Fetching it per frame is the cheap way to stay honest about that. + font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL); + FAIL_ZERO_RETURN( + errctx, + font, + AKGL_ERR_REGISTRY, + "No font registered as \"%s\"", + JRPG_FONT_NAME + ); + + // Screen coordinates, from the camera's size rather than from a second copy + // of the window dimensions. akgl_render_2d_init put them there. + panel.x = TEXTBOX_MARGIN; + panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN); + panel.h = TEXTBOX_HEIGHT; + panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h; + + PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL)); + PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE)); + PASS(errctx, + akgl_text_rendertextat( + font, + textbox_text, + TEXTBOX_INK, + (int)(panel.w - (2.0f * TEXTBOX_PADDING)), + (int)(panel.x + TEXTBOX_PADDING), + (int)(panel.y + TEXTBOX_PADDING) + )); + + SUCCEED_RETURN(errctx); +} diff --git a/examples/jrpg/world.c b/examples/jrpg/world.c new file mode 100644 index 0000000..8f7edac --- /dev/null +++ b/examples/jrpg/world.c @@ -0,0 +1,458 @@ +/** + * @file world.c + * @brief The content pipeline: sprites, characters, the town map, and what the + * map's actors do once they exist. + * + * Almost nothing here is game logic. It is the four steps between a directory + * of JSON and a world with people standing in it, in the one order that works, + * plus the two hooks -- a `movementlogicfunc` and a `renderfunc` -- that a game + * has to supply because the library does not. + */ + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "jrpg.h" + +/* + * The three character templates and the twenty-four sprites that dress them. + * The file names are a product of these three tables rather than a list of + * twenty-four strings, because that is what the naming convention *is*: one + * sprite per character, per motion, per facing. A missing combination shows up + * as a load failure naming the file, not as art that silently never appears. + */ + +/* Character template Sprite name prefix */ +static char *CAST[] = { + "player", /* jrpg_player jrpg_player_* */ + "elder", /* jrpg_elder jrpg_elder_* */ + "shopkeeper" /* jrpg_shopkeeper jrpg_shopkeeper_* */ +}; + +static char *MOTIONS[] = { "idle", "walk" }; +static char *FACINGS[] = { "up", "down", "left", "right" }; + +#define CAST_COUNT (sizeof(CAST) / sizeof(CAST[0])) +#define MOTION_COUNT (sizeof(MOTIONS) / sizeof(MOTIONS[0])) +#define FACING_COUNT (sizeof(FACINGS) / sizeof(FACINGS[0])) + +/** @brief One person in the town who has something to say. */ +typedef struct { + char *actor; /**< Registry key, which is the `name` of the map object that spawned them. */ + char *line; /**< What they say. */ +} jrpg_Townsfolk; + +/* + * Actor (the map object's name) What they say + */ +static jrpg_Townsfolk TOWNSFOLK[] = { + { "elder", "ELDER: The old road north is closed. Nothing to be done about it,\nand nothing beyond it worth the walk." }, + { "shopkeeper", "SHOPKEEPER: Nothing in stock but tiles and good intentions.\nCome back when I have inventory." } +}; + +#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0])) + +/** @brief How close the player has to stand, in pixels between sprite centres. */ +#define TALK_RANGE 64.0f + +/* + * The player's footprint, in pixels from the top-left of a 32x32 frame. A + * character stands on the bottom third of their sprite, so that is the part + * that has to fit through a doorway -- testing the whole frame would make every + * corridor two tiles wide. + * + * 0 32 + * +------------------------------+ 0 + * | | + * | (art) | + * | | + * | +----------------+ | 20 FEET_TOP + * | | footprint | | + * +------+----------------+------+ 30 FEET_TOP + FEET_H + * 6 26 + * FEET_X FEET_X + FEET_W + */ +#define FEET_X 6.0f +#define FEET_W 20.0f +#define FEET_TOP 20.0f +#define FEET_H 10.0f + +/** @brief Where the party member walks, in pixels relative to the player. */ +#define FOLLOWER_OFFSET_X (-14.0f) +#define FOLLOWER_OFFSET_Y (10.0f) + +/** + * @brief Load one sprite definition by its three naming components. + * + * @param cast Character stem, e.g. `"player"`. + * @param motion `"idle"` or `"walk"`. + * @param facing `"up"`, `"down"`, `"left"` or `"right"`. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *sprite_load(char *cast, char *motion, char *facing) +{ + PREPARE_ERROR(errctx); + char path[PATH_MAX]; + int written = 0; + + FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast"); + FAIL_ZERO_RETURN(errctx, motion, AKERR_NULLPOINTER, "motion"); + FAIL_ZERO_RETURN(errctx, facing, AKERR_NULLPOINTER, "facing"); + + PASS(errctx, + aksl_snprintf( + &written, + path, + sizeof(path), + "%s/sprite_jrpg_%s_%s_%s.json", + JRPG_ASSET_DIR, + cast, + motion, + facing + )); + PASS(errctx, akgl_sprite_load_json(path)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Load one character definition by its stem. + * @param cast Character stem, e.g. `"player"`. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *character_load(char *cast) +{ + PREPARE_ERROR(errctx); + char path[PATH_MAX]; + int written = 0; + + FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast"); + PASS(errctx, + aksl_snprintf( + &written, + path, + sizeof(path), + "%s/character_jrpg_%s.json", + JRPG_ASSET_DIR, + cast + )); + PASS(errctx, akgl_character_load_json(path)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_world_load(void) +{ + PREPARE_ERROR(errctx); + char path[PATH_MAX]; + int written = 0; + size_t c = 0; + size_t m = 0; + size_t f = 0; + + // Sprites first. A character JSON names its sprites by registry key and + // akgl_character_load_json refuses one it cannot find, so loading the two + // the other way round fails on every mapping in the file. + for ( c = 0; c < CAST_COUNT; c++ ) { + for ( m = 0; m < MOTION_COUNT; m++ ) { + for ( f = 0; f < FACING_COUNT; f++ ) { + PASS(errctx, sprite_load(CAST[c], MOTIONS[m], FACINGS[f])); + } + } + } + for ( c = 0; c < CAST_COUNT; c++ ) { + PASS(errctx, character_load(CAST[c])); + } + + // And the map last: loading it creates an actor for every `actor` object in + // its object layer, and each of those resolves a character by name. + PASS(errctx, + aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR)); + PASS(errctx, akgl_tilemap_load(path, akgl_gamemap)); + + // The map declares its own physics -- `physics.model` and zero gravity on + // both axes -- and akgl_tilemap_load builds a backend for it and sets + // use_own_physics. It does not *switch* to it: akgl_game_update simulates + // through the global akgl_physics, whatever that points at. Honouring the + // map is the caller's job, and this is it. + if ( akgl_gamemap->use_own_physics ) { + akgl_physics = &akgl_gamemap->physics; + } + + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_world_populate(void) +{ + PREPARE_ERROR(errctx); + akgl_Actor *player = NULL; + akgl_Actor *follower = NULL; + int i = 0; + + // Every actor the map spawned, including the player. + // + // akgl_actor_initialize sets movement_controls_face, and the default + // facefunc it installs clears every facing bit and then sets the one + // matching a *movement* bit. An NPC has no movement bits, so on the first + // frame its state falls from ALIVE|FACE_DOWN (17) to ALIVE (16), which is + // a state combination no character JSON maps a sprite to -- and an actor + // with no sprite for its state is skipped rather than reported. Every NPC + // in the town disappears on frame one, silently, and so does the player as + // soon as they stop walking. + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + if ( akgl_heap_actors[i].refcount == 0 ) { + continue; + } + akgl_heap_actors[i].movement_controls_face = false; + } + + player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); + FAIL_ZERO_RETURN( + errctx, + player, + AKGL_ERR_REGISTRY, + "town.tmj spawned no actor named \"player\"" + ); + player->movementlogicfunc = &jrpg_actor_logic_walk; + + // The party member. Nothing in the map creates it, and nothing has to: an + // actor is a pool object with a name, and the character it instantiates is + // already registered. This one borrows the elder's template, which is the + // whole point of splitting instance from template. + PASS(errctx, akgl_heap_next_actor(&follower)); + PASS(errctx, akgl_actor_initialize(follower, JRPG_FOLLOWER_NAME)); + PASS(errctx, akgl_actor_set_character(follower, "jrpg_elder")); + follower->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN); + follower->movement_controls_face = false; + follower->visible = true; + follower->layer = player->layer; + follower->renderfunc = &jrpg_follower_render; + PASS(errctx, player->addchild(player, follower)); + + // A child's velocity fields are read as a fixed offset from the parent + // rather than as a velocity: akgl_physics_simulate snaps a child to + // `parent->x + vx` and never simulates it. Set after addchild, because + // addchild is what makes them mean an offset. + follower->vx = FOLLOWER_OFFSET_X; + follower->vy = FOLLOWER_OFFSET_Y; + + SUCCEED_RETURN(errctx); +} + +/** + * @brief Is the map cell containing this pixel one the player cannot enter? + * + * Two rules, and both of them are the game's rather than the library's. The + * outer ring of the map is solid, because nothing else stops an actor walking + * off the edge of the world. Everything else is the decoration layer: a cell + * with a tile in it is a building, a tree or a lamp post, and a cell with 0 in + * it is open ground. + * + * @param px Position in map pixels along x. + * @param py Position in map pixels along y. + * @return True when the cell blocks movement. + */ +static bool cell_solid(float32_t px, float32_t py) +{ + int tx = 0; + int ty = 0; + + tx = (int)(px / (float32_t)akgl_gamemap->tilewidth); + ty = (int)(py / (float32_t)akgl_gamemap->tileheight); + + if ( (tx <= 0) || (ty <= 0) || + (tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) { + return true; + } + return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0); +} + +/** + * @brief Would an actor whose frame is at (x, y) have its feet in a wall? + * + * Tests the four corners of the footprint. Four corners is enough only because + * the footprint is smaller than a tile in both directions; a bigger one would + * step over a single-cell wall between two of its corners. + * + * @param x Candidate frame position along x, in map pixels. + * @param y Candidate frame position along y, in map pixels. + * @return True when any corner of the footprint lands in a solid cell. + */ +static bool feet_blocked(float32_t x, float32_t y) +{ + float32_t left = x + FEET_X; + float32_t right = x + FEET_X + FEET_W; + float32_t top = y + FEET_TOP; + float32_t bottom = y + FEET_TOP + FEET_H; + + return (cell_solid(left, top) || + cell_solid(right, top) || + cell_solid(left, bottom) || + cell_solid(right, bottom)); +} + +akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "obj->basechar"); + + // A conversation freezes the world. AKGL_ERR_LOGICINTERRUPT raised from a + // movementlogicfunc means "skip the rest of this tick for this actor", and + // akgl_physics_simulate swallows it in a HANDLE block -- so gravity, drag, + // the velocity recompute and the move are all skipped and the frame carries + // on. Thrust has already been integrated by the time this runs, so it is + // zeroed here; leaving it would let it accumulate while frozen and lurch on + // the first step after the box closes. The movement bits go too, so the + // walk animation stops rather than marching on the spot -- which does mean + // a direction held across the close has to be pressed again. + if ( jrpg_textbox_showing() ) { + obj->tx = 0.0f; + obj->ty = 0.0f; + AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_ALL); + FAIL_RETURN( + errctx, + AKGL_ERR_LOGICINTERRUPT, + "%s does not move while a conversation is open", + (char *)obj->name + ); + } + + // Everything the default hook does -- re-copy the character's speed limits, + // turn the movement bits into signed acceleration -- is still wanted. This + // hook adds to it rather than replacing it. + PASS(errctx, akgl_actor_logic_movement(obj, dt)); + + // Where this step would put us. akgl_physics_simulate has already + // integrated thrust for this step and capped it against the character's + // speed ellipse, and it computes velocity as `e + t` -- and with the town's + // zero gravity and zero drag, `e` stays zero, so `v` is `t`. That is what + // makes the prediction exact rather than approximate. A map with gravity + // would have to account for `ey` here as well. + // + // Axis at a time, so walking into a wall diagonally slides along it rather + // than stopping dead. + if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) { + obj->tx = 0.0f; + obj->ax = 0.0f; + } + if ( feet_blocked(obj->x, obj->y + (obj->ty * dt)) ) { + obj->ty = 0.0f; + obj->ay = 0.0f; + } + + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_follower_render(akgl_Actor *obj) +{ + PREPARE_ERROR(errctx); + akgl_Actor *parent = NULL; + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + + // The guard. akgl_physics_simulate has already written this actor's x and y + // as `parent->x + vx` and `parent->y + vy`, which is an absolute world + // position; akgl_actor_render adds the parent's position to it a second + // time. Detaching the parent for the duration of the draw takes the branch + // that does not. CLEANUP puts it back on every path, including the failing + // one -- an actor left with a NULL parent would be simulated as a free + // agent on the very next step. + parent = obj->parent; + obj->parent = NULL; + ATTEMPT { + CATCH(errctx, akgl_actor_render(obj)); + } CLEANUP { + obj->parent = parent; + } PROCESS(errctx) { + } FINISH(errctx, true); + + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_camera_follow(void) +{ + PREPARE_ERROR(errctx); + akgl_Actor *player = NULL; + float32_t mapw = 0.0f; + float32_t maph = 0.0f; + + player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); + FAIL_ZERO_RETURN(errctx, player, AKGL_ERR_REGISTRY, "No actor named \"player\""); + + mapw = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth); + maph = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight); + + akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f); + akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f); + + if ( akgl_camera->x < 0.0f ) { + akgl_camera->x = 0.0f; + } + if ( akgl_camera->y < 0.0f ) { + akgl_camera->y = 0.0f; + } + if ( akgl_camera->x > (mapw - akgl_camera->w) ) { + akgl_camera->x = mapw - akgl_camera->w; + } + if ( akgl_camera->y > (maph - akgl_camera->h) ) { + akgl_camera->y = maph - akgl_camera->h; + } + + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + akgl_Actor *npc = NULL; + size_t i = 0; + float32_t dx = 0.0f; + float32_t dy = 0.0f; + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + + if ( jrpg_textbox_showing() ) { + jrpg_textbox_close(); + SUCCEED_RETURN(errctx); + } + + for ( i = 0; i < TOWNSFOLK_COUNT; i++ ) { + npc = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, TOWNSFOLK[i].actor, NULL); + if ( npc == NULL ) { + continue; + } + dx = npc->x - obj->x; + dy = npc->y - obj->y; + if ( sqrtf((dx * dx) + (dy * dy)) > TALK_RANGE ) { + continue; + } + PASS(errctx, jrpg_textbox_open(TOWNSFOLK[i].line)); + SUCCEED_RETURN(errctx); + } + + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + SUCCEED_RETURN(errctx); +} diff --git a/examples/sidescroller/CMakeLists.txt b/examples/sidescroller/CMakeLists.txt new file mode 100644 index 0000000..572ea6d --- /dev/null +++ b/examples/sidescroller/CMakeLists.txt @@ -0,0 +1,48 @@ +# The sidescroller tutorial game. +# +# A real target, built by default, so docs/19-tutorial-sidescroller.md cannot +# quote a program that does not compile -- and registered as a headless smoke +# test, so it cannot quote one that does not run. + +get_filename_component(SS_ASSET_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../../docs/tutorials/assets/sidescroller" ABSOLUTE) + +add_executable(sidescroller + main.c + collision.c + player.c + actors.c +) + +target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_options(sidescroller PRIVATE ${AKGL_WARNING_FLAGS}) + +# Baked in rather than looked up at runtime, so the smoke test can be launched +# from any working directory. `--assets DIR` overrides it for a reader who has +# moved the art somewhere else. +target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"") + +target_link_libraries(sidescroller + PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf + SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) + +# A vendored build leaves the SDL satellite libraries in per-project +# subdirectories that are not on the loader's default search path, which is the +# same problem AKGL_VENDORED_RPATH solves for the test executables. +if(AKGL_VENDORED_DEPENDENCIES) + set_target_properties(sidescroller PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") +endif() + +# Four seconds of scripted play under the headless drivers: the level loads, the +# player walks and jumps, the swept collision runs against real terrain, and the +# program tears down and exits 0. A tutorial that stops working fails here rather +# than in front of a reader. +# +# add_test() rather than _add_test(): the root CMakeLists.txt shadows it only +# while this project is top-level, and by the time examples/ is added the +# suppression has already been lifted. An embedded build gets the builtin. +add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay) +set_tests_properties(example_sidescroller PROPERTIES + TIMEOUT 120 + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +) diff --git a/examples/sidescroller/actors.c b/examples/sidescroller/actors.c new file mode 100644 index 0000000..e0a19b1 --- /dev/null +++ b/examples/sidescroller/actors.c @@ -0,0 +1,183 @@ +/** + * @file actors.c + * @brief Everything level1.tmj places that is not the player. + * + * None of these are created here. The map's object layer creates all seven + * actors, registers them under the names Tiled gave them, and binds each one to + * the character its `character` property names -- so this file's whole job is to + * find them again by name and give them behaviour. + * + * Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is the one + * status in libakgl that is not a failure. Raised from a `movementlogicfunc` it + * means "skip the rest of this tick for me": no gravity, no drag, no move for + * that actor this step, and akgl_physics_simulate carries on to the next one. + * That is exactly what an actor that has just placed itself wants. + */ + +#include + +#include + +#include +#include + +#include "sidescroller.h" + +/** @brief The blob's collision box, offset into its 32x32 frame. */ +static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f }; + +/** + * @brief Per-actor data for everything in this file, one slot per actor. + * + * A fixed table rather than an allocation, for the same reason the library has + * pools rather than a `malloc`: the level places a known number of things, and a + * game that cannot run out of memory at runtime is one fewer failure mode. + */ +static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT]; + +/** + * @brief Look an actor up by the name its Tiled object carried. + * + * A miss here means the map and the code disagree about what the level + * contains, which is worth failing on rather than working around. + */ +static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + + *dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL); + FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name); + SUCCEED_RETURN(errctx); +} + +/** + * @brief A `movementlogicfunc` for something that does not move at all. + * + * The coins need this. Their character declares no speed and no acceleration, so + * they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity` + * accumulates into `ey` for every actor it is handed, so a coin left to the + * default logic falls out of the level along with everything else. + * + * Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its + * step. It is not an error and nothing logs it. + */ +static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt) +{ + PREPARE_ERROR(errctx); + (void)dt; + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name); +} + +/** + * @brief The blob: walk, and turn around at a wall or a ledge. + * + * This one stays inside the physics step -- it walks on the ground under the + * map's gravity like the player does, and uses the same swept resolution. + */ +static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt) +{ + ss_ActorData *data = NULL; + ss_Contact contact; + float32_t probe_x = 0.0f; + float32_t probe_y = 0.0f; + bool floor_ahead = false; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData"); + data = (ss_ActorData *)obj->actorData; + + AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL)); + if ( data->facing < 0.0f ) { + AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT)); + } else { + AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT)); + } + /* Signs the acceleration from the movement bits just set. */ + PASS(errctx, akgl_actor_logic_movement(obj, dt)); + + PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact)); + + /* One pixel past the leading edge of the box and one pixel below its feet: + * no floor there means the next step walks off. */ + probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f; + if ( data->facing < 0.0f ) { + probe_x = obj->x + ss_blob_body.x - 1.0f; + } else { + probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f; + } + PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead)); + + if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) { + data->facing = -data->facing; + /* The resolution already zeroed `tx` on a blocked axis; zero it on the + * ledge path too, or the blob keeps its old momentum through the turn. */ + obj->tx = 0.0f; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief The moth: a figure-eight around where the map put it. + * + * It flies, so it wants no gravity -- and the map's physics has gravity, because + * the player needs it. Rather than fighting the backend the moth writes its own + * position and then opts out of the rest of the step, which is the second thing + * #AKGL_ERR_LOGICINTERRUPT is for. + */ +static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt) +{ + ss_ActorData *data = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData"); + data = (ss_ActorData *)obj->actorData; + + data->phase += dt; + obj->x = data->home_x + (sinf(data->phase) * 64.0f); + obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f); + + AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL); + if ( cosf(data->phase) < 0.0f ) { + AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT); + } else { + AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT); + } + + FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name); +} + +akerr_ErrorContext *ss_actors_bind(void) +{ + char name[32]; + int count = 0; + int i = 0; + PREPARE_ERROR(errctx); + + for ( i = 0; i < SS_COIN_COUNT; i++ ) { + PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1))); + PASS(errctx, find_actor((char *)&name, &ss_game.coins[i])); + ss_game.coins[i]->movementlogicfunc = &ss_static_movement; + } + + PASS(errctx, find_actor("blob1", &ss_game.hazards[0])); + PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body)); + ss_hazard_data[0].home_x = ss_game.hazards[0]->x; + ss_hazard_data[0].home_y = ss_game.hazards[0]->y; + ss_hazard_data[0].facing = -1.0f; + ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0]; + ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement; + + PASS(errctx, find_actor("moth1", &ss_game.hazards[1])); + ss_hazard_data[1].home_x = ss_game.hazards[1]->x; + ss_hazard_data[1].home_y = ss_game.hazards[1]->y; + ss_hazard_data[1].facing = 1.0f; + ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1]; + ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement; + + SUCCEED_RETURN(errctx); +} diff --git a/examples/sidescroller/collision.c b/examples/sidescroller/collision.c new file mode 100644 index 0000000..08dafe1 --- /dev/null +++ b/examples/sidescroller/collision.c @@ -0,0 +1,383 @@ +/** + * @file collision.c + * @brief The collision libakgl does not have. + * + * This file exists because of a gap, and the gap is worth stating exactly: + * + * - `akgl_physics_arcade_collide` raises `AKERR_API` with the message "Not + * implemented". + * - `akgl_physics_simulate` never calls `collide` at all -- not for the arcade + * backend, not for the null one. The vtable slot exists and the simulation + * does not use it. + * - `akgl_physics_arcade_move` is `position += velocity * dt` and nothing else. + * It does not clamp to the map, consult the tilemap, or test anything. + * + * So an actor walks through a wall and off the edge of the world, and the only + * hook that runs inside the physics step is the actor's own `movementlogicfunc`. + * That is where this game's collision lives, and everything here is called from + * there. + * + * The awkward part is the ordering. `movementlogicfunc` runs *before* gravity, + * drag, the velocity recomputation and `move`, so it cannot look at where the + * actor ended up -- the step has not happened yet. ss_collide_predict therefore + * repeats the arithmetic akgl_physics_simulate is about to do, and the sweep + * resolves against that predicted position. Get the prediction wrong and the + * actor is resolved against a step it never takes. + */ + +#include + +#include + +#include +#include + +#include "sidescroller.h" + +/** + * @brief Half a pixel-thousandth, taken off the far edge of a box before it is + * turned into tile indices. + * + * A box whose right edge sits exactly on a tile boundary does not overlap the + * tile on the far side of it. Without this, an actor standing flush against a + * wall reads as inside it, and the sweep snaps it back a tile every frame. + */ +#define SS_COLLIDE_EPSILON 0.001f + +/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */ +#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f) + +/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */ +#define SS_COLLIDE_SETTLE_TILES 4 + +static akgl_TilemapLayer *ss_terrain = NULL; + +/** + * @brief Is this map cell solid? + * + * Off the left or right edge of the map is solid, so the level has walls at its + * ends. Off the top or the bottom is not: the sky is open and the pit in the + * middle of level1.tmj has to be fallable-into, which is the whole point of it. + */ +static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run"); + + if ( (tilex < 0) || (tilex >= ss_terrain->width) ) { + *dest = true; + } else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) { + *dest = false; + } else { + /* A tile layer's data is global tile ids in row-major order, and 0 is + * the empty cell. Any tile at all on the terrain layer is solid: the + * level says what is solid by which layer the tile is drawn on. */ + *dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map) +{ + int i = 0; + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map"); + + ss_terrain = NULL; + for ( i = 0; i < map->numlayers; i++ ) { + if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) && + (map->layers[i].id == SS_TERRAIN_LAYER_ID) ) { + ss_terrain = &map->layers[i]; + } + } + FAIL_ZERO_RETURN( + errctx, + ss_terrain, + AKERR_KEY, + "Map has no tile layer with id %d to collide against", + SS_TERRAIN_LAYER_ID + ); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest) +{ + int x0 = 0; + int x1 = 0; + int y0 = 0; + int y1 = 0; + int tilex = 0; + int tiley = 0; + bool solid = false; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + FAIL_NONZERO_RETURN( + errctx, + ((box->w <= 0.0f) || (box->h <= 0.0f)), + AKERR_VALUE, + "Collision box is %fx%f; both sides must be positive", + box->w, + box->h + ); + + *dest = false; + x0 = (int)floorf(box->x / SS_TILE_SIZE); + x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE); + y0 = (int)floorf(box->y / SS_TILE_SIZE); + y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE); + + for ( tiley = y0; tiley <= y1; tiley++ ) { + for ( tilex = x0; tilex <= x1; tilex++ ) { + PASS(errctx, solid_tile(tilex, tiley, &solid)); + if ( solid == true ) { + *dest = true; + SUCCEED_RETURN(errctx); + } + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy) +{ + float32_t ex = 0.0f; + float32_t ey = 0.0f; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx"); + FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy"); + FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics"); + + /* + * Everything below is akgl_physics_simulate's own arithmetic, in its own + * order: gravity onto the environmental term, then drag off it, then + * velocity as environmental plus thrust, then position plus velocity times + * dt. The `!= 0` guards are the library's too -- it skips each axis whose + * constant is zero rather than multiplying by it -- and are repeated here + * because dropping them would make a zero-gravity axis pick up drag. + * + * This mirrors the *arcade* backend. Against the null backend gravity does + * nothing, so the prediction reduces to `(ex + tx) * dt`, which is still + * right. + */ + ex = obj->ex; + ey = obj->ey; + if ( akgl_physics->gravity_x != 0 ) { + ex -= (float32_t)akgl_physics->gravity_x * dt; + } + if ( akgl_physics->gravity_y != 0 ) { + ey += (float32_t)akgl_physics->gravity_y * dt; + } + if ( akgl_physics->drag_x != 0 ) { + ex -= ex * (float32_t)akgl_physics->drag_x * dt; + } + if ( akgl_physics->drag_y != 0 ) { + ey -= ey * (float32_t)akgl_physics->drag_y * dt; + } + *dx = (ex + obj->tx) * dt; + *dy = (ey + obj->ty) * dt; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain. + * + * Each axis is tested on its own, which is what lets an actor slide along a wall + * instead of sticking to it, and the sub-step is capped at half a tile so a + * fast fall cannot pass through a floor between two samples. At 900 px/s^2 with + * the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one + * step, which is nearly two tiles -- so this is not a theoretical concern. + * + * On a blocked axis the box is snapped to the tile boundary it was about to + * cross rather than simply not moved, so an actor lands flush on a floor at + * whatever speed it arrives. + */ +static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest) +{ + SDL_FRect trial; + float32_t span = 0.0f; + float32_t stepx = 0.0f; + float32_t stepy = 0.0f; + int steps = 0; + int i = 0; + bool solid = false; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + + span = fabsf(dx); + if ( fabsf(dy) > span ) { + span = fabsf(dy); + } + steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1; + stepx = dx / (float32_t)steps; + stepy = dy / (float32_t)steps; + + for ( i = 0; i < steps; i++ ) { + if ( stepx != 0.0f ) { + trial = *box; + trial.x += stepx; + PASS(errctx, ss_collide_box_blocked(&trial, &solid)); + if ( solid == true ) { + if ( stepx > 0.0f ) { + box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w; + } else { + box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE; + } + stepx = 0.0f; + dest->blocked_x = true; + } else { + box->x = trial.x; + } + } + if ( stepy != 0.0f ) { + trial = *box; + trial.y += stepy; + PASS(errctx, ss_collide_box_blocked(&trial, &solid)); + if ( solid == true ) { + if ( stepy > 0.0f ) { + box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h; + } else { + box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE; + } + stepy = 0.0f; + dest->blocked_y = true; + } else { + box->y = trial.y; + } + } + if ( (stepx == 0.0f) && (stepy == 0.0f) ) { + break; + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest) +{ + SDL_FRect box; + SDL_FRect probe; + float32_t dx = 0.0f; + float32_t dy = 0.0f; + bool solid = false; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + + dest->blocked_x = false; + dest->blocked_y = false; + dest->grounded = false; + + PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy)); + + box.x = obj->x + body->x; + box.y = obj->y + body->y; + box.w = body->w; + box.h = body->h; + PASS(errctx, sweep(&box, dx, dy, dest)); + + /* + * Only a blocked axis is written back. The free axis is left for + * akgl_physics_arcade_move to advance by exactly the amount this function + * predicted -- writing it here as well would move the actor twice. + * + * `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own + * effort; a blocked axis has to give up both, or the actor keeps pressing + * into the wall and the next step's prediction is wrong by everything it + * accumulated while stuck. + * + * The environmental term is not set to zero, it is set to *minus one step of + * gravity*, and that is not a nicety. Zeroing it leaves the step about to add + * `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a + * quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is + * still fatal: the box now overlaps the floor tile, so the *horizontal* + * sweep on the next step finds itself blocked wherever it is, and the actor + * is snapped back a whole tile every time it tries to walk. Pre-loading the + * cancellation leaves the actor resting exactly on the surface instead. + */ + if ( dest->blocked_x == true ) { + obj->x = box.x - body->x; + obj->ex = (float32_t)akgl_physics->gravity_x * dt; + obj->tx = 0.0f; + } + if ( dest->blocked_y == true ) { + obj->y = box.y - body->y; + obj->ey = -(float32_t)akgl_physics->gravity_y * dt; + obj->ty = 0.0f; + } + + /* + * Standing on a floor is not a state the library records, so it is measured: + * one pixel below where the box will be at the end of this step. Note that + * zeroing `ey` above does not stop gravity -- the step still adds + * `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of + * fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes + * it straight back off, so a standing actor rests within a pixel of the + * surface forever rather than sinking through it. + */ + probe = box; + probe.y += 1.0f; + PASS(errctx, ss_collide_box_blocked(&probe, &solid)); + dest->grounded = solid; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body) +{ + SDL_FRect box; + bool solid = false; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body"); + + /* + * A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an + * actor can start out with its box partly inside a step -- level1.tmj puts + * the player at x=32 and a block at tiles (3,11), which is under the right + * half of the player's frame. + * + * The swept resolution cannot fix that. It stops an actor *entering* + * terrain and has nothing to say about one that began inside it; what it + * does instead is refuse every horizontal move, because the box is already + * blocked wherever it goes. So a spawn point gets lifted clear once, before + * the first step, a tile at a time. + */ + for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) { + box.x = obj->x + body->x; + box.y = obj->y + body->y; + box.w = body->w; + box.h = body->h; + PASS(errctx, ss_collide_box_blocked(&box, &solid)); + if ( solid == false ) { + SUCCEED_RETURN(errctx); + } + obj->y -= (float32_t)SS_TILE_SIZE; + SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y); + } + FAIL_RETURN( + errctx, + AKERR_VALUE, + "%s spawned inside more than %d tiles of terrain at %f, %f", + (char *)obj->name, + SS_COLLIDE_SETTLE_TILES, + obj->x, + obj->y + ); +} diff --git a/examples/sidescroller/main.c b/examples/sidescroller/main.c new file mode 100644 index 0000000..13edd0b --- /dev/null +++ b/examples/sidescroller/main.c @@ -0,0 +1,456 @@ +/** + * @file main.c + * @brief Startup, the frame loop, and teardown for the sidescroller tutorial. + * + * The order everything happens in is the point of this file. libakgl has one + * startup sequence that works, documented at the top of `include/akgl/game.h`, + * and three of its steps are ones a reader gets wrong the first time: + * + * - the configuration properties have to be set *before* the renderer and the + * physics backend are initialized, because both read them; + * - `akgl_game_init` does **not** choose a physics backend, so the application + * has to; + * - characters name sprites and maps name characters, so the assets load + * sprites, then characters, then the map -- and the map creates the actors. + */ + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sidescroller.h" + +/** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */ +#ifndef SS_ASSET_DIR +#define SS_ASSET_DIR "." +#endif + +/** @brief Longest asset path this program will build. */ +#define SS_PATH_MAX 1024 + +ss_Game ss_game; + +/** + * @brief The sprites, loaded in this order. + * + * Sprites first and characters second is not a preference. A character's JSON + * names its sprites by registry name and `akgl_character_load_json` looks each + * one up as it reads the mapping, so a character loaded first fails with + * `AKERR_NULLPOINTER` on the first sprite it cannot find. + */ +static char *ss_sprite_files[] = { + "sprite_ss_player_idle_left.json", /* one frame, held */ + "sprite_ss_player_idle_right.json", + "sprite_ss_player_run_left.json", /* four frames at 90 ms */ + "sprite_ss_player_run_right.json", + "sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */ + "sprite_ss_player_jump_right.json", + "sprite_ss_coin.json", + "sprite_ss_hazard_blob.json", + "sprite_ss_hazard_moth.json", + NULL +}; + +/** @brief The characters. Each one binds state bitmasks to the sprites above. */ +static char *ss_character_files[] = { + "character_ss_player.json", + "character_ss_coin.json", + "character_ss_hazard_blob.json", + "character_ss_hazard_moth.json", + NULL +}; + +/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */ +static int ss_failed = 0; + +/** + * @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame. + * + * The default is `akgl_game_lowfps`, and it fires on **every frame** the frame + * rate is under 30 -- including every frame of the first second of the process, + * because `akgl_game.fps` is a completed-second average and reads 0 until the + * first second is up. The hook exists to be replaced; the point of it is that a + * game can shed work rather than log about it. This one has nothing to shed. + */ +static void ss_lowfps(void) +{ +} + +/** + * @brief Join the asset directory and a file name into @p dest. + * + * `aksl_snprintf` rather than `snprintf`, because a path that does not fit has + * to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it + * reports itself later as a missing file with a name nobody wrote. + */ +static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size) +{ + int count = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir"); + FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Bring the library up and open the window. + * + * `akgl_game.name`, `.version` and `.uri` are filled in first because + * `akgl_game_init` refuses to run without all three: the window title, SDL's + * application metadata and the savegame compatibility check are built from them. + */ +static akerr_ErrorContext *startup(void) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, aksl_strncpy( + (char *)&akgl_game.name, + sizeof(akgl_game.name), + "libakgl sidescroller tutorial", + sizeof(akgl_game.name) - 1)); + PASS(errctx, aksl_strncpy( + (char *)&akgl_game.version, + sizeof(akgl_game.version), + "1.0.0", + sizeof(akgl_game.version) - 1)); + PASS(errctx, aksl_strncpy( + (char *)&akgl_game.uri, + sizeof(akgl_game.uri), + "net.aklabs.libakgl.sidescroller", + sizeof(akgl_game.uri) - 1)); + + PASS(errctx, akgl_game_init()); + akgl_game.lowfpsfunc = &ss_lowfps; + + /* Properties before the renderer: akgl_render_2d_init reads both of these + * out of the registry, and an unset one defaults to the string "0", which + * asks SDL for a zero-sized window. */ + PASS(errctx, akgl_set_property("game.screenwidth", "960")); + PASS(errctx, akgl_set_property("game.screenheight", "480")); + PASS(errctx, akgl_render_2d_init(akgl_renderer)); + + /* + * libakgl draws in map pixels and has no scale factor of its own -- the only + * scaling it applies is the tilemap's perspective band, which this map does + * not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more + * than that. SDL's logical presentation is the answer: the game renders a + * 480x240 view and SDL scales it up by whole multiples to fill the window. + */ + FAIL_ZERO_RETURN( + errctx, + SDL_SetRenderLogicalPresentation( + akgl_renderer->sdl_renderer, + SS_VIEW_WIDTH, + SS_VIEW_HEIGHT, + SDL_LOGICAL_PRESENTATION_INTEGER_SCALE), + AKGL_ERR_SDL, + "%s", + SDL_GetError() + ); + + /* akgl_render_2d_init sized the camera from the window. The view is what the + * camera looks through, so it has to say the same thing. */ + akgl_camera->x = 0.0f; + akgl_camera->y = 0.0f; + akgl_camera->w = (float32_t)SS_VIEW_WIDTH; + akgl_camera->h = (float32_t)SS_VIEW_HEIGHT; + + /* + * akgl_game_init does NOT choose a physics backend. It points akgl_physics + * at akgl_default_physics, which is zeroed storage -- all four of its method + * pointers are NULL -- and never initializes it. There is no + * `physics.engine` property either, whatever physics.h says. Skip this call + * and the first akgl_game_update calls through a NULL `simulate`. + * + * The map replaces this backend below with one of its own. It is still done + * here, because a game that loads a map without physics properties gets a + * working backend rather than a crash. + */ + PASS(errctx, akgl_physics_init_arcade(akgl_physics)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Load the sprites, the characters and the level, in that order. + */ +static akerr_ErrorContext *load_level(char *assetdir) +{ + char path[SS_PATH_MAX]; + akgl_Actor *player = NULL; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir"); + + for ( i = 0; ss_sprite_files[i] != NULL; i++ ) { + PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path))); + PASS(errctx, akgl_sprite_load_json((char *)&path)); + } + for ( i = 0; ss_character_files[i] != NULL; i++ ) { + PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path))); + PASS(errctx, akgl_character_load_json((char *)&path)); + } + + /* + * `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static + * storage in the library. That is not an accident and it is not a + * micro-optimisation: sizeof(akgl_Tilemap) is three times a default 8 MiB + * thread stack, so a local one is a segfault before the loader writes a + * byte. + * + * Loading the map creates every `actor` object in its object layers, binds + * each to the character its properties name, and publishes it in + * AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first. + */ + PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path))); + PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap)); + + /* + * The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`, + * so the loader built a backend of its own and set `use_own_physics`. + * Nothing in the library acts on that flag: akgl_game_update steps the + * global akgl_physics and never looks at the map's. Honouring it is this + * line, and it is what lets a swimming level and a walking level differ by + * data rather than by code. + */ + if ( akgl_gamemap->use_own_physics == true ) { + akgl_physics = &akgl_gamemap->physics; + SDL_Log( + "Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s", + akgl_physics->gravity_y, + akgl_physics->drag_y, + (akgl_physics->gravity_y / akgl_physics->drag_y)); + } + + PASS(errctx, ss_collide_bind(akgl_gamemap)); + + player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); + FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player"); + PASS(errctx, ss_player_bind(player)); + PASS(errctx, ss_actors_bind()); + PASS(errctx, ss_player_controls(0, "player")); + + /* + * Re-stamp the clock the simulation measures against. Everything above -- + * nine sprite files, four characters, a map and its tileset image -- happened + * between the backend being created and the first step, and dt is measured + * from `gravity_time`. The bound on `physics.max_timestep` would catch it, + * at the cost of one visibly slow-motion frame. + */ + akgl_physics->gravity_time = SDL_GetTicksNS(); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Centre the camera on the player, clamped to the level. + * + * The camera is a plain `SDL_FRect` in map pixels that the library reads; moving + * it is the whole of scrolling. It is floored to a whole pixel because + * `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles + * to show, and a camera that is fractionally different every frame makes the + * tile grid shimmer. + */ +static akerr_ErrorContext *update_camera(void) +{ + float32_t limit = 0.0f; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player"); + FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera"); + + limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w; + akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f); + if ( akgl_camera->x > limit ) { + akgl_camera->x = limit; + } + if ( akgl_camera->x < 0.0f ) { + akgl_camera->x = 0.0f; + } + akgl_camera->x = (float32_t)((int)akgl_camera->x); + akgl_camera->y = 0.0f; + SUCCEED_RETURN(errctx); +} + +/** + * @brief One frame: events, then the camera, then the library's own tick. + * + * `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It + * does not clear or present, so the frame is bracketed by the backend's + * `frame_start` and `frame_end` here. + */ +static akerr_ErrorContext *frame(bool *running) +{ + SDL_Event event; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running"); + + while ( SDL_PollEvent(&event) == true ) { + if ( event.type == SDL_EVENT_QUIT ) { + *running = false; + } + /* Every event, unconditionally: one that no control map binds is not an + * error, it is a call that did nothing. */ + PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); + } + + ss_game.frame += 1; + if ( ss_game.autoplay == true ) { + PASS(errctx, ss_player_autoplay(ss_game.frame)); + } + PASS(errctx, update_camera()); + + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + /* + * Note the failure contract: akgl_game_update takes the game state lock and + * every one of its failure paths returns with the lock still held. SDL + * mutexes are recursive so a single-threaded loop does not deadlock on the + * next frame, but a frame that failed has left the world half-stepped. + * Treat it as terminal, which is what PASS does here. + */ + PASS(errctx, akgl_game_update(NULL)); + PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *run(int frames) +{ + bool running = true; + PREPARE_ERROR(errctx); + + while ( running == true ) { + PASS(errctx, frame(&running)); + if ( (frames > 0) && (ss_game.frame >= frames) ) { + running = false; + } + /* A crude frame limiter. A game with a window on a real display should + * ask SDL for vsync instead; this one has to work under the dummy video + * driver, where there is nothing to sync to. */ + SDL_Delay(16); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Give back what the process is holding. + * + * **There is no `akgl_game_shutdown`.** Teardown is the application's, and the + * order matters in one place: `akgl_text_unloadallfonts` has to run before + * `TTF_Quit` or `SDL_Quit`, because those destroy the fonts underneath the + * registry that still points at them. + * + * The pools are not walked beyond the actors. They are static storage in a + * process that is exiting, and the sprites and characters are still referenced + * by each other; a game that loads a second level has to unwind that properly, + * and this one does not pretend to. + */ +static void shutdown_game(void) +{ + int i = 0; + + IGNORE(akgl_text_unloadallfonts()); + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + if ( akgl_heap_actors[i].refcount > 0 ) { + IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i])); + } + } + /* Guarded: this runs from CLEANUP, which is reached even when startup failed + * before akgl_game_init pointed akgl_gamemap at anything. */ + if ( akgl_gamemap != NULL ) { + IGNORE(akgl_tilemap_release(akgl_gamemap)); + } + TTF_Quit(); + MIX_Quit(); + SDL_Quit(); +} + +static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames) +{ + const char *env = NULL; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir"); + FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames"); + + env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES"); + if ( env != NULL ) { + PASS(errctx, aksl_atoi((char *)env, frames)); + } + for ( i = 1; i < argc; i++ ) { + if ( strcmp(argv[i], "--autoplay") == 0 ) { + ss_game.autoplay = true; + } else if ( strcmp(argv[i], "--frames") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count"); + PASS(errctx, aksl_atoi(argv[i], frames)); + } else if ( strcmp(argv[i], "--assets") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory"); + *assetdir = argv[i]; + } else { + FAIL_RETURN( + errctx, + AKERR_VALUE, + "usage: sidescroller [--assets DIR] [--frames N] [--autoplay]" + ); + } + } + SUCCEED_RETURN(errctx); +} + +int main(int argc, char *argv[]) +{ + char *assetdir = SS_ASSET_DIR; + int frames = 0; + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, parse_args(argc, argv, &assetdir, &frames)); + CATCH(errctx, startup()); + CATCH(errctx, load_level(assetdir)); + CATCH(errctx, run(frames)); + } CLEANUP { + shutdown_game(); + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run"); + /* + * Set a flag rather than returning: leaving a HANDLE block early skips + * FINISH's RELEASE_ERROR and leaks the context's pool slot, and the + * 129th such call aborts the process. AGENTS.md spells it out. + */ + ss_failed = 1; + /* + * FINISH_NORETURN rather than FINISH: FINISH expands a + * `return __err_context` that an int-returning function cannot compile, + * even on a branch that cannot be reached. + */ + } FINISH_NORETURN(errctx); + + SDL_Log( + "sidescroller: %d frames, %d of %d coins, %d deaths", + ss_game.frame, + ss_game.coins_taken, + SS_COIN_COUNT, + ss_game.deaths); + return ss_failed; +} diff --git a/examples/sidescroller/player.c b/examples/sidescroller/player.c new file mode 100644 index 0000000..b462619 --- /dev/null +++ b/examples/sidescroller/player.c @@ -0,0 +1,436 @@ +/** + * @file player.c + * @brief The player's two hooks and its control bindings. + * + * Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the + * split between them is the frame's own order. `akgl_game_update` calls every + * actor's `updatefunc`, then steps the physics, then draws -- so per-frame game + * logic that is not movement (picking a coin up, falling in the pit) goes in + * `updatefunc`, and anything that has to happen inside the physics step goes in + * `movementlogicfunc`, which is the only hook the step calls. + * + * Two of the library's documented gaps are worked around here rather than + * papered over. Both are in `TODO.md` under "Arcade physics feel". + */ + +#include + +#include + +#include +#include +#include +#include +#include + +#include "sidescroller.h" + +/** @brief The player's collision box, as an offset into its 32x32 sprite frame. */ +static SDL_FRect ss_player_body = { + .x = SS_PLAYER_BOX_X, + .y = SS_PLAYER_BOX_Y, + .w = SS_PLAYER_BOX_W, + .h = SS_PLAYER_BOX_H +}; + +/** @brief Where the map put the player, so a death can put it back. */ +static ss_ActorData ss_player_data; + +/** + * @brief The rectangle an actor is tested against, given a 32x32 sprite frame. + * + * Smaller than the frame on purpose. Sprite art does not reach the edges, and a + * hazard box the full size of the frame kills a player who is visibly nowhere + * near it. + */ +static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + + dest->x = obj->x + inset; + dest->y = obj->y + inset; + dest->w = 32.0f - (inset * 2.0f); + dest->h = 32.0f - (inset * 2.0f); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Put the player back where the map placed it, at rest. + * + * Everything the simulation carries between steps has to be cleared, not just + * the position: `ey` is where gravity has been accumulating, and a player who + * respawns still holding a full-speed fall lands dead again immediately. + */ +static akerr_ErrorContext *respawn(akgl_Actor *obj) +{ + ss_ActorData *data = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData"); + + data = (ss_ActorData *)obj->actorData; + obj->x = data->home_x; + obj->y = data->home_y; + obj->ex = 0.0f; + obj->ey = 0.0f; + obj->tx = 0.0f; + obj->ty = 0.0f; + obj->vx = 0.0f; + obj->vy = 0.0f; + ss_game.deaths += 1; + SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y); + SUCCEED_RETURN(errctx); +} + +/** + * @brief The player's `movementlogicfunc`: friction, the jump, and terrain. + * + * Called by `akgl_physics_simulate` once per actor per step, *before* gravity, + * drag, the velocity recomputation and `move`. + */ +static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt) +{ + ss_Contact contact; + float32_t friction = 0.0f; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + + /* The default logic still has to run: it is what copies the character's + * speeds onto the actor and signs its acceleration by the movement bits. */ + PASS(errctx, akgl_actor_logic_movement(obj, dt)); + + /* + * Workaround 1: there is no friction anywhere in the arcade backend. + * + * akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction + * stops the actor dead inside one frame -- correct for a top-down Zelda, + * wrong for a sidescroller. This game binds its own `_off` handlers that + * leave `tx` alone (see below) and decays it here instead, faster on the + * ground than in the air. Below a pixel per second it is snapped to zero, + * because an exponential decay never actually arrives. + */ + if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) && + AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) { + friction = SS_FRICTION_AIR; + if ( ss_game.grounded == true ) { + friction = SS_FRICTION_GROUND; + } + obj->tx -= obj->tx * friction * dt; + if ( fabsf(obj->tx) < 1.0f ) { + obj->tx = 0.0f; + } + } + + /* + * A jump is an impulse straight into the environmental term, because `ey` is + * the axis gravity accumulates on and the two have to cancel for the arc to + * come back down. Thrust would not: `ty` is capped against the character's + * `speed_y`, which is 0 for this character, so a jump written as thrust is + * scaled to nothing by the ellipse cap in akgl_physics_simulate. + * + * `ss_game.grounded` is last step's verdict, one frame stale. That is the + * ordering again -- this hook runs before the step it is deciding about -- + * and one frame of coyote time is not a thing a player can feel. + */ + if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) { + obj->ey = -SS_JUMP_SPEED; + } + ss_game.jump_requested = false; + + PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact)); + ss_game.grounded = contact.grounded; + + /* + * The character maps MOVING_UP to the jump sprites, so the state word says + * "in the air" whether the actor is rising or falling. Nothing else reads + * the bit here: `speed_y` and `acceleration_y` are both 0 in + * character_ss_player.json, so the thrust it would authorise is capped to + * nothing. + */ + if ( contact.grounded == true ) { + AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP); + } else { + AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief The player's `updatefunc`: the animation, then what it is touching. + * + * Runs in `akgl_game_update`'s actor sweep, before the physics step and before + * anything is drawn. + */ +static akerr_ErrorContext *ss_player_update(akgl_Actor *obj) +{ + SDL_FRect player; + SDL_FRect other; + bool hit = false; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + + /* Facing, then the animation frame. Replacing a hook does not mean + * reimplementing it. */ + PASS(errctx, akgl_actor_update(obj)); + + /* Off the bottom of the world. Nothing in the library stops an actor + * leaving the map -- akgl_physics_arcade_move does not clamp -- so falling + * out of the level is a thing the game has to notice for itself. */ + if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) { + PASS(errctx, respawn(obj)); + SUCCEED_RETURN(errctx); + } + + PASS(errctx, hitbox(obj, 8.0f, &player)); + + for ( i = 0; i < SS_COIN_COUNT; i++ ) { + if ( ss_game.coins[i] == NULL ) { + continue; + } + PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other)); + PASS(errctx, akgl_collide_rectangles(&player, &other, &hit)); + if ( hit == true ) { + /* + * Giving the pool slot back is what unregisters the actor and stops + * it being drawn; there is no "despawn" call. Releasing another + * actor from inside this sweep is safe -- akgl_game_update re-reads + * `refcount` at the top of every iteration and skips a slot that has + * gone free. + */ + PASS(errctx, akgl_heap_release_actor(ss_game.coins[i])); + ss_game.coins[i] = NULL; + ss_game.coins_taken += 1; + SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT); + } + } + + for ( i = 0; i < SS_HAZARD_COUNT; i++ ) { + if ( ss_game.hazards[i] == NULL ) { + continue; + } + PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other)); + PASS(errctx, akgl_collide_rectangles(&player, &other, &hit)); + if ( hit == true ) { + PASS(errctx, respawn(obj)); + SUCCEED_RETURN(errctx); + } + } + SUCCEED_RETURN(errctx); +} + +/* + * Workaround 2, the release half. + * + * akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax` + * *and* zero `tx`. That last one is the "stops dead" behaviour; these two do + * everything else it does and leave `tx` for ss_player_movement to decay. + */ +static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + obj->ax = 0.0f; + AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *ss_control_right_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + obj->ax = 0.0f; + AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT); + SUCCEED_RETURN(errctx); +} + +/** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */ +static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + ss_game.jump_requested = true; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Cut a rising jump short when the button is let go. + * + * Variable jump height for four lines, and it works because `ey` is an ordinary + * field that nothing else owns between steps. + */ +static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + if ( obj->ey < 0.0f ) { + obj->ey *= 0.4f; + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_player_bind(akgl_Actor *obj) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + + ss_game.player = obj; + + /* Lift the spawn point clear of the step it overlaps *before* recording it, + * so a respawn does not put the player back inside the geometry. */ + PASS(errctx, ss_collide_settle(obj, &ss_player_body)); + ss_player_data.home_x = obj->x; + ss_player_data.home_y = obj->y; + obj->actorData = (void *)&ss_player_data; + + /* + * The default `facefunc` clears every facing bit and then sets one from the + * movement bits -- so an actor that stops moving is left facing nowhere, its + * state drops to bare ALIVE, and character_ss_player.json has no sprite for + * that. The actor becomes invisible while standing still. + * + * Clearing this is the documented way out: akgl_actor_automatic_face leaves + * an actor alone entirely when it is clear, and the facing bits stay + * wherever the control handlers last put them. + */ + obj->movement_controls_face = false; + + /* Replace the hooks after akgl_actor_initialize, never before -- it + * overwrites all six. The tilemap loader has already run it here. */ + obj->movementlogicfunc = &ss_player_movement; + obj->updatefunc = &ss_player_update; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname) +{ + akgl_ControlMap *controlmap = NULL; + akgl_Control control; + SDL_KeyboardID *keyboards = NULL; + SDL_JoystickID *gamepads = NULL; + int count = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname"); + /* akgl_controller_pushmap checks the upper bound and not the lower one, so + * a negative id indexes before the start of akgl_controlmaps. TODO.md, + * "Known and still open" item 11. */ + FAIL_NONZERO_RETURN( + errctx, + ((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)), + AKERR_OUTOFBOUNDS, + "Control map id %d is outside 0..%d", + controlmapid, + (AKGL_MAX_CONTROL_MAPS - 1) + ); + PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control))); + + controlmap = &akgl_controlmaps[controlmapid]; + controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL); + FAIL_ZERO_RETURN( + errctx, + controlmap->target, + AKGL_ERR_REGISTRY, + "Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded", + actorname + ); + + /* + * A control map listens to exactly one keyboard and one gamepad, matched by + * id, and a binding whose id does not match the event's is not consulted. + * That is what keeps two local players on two keyboards apart -- and it is + * also why "the arrow keys do nothing" is usually the wrong id rather than + * the wrong key. + */ + keyboards = SDL_GetKeyboards(&count); + if ( keyboards != NULL ) { + if ( count > 0 ) { + controlmap->kbid = keyboards[0]; + } + SDL_free(keyboards); + } + gamepads = SDL_GetGamepads(&count); + if ( gamepads != NULL ) { + if ( count > 0 ) { + controlmap->jsid = gamepads[0]; + } + SDL_free(gamepads); + } + + /* ---- keyboard ---- */ + control.event_on = SDL_EVENT_KEY_DOWN; + control.event_off = SDL_EVENT_KEY_UP; + + control.key = SDLK_LEFT; + control.handler_on = &akgl_actor_cmhf_left_on; + control.handler_off = &ss_control_left_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); + + control.key = SDLK_RIGHT; + control.handler_on = &akgl_actor_cmhf_right_on; + control.handler_off = &ss_control_right_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); + + control.key = SDLK_SPACE; + control.handler_on = &ss_control_jump_on; + control.handler_off = &ss_control_jump_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); + + /* ---- gamepad ---- */ + /* Clear the keycode first: a keyboard event is matched on `key` whatever + * else the binding carries, and 0 is a keycode like any other. */ + control.key = 0; + control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN; + control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP; + + control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT; + control.handler_on = &akgl_actor_cmhf_left_on; + control.handler_off = &ss_control_left_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); + + control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT; + control.handler_on = &akgl_actor_cmhf_right_on; + control.handler_off = &ss_control_right_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); + + control.button = SDL_GAMEPAD_BUTTON_SOUTH; + control.handler_on = &ss_control_jump_on; + control.handler_off = &ss_control_jump_off; + PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); + + SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *ss_player_autoplay(int frame) +{ + SDL_Event synthetic; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player"); + PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event))); + synthetic.type = SDL_EVENT_KEY_DOWN; + + /* + * The headless smoke run has no keyboard, so it calls the handlers a + * keyboard would have called. They take the actor and an event, and the + * keyboard handlers do not read the event beyond requiring one -- which is + * what makes a scripted run possible at all. + */ + if ( frame == 1 ) { + PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic)); + } + if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) { + ss_game.jump_requested = true; + } + SUCCEED_RETURN(errctx); +} diff --git a/examples/sidescroller/sidescroller.h b/examples/sidescroller/sidescroller.h new file mode 100644 index 0000000..4e2d048 --- /dev/null +++ b/examples/sidescroller/sidescroller.h @@ -0,0 +1,135 @@ +/** + * @file sidescroller.h + * @brief Shared declarations for the sidescroller tutorial game. + * + * The game is four translation units and this is the seam between them: + * + * main.c startup, asset loading, the frame loop, teardown + * collision.c the tile queries and the swept resolution libakgl does not have + * player.c the player's hooks and its control bindings + * actors.c the coins, the patrolling blob, the flying moth + * + * Nothing here is prefixed `akgl_`. That prefix belongs to the library; a game + * built on it takes its own, and this one is `ss_`. + */ + +#ifndef _SIDESCROLLER_H_ +#define _SIDESCROLLER_H_ + +#include + +#include + +#include + +#include +#include +#include +#include +#include + +/* + * The level's geometry, in the units the map is authored in. level1.tmj is 40x15 + * tiles of 16 pixels, so the world is 640x240 pixels and the view is a 480x240 + * window onto it that scrolls horizontally. + */ +#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */ +#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */ +#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */ +#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */ + +/* + * Which layer of the map is solid. + * + * akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the + * layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`, + * `y` and `type`, and nothing else. So a game cannot ask for "the layer called + * terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2. + */ +#define SS_TERRAIN_LAYER_ID 2 + +/* The player's collision box, as an offset into its 32x32 sprite frame. The art + * does not fill the frame edge to edge, and a box the full width of the frame + * catches on doorways the character visibly clears. */ +#define SS_PLAYER_BOX_X 8.0f +#define SS_PLAYER_BOX_Y 0.0f +#define SS_PLAYER_BOX_W 16.0f +#define SS_PLAYER_BOX_H 32.0f + +/* Upward velocity a jump installs directly into the actor's environmental term, + * in pixels per second. Under the map's 900 px/s^2 gravity this peaks a little + * under 100 px -- six tiles -- which is what the platform heights are cut to. */ +#define SS_JUMP_SPEED 420.0f + +/* How fast thrust bleeds away when no direction is held, as a fraction per + * second. The library has no friction at all: see ss_player_friction. */ +#define SS_FRICTION_GROUND 12.0f +#define SS_FRICTION_AIR 1.5f + +/* How many of each kind of thing level1.tmj places. */ +#define SS_COIN_COUNT 4 +#define SS_HAZARD_COUNT 2 + +/* How far the autoplay script waits between jumps, in frames. */ +#define SS_AUTOPLAY_JUMP_PERIOD 45 + +/** + * @brief What one call to ss_collide_resolve found. + * + * `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this + * step; `grounded` says there is solid terrain one pixel under where the actor + * will be when the step finishes, which is the test a jump is gated on. + */ +typedef struct { + bool blocked_x; + bool blocked_y; + bool grounded; +} ss_Contact; + +/** + * @brief Per-actor game data, hung off akgl_Actor::actorData. + * + * The library never reads or frees `actorData`, so it is the place a game puts + * what the library has no field for. These live in a fixed table in actors.c + * rather than being allocated, which is the same discipline the library's own + * pools follow. + */ +typedef struct { + float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */ + float32_t home_y; + float32_t phase; /**< Seconds of flight, for the moth's orbit. */ + float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */ +} ss_ActorData; + +/** @brief The whole game's state. One player, one level, no menus. */ +typedef struct { + akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */ + akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */ + akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */ + int coins_taken; + int deaths; + bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */ + bool grounded; /**< Last step's verdict; what gates the next jump. */ + bool autoplay; /**< Drive the player from a script instead of the keyboard. */ + int frame; /**< Frames drawn so far. */ +} ss_Game; + +extern ss_Game ss_game; + +/* collision.c -- everything akgl_physics_arcade_collide would have done. */ +akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map); +akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest); +akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest); +akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy); +akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest); +akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body); + +/* player.c */ +akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj); +akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname); +akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame); + +/* actors.c */ +akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void); + +#endif // _SIDESCROLLER_H_ diff --git a/scripts/fetch_tutorial_assets.sh b/scripts/fetch_tutorial_assets.sh new file mode 100755 index 0000000..ac27ab9 --- /dev/null +++ b/scripts/fetch_tutorial_assets.sh @@ -0,0 +1,348 @@ +#!/bin/bash +# +# Refresh the tutorial art in docs/tutorials/assets/ from Kenney.nl. +# +# Those PNGs are tracked on purpose. They are what the two tutorial games in +# examples/ draw, what docs/tutorials/PROVENANCE.md accounts for, and what a +# reader copies into their own project -- so the tree has to build and the +# tutorials have to run with no network access at all. This script therefore +# has the same rule mkcontrollermappings.sh was fixed into for 0.5.0: **it must +# never replace a good tracked asset with a worse one**. Everything is fetched +# and repacked in a temporary directory, every step is checked, and the staged +# files are moved into place only once all of them have come out right. Any +# failure leaves the tracked copies exactly as they were and exits non-zero. +# +# Nothing in the build runs this. It is a deliberate, standalone refresh, and +# its output should land in its own commit so the diff is reviewable. +# +# Two of the eight images are verbatim copies of a Kenney tileset. The other six +# are repacked: 16x16 source art composited bottom-centre into 32x32 cells, one +# row, left to right, which is the layout akgl_spritesheet_coords_for_frame +# indexes. The repack lives here rather than in a second script so that the +# tracked bytes are reproducible from one command. +# +# The JSON (sprites, characters) and the TMJ maps are hand-authored source, not +# generated. This script does not touch them. +# +# Usage: scripts/fetch_tutorial_assets.sh [repository-root] + +set -euo pipefail + +AKGL_KENNEY_ASSET_URL="${AKGL_KENNEY_ASSET_URL:-https://kenney.nl/assets}" +# A floor, not a target. Each pack page carries its own licence line; a page +# that does not say CC0 is either the wrong page or a relicensed pack, and +# either way its bytes must not land in this repository. +AKGL_KENNEY_LICENSE_TEXT="${AKGL_KENNEY_LICENSE_TEXT:-Creative Commons CC0}" + +function die() +{ + echo "fetch_tutorial_assets: $*" >&2 + exit 1 +} + +function note() +{ + echo "fetch_tutorial_assets: $*" +} + +rootdir="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +assetdir="${rootdir}/docs/tutorials/assets" + +if [[ ! -d "${assetdir}/sidescroller" ]] || [[ ! -d "${assetdir}/jrpg" ]]; then + die "no such directory: ${assetdir}/{sidescroller,jrpg}" +fi + +command -v curl >/dev/null 2>&1 || die "curl is not available" +command -v unzip >/dev/null 2>&1 || die "unzip is not available" +command -v convert >/dev/null 2>&1 || die "ImageMagick 'convert' is not available" +command -v identify >/dev/null 2>&1 || die "ImageMagick 'identify' is not available" + +workdir="$(mktemp -d)" + +function cleanup() +{ + rm -rf "${workdir}" +} +trap cleanup EXIT + +staging="${workdir}/staging" +mkdir -p "${staging}/sidescroller" "${staging}/jrpg" + +## +## Fetching +## + +# Download one pack, check its page says CC0, and unpack it under ${workdir}/. +function fetch_pack() +{ + local slug="$1" + local page="${workdir}/${slug}.html" + local zip="${workdir}/${slug}.zip" + local dest="${workdir}/${slug}" + local url="" + + # --fail so an HTTP error status is an exit status rather than an error page + # in the body. Not a pipeline: the status being checked is curl's, and a + # pipeline reports the last command's. + if ! curl --fail --silent --show-error --location "${AKGL_KENNEY_ASSET_URL}/${slug}" --output "${page}"; then + die "could not fetch the pack page for ${slug}; tracked assets left as they were" + fi + + if ! grep -q "${AKGL_KENNEY_LICENSE_TEXT}" "${page}"; then + die "the ${slug} page does not say '${AKGL_KENNEY_LICENSE_TEXT}'; refusing to vendor it" + fi + + url="$(grep -o "https://[^'\"]*${slug}\.zip" "${page}" | head -1)" + if [[ -z "${url}" ]]; then + die "no download link found on the ${slug} page; tracked assets left as they were" + fi + + if ! curl --fail --silent --show-error --location "${url}" --output "${zip}"; then + die "download failed from ${url}; tracked assets left as they were" + fi + + if ! unzip -tq "${zip}" >/dev/null 2>&1; then + die "${slug}.zip did not survive an integrity check; tracked assets left as they were" + fi + + mkdir -p "${dest}" + if ! unzip -qo "${zip}" -d "${dest}"; then + die "could not unpack ${slug}.zip; tracked assets left as they were" + fi + + if ! grep -q "Creative Commons Zero" "${dest}/License.txt"; then + die "${slug} does not carry a CC0 License.txt; refusing to vendor it" + fi + + note "fetched ${slug} from ${url}" +} + +# Refuse to publish an audio file that is not actually Ogg. `file(1)` is not +# guaranteed to be installed; the container magic is four bytes at offset zero. +function require_ogg() +{ + local file="$1" + local minbytes="$2" + + [[ -f "${file}" ]] || die "expected file is missing: ${file}" + if [[ "$(head -c 4 "${file}")" != "OggS" ]]; then + die "${file} is not an Ogg stream; tracked assets left as they were" + fi + if [[ "$(wc -c < "${file}")" -lt "${minbytes}" ]]; then + die "${file} is under ${minbytes} bytes; tracked assets left as they were" + fi +} + +# Refuse to work from an image that is not the size the frame arithmetic assumes. +function require_size() +{ + local file="$1" + local want="$2" + local got="" + + [[ -f "${file}" ]] || die "expected file is missing: ${file}" + got="$(identify -format '%wx%h' "${file}")" + if [[ "${got}" != "${want}" ]]; then + die "${file} is ${got}, expected ${want}; the pack layout has changed and the frame offsets no longer hold" + fi +} + +## +## Repacking +## +## A sheet is one row of 32x32 cells. Each cell holds one 16x16 source tile +## composited at (8, 16) -- horizontally centred, sitting on the cell's bottom +## edge -- so an actor drawn into a 32x32 destination rectangle has its feet on +## the bottom of that rectangle whichever sheet it came from. +## + +AKGL_SHEET_CELL=32 +AKGL_SOURCE_TILE=16 +AKGL_CELL_OFFSET_X=8 +AKGL_CELL_OFFSET_Y=16 + +# build_sheet ... +# +# Each spec is "X,Y" or "X,Y,flop": the source pixel offset of a 16x16 tile, and +# whether to mirror it horizontally. Specs are laid into cells left to right, so +# the position of a spec in the argument list *is* its frame id. +function build_sheet() +{ + local src="$1" + local out="$2" + shift 2 + + local -a args=() + local count=$# + local idx=0 + local spec="" + local sx="" + local sy="" + local flop="" + + args+=( -size "$((count * AKGL_SHEET_CELL))x${AKGL_SHEET_CELL}" xc:none ) + for spec in "$@"; do + IFS=',' read -r sx sy flop <<< "${spec}" + args+=( '(' "${src}" -crop "${AKGL_SOURCE_TILE}x${AKGL_SOURCE_TILE}+${sx}+${sy}" +repage ) + if [[ "${flop:-}" == "flop" ]]; then + args+=( -flop ) + fi + args+=( ')' -geometry "+$(( (idx * AKGL_SHEET_CELL) + AKGL_CELL_OFFSET_X ))+${AKGL_CELL_OFFSET_Y}" -composite ) + idx=$((idx + 1)) + done + + # -strip, and the two date properties by name, because ImageMagick writes + # date:create/date:modify tEXt chunks from the wall clock. Without this a + # refresh that changed nothing at all still produced eight different files, + # and the diff would say the art had changed when it had not. + args+=( -strip +set date:create +set date:modify +set date:timestamp ) + if ! convert "${args[@]}" "${out}"; then + die "could not repack ${out}; tracked assets left as they were" + fi +} + +# rpg_urban_character +# +# The RPG Urban Pack lays each character out as a 4-column by 3-row block in the +# last four columns of the tileset: columns are left, down, up, right; rows are +# stand, step A, step B. This regroups one block into the twelve-frame order the +# tutorial sprite JSON expects -- down, left, right, up, three frames each. +function rpg_urban_character() +{ + local charidx="$1" + local out="$2" + local src="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png" + local left=368 + local down=384 + local up=400 + local right=416 + local y0=$(( charidx * 3 * 16 )) + local y1=$(( y0 + 16 )) + local y2=$(( y0 + 32 )) + + build_sheet "${src}" "${out}" \ + "${down},${y0}" "${down},${y1}" "${down},${y2}" \ + "${left},${y0}" "${left},${y1}" "${left},${y2}" \ + "${right},${y0}" "${right},${y1}" "${right},${y2}" \ + "${up},${y0}" "${up},${y1}" "${up},${y2}" +} + +## +## Do the work +## + +fetch_pack "pixel-line-platformer" +fetch_pack "rpg-urban-pack" +fetch_pack "music-jingles" + +plp="${workdir}/pixel-line-platformer/Tilemap/tilemap_packed.png" +rup="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png" + +require_size "${plp}" "160x96" +require_size "${rup}" "432x288" + +# Tilesets are copied verbatim. Both are already 16x16 with zero spacing and +# zero margin, which is the only geometry akgl_tilemap_compute_tileset_offsets +# gets right: it adds `spacing` to the tile pitch but sets the first row's y +# offset to `spacing` rather than zero, and it ignores `margin` entirely. +cp "${plp}" "${staging}/sidescroller/tiles.png" +cp "${rup}" "${staging}/jrpg/tiles.png" + +# Pixel Line Platformer tile ids, ten columns: id -> ((id % 10) * 16, (id / 10) * 16) +# 40, 41, 42 the armed rabbit: contact, passing (also the jump pose), contact +# 44 the gold pickup +# 51, 52 the moth, wings up and down +# 55, 56 the red blob, two frames +# +# akgl_actor_render always draws with SDL_FLIP_NONE, so a left-facing run has to +# exist in the sheet as its own frames rather than being mirrored at draw time. +build_sheet "${plp}" "${staging}/sidescroller/player.png" \ + "0,64" "16,64" "32,64" \ + "0,64,flop" "16,64,flop" "32,64,flop" +build_sheet "${plp}" "${staging}/sidescroller/coin.png" \ + "64,64" +build_sheet "${plp}" "${staging}/sidescroller/hazard.png" \ + "80,80" "96,80" "16,80" "32,80" + +rpg_urban_character 0 "${staging}/jrpg/player.png" +rpg_urban_character 3 "${staging}/jrpg/npc_shopkeeper.png" +rpg_urban_character 2 "${staging}/jrpg/npc_elder.png" + +# One jingle per game, copied unchanged. akgl_load_start_bgm() is the library's +# only file-audio entry point -- akgl_audio_* is a synthesiser, and there is no +# sound-effect loader at all -- so a jingle is what a tutorial can actually +# play. Both are stings of under two seconds, and the loop request in +# akgl_load_start_bgm does not take effect, so each plays once. +cp "${workdir}/music-jingles/Audio/8-Bit jingles/jingles_NES00.ogg" \ + "${staging}/sidescroller/jingle_start.ogg" +cp "${workdir}/music-jingles/Audio/Pizzicato jingles/jingles_PIZZI07.ogg" \ + "${staging}/jrpg/jingle_start.ogg" + +cp "${workdir}/pixel-line-platformer/License.txt" "${staging}/LICENSE.kenney_pixel-line-platformer.txt" +cp "${workdir}/rpg-urban-pack/License.txt" "${staging}/LICENSE.kenney_rpg-urban-pack.txt" +cp "${workdir}/music-jingles/License.txt" "${staging}/LICENSE.kenney_music-jingles.txt" + +## +## Check the staging area before anything is allowed near the tracked copies +## + +# name expected size +AKGL_TUTORIAL_ASSETS=( + "sidescroller/tiles.png 160x96" + "sidescroller/player.png 192x32" + "sidescroller/coin.png 32x32" + "sidescroller/hazard.png 128x32" + "jrpg/tiles.png 432x288" + "jrpg/player.png 384x32" + "jrpg/npc_shopkeeper.png 384x32" + "jrpg/npc_elder.png 384x32" +) + +staged=0 +for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do + read -r name want <<< "${entry}" + require_size "${staging}/${name}" "${want}" + if [[ ! -s "${staging}/${name}" ]]; then + die "staged ${name} is empty; tracked assets left as they were" + fi + staged=$((staged + 1)) +done + +if [[ "${staged}" -ne "${#AKGL_TUTORIAL_ASSETS[@]}" ]]; then + die "staged ${staged} images, expected ${#AKGL_TUTORIAL_ASSETS[@]}; tracked assets left as they were" +fi + +AKGL_TUTORIAL_AUDIO=( + "sidescroller/jingle_start.ogg" + "jrpg/jingle_start.ogg" +) + +for name in "${AKGL_TUTORIAL_AUDIO[@]}"; do + require_ogg "${staging}/${name}" 4096 +done + +AKGL_TUTORIAL_LICENSES=( + "LICENSE.kenney_pixel-line-platformer.txt" + "LICENSE.kenney_rpg-urban-pack.txt" + "LICENSE.kenney_music-jingles.txt" +) + +for name in "${AKGL_TUTORIAL_LICENSES[@]}"; do + if [[ ! -s "${staging}/${name}" ]]; then + die "staged ${name} is empty; tracked assets left as they were" + fi +done + +## +## Publish +## + +for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do + read -r name want <<< "${entry}" + mv "${staging}/${name}" "${assetdir}/${name}" +done +for name in "${AKGL_TUTORIAL_AUDIO[@]}" "${AKGL_TUTORIAL_LICENSES[@]}"; do + mv "${staging}/${name}" "${assetdir}/${name}" +done + +note "wrote ${staged} images, ${#AKGL_TUTORIAL_AUDIO[@]} jingles and ${#AKGL_TUTORIAL_LICENSES[@]} upstream licence files to ${assetdir}"