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 0000000..e3f2c49 Binary files /dev/null and b/docs/tutorials/assets/jrpg/jingle_start.ogg differ diff --git a/docs/tutorials/assets/jrpg/npc_elder.png b/docs/tutorials/assets/jrpg/npc_elder.png new file mode 100644 index 0000000..a549552 Binary files /dev/null and b/docs/tutorials/assets/jrpg/npc_elder.png differ diff --git a/docs/tutorials/assets/jrpg/npc_shopkeeper.png b/docs/tutorials/assets/jrpg/npc_shopkeeper.png new file mode 100644 index 0000000..5b0f929 Binary files /dev/null and b/docs/tutorials/assets/jrpg/npc_shopkeeper.png differ diff --git a/docs/tutorials/assets/jrpg/player.png b/docs/tutorials/assets/jrpg/player.png new file mode 100644 index 0000000..2dd160d Binary files /dev/null and b/docs/tutorials/assets/jrpg/player.png differ diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json new file mode 100644 index 0000000..11cd354 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_idle_down", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json new file mode 100644 index 0000000..ff403fd --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_idle_left", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 3 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json new file mode 100644 index 0000000..385b6f2 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_idle_right", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 6 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json new file mode 100644 index 0000000..0c1a548 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_idle_up", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 9 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json new file mode 100644 index 0000000..eec517d --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_walk_down", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 1, + 0, + 2, + 0 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json new file mode 100644 index 0000000..6468eb4 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_walk_left", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 4, + 3, + 5, + 3 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json new file mode 100644 index 0000000..5ebc555 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_walk_right", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 7, + 6, + 8, + 6 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json new file mode 100644 index 0000000..46a283c --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_elder.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_elder_walk_up", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 10, + 9, + 11, + 9 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json new file mode 100644 index 0000000..7bd4e50 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_idle_down", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json new file mode 100644 index 0000000..0c4b69e --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_idle_left", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 3 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json new file mode 100644 index 0000000..88e9542 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_idle_right", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 6 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json new file mode 100644 index 0000000..c6bd043 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_idle_up", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 9 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json new file mode 100644 index 0000000..ffbd9db --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_walk_down", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 1, + 0, + 2, + 0 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json new file mode 100644 index 0000000..a315161 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_walk_left", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 4, + 3, + 5, + 3 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json new file mode 100644 index 0000000..b8b5301 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_walk_right", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 7, + 6, + 8, + 6 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json new file mode 100644 index 0000000..2b37d75 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_player_walk_up", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 10, + 9, + 11, + 9 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json new file mode 100644 index 0000000..28d1615 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_idle_down", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json new file mode 100644 index 0000000..f412be3 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_idle_left", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 3 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json new file mode 100644 index 0000000..5cddafd --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_idle_right", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 6 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json new file mode 100644 index 0000000..94eec53 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_idle_up", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 9 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json new file mode 100644 index 0000000..c57c626 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_walk_down", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 1, + 0, + 2, + 0 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json new file mode 100644 index 0000000..7a29494 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_walk_left", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 4, + 3, + 5, + 3 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json new file mode 100644 index 0000000..eb73dd0 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_walk_right", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 7, + 6, + 8, + 6 + ] +} diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json new file mode 100644 index 0000000..ee17a00 --- /dev/null +++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "npc_shopkeeper.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "jrpg_shopkeeper_walk_up", + "width": 32, + "height": 32, + "speed": 150, + "loop": true, + "loopReverse": false, + "frames": [ + 10, + 9, + 11, + 9 + ] +} diff --git a/docs/tutorials/assets/jrpg/tiles.png b/docs/tutorials/assets/jrpg/tiles.png new file mode 100644 index 0000000..66bf115 Binary files /dev/null and b/docs/tutorials/assets/jrpg/tiles.png differ 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 0000000..b1ab9aa Binary files /dev/null and b/docs/tutorials/assets/sidescroller/coin.png differ diff --git a/docs/tutorials/assets/sidescroller/hazard.png b/docs/tutorials/assets/sidescroller/hazard.png new file mode 100644 index 0000000..72f6ebc Binary files /dev/null and b/docs/tutorials/assets/sidescroller/hazard.png differ diff --git a/docs/tutorials/assets/sidescroller/jingle_start.ogg b/docs/tutorials/assets/sidescroller/jingle_start.ogg new file mode 100644 index 0000000..2db3b5b Binary files /dev/null and b/docs/tutorials/assets/sidescroller/jingle_start.ogg differ 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 0000000..2643635 Binary files /dev/null and b/docs/tutorials/assets/sidescroller/player.png differ diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_coin.json b/docs/tutorials/assets/sidescroller/sprite_ss_coin.json new file mode 100644 index 0000000..d692b7a --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_coin.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "coin.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_coin", + "width": 32, + "height": 32, + "speed": 200, + "loop": true, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json new file mode 100644 index 0000000..407a929 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json @@ -0,0 +1,17 @@ +{ + "spritesheet": { + "filename": "hazard.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_hazard_blob", + "width": 32, + "height": 32, + "speed": 180, + "loop": true, + "loopReverse": false, + "frames": [ + 0, + 1 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json new file mode 100644 index 0000000..255ed53 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json @@ -0,0 +1,17 @@ +{ + "spritesheet": { + "filename": "hazard.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_hazard_moth", + "width": 32, + "height": 32, + "speed": 120, + "loop": true, + "loopReverse": false, + "frames": [ + 2, + 3 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json new file mode 100644 index 0000000..4f5bb47 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_idle_left", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 3 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json new file mode 100644 index 0000000..be85013 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_idle_right", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json new file mode 100644 index 0000000..0e0fd5d --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_jump_left", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 4 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json new file mode 100644 index 0000000..8e8abd5 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_jump_right", + "width": 32, + "height": 32, + "speed": 250, + "loop": true, + "loopReverse": false, + "frames": [ + 1 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json new file mode 100644 index 0000000..cc920a1 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_run_left", + "width": 32, + "height": 32, + "speed": 90, + "loop": true, + "loopReverse": false, + "frames": [ + 3, + 4, + 5, + 4 + ] +} diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json new file mode 100644 index 0000000..17f5c83 --- /dev/null +++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json @@ -0,0 +1,19 @@ +{ + "spritesheet": { + "filename": "player.png", + "frame_width": 32, + "frame_height": 32 + }, + "name": "ss_player_run_right", + "width": 32, + "height": 32, + "speed": 90, + "loop": true, + "loopReverse": false, + "frames": [ + 0, + 1, + 2, + 1 + ] +} diff --git a/docs/tutorials/assets/sidescroller/tiles.png b/docs/tutorials/assets/sidescroller/tiles.png new file mode 100644 index 0000000..8a180a6 Binary files /dev/null and b/docs/tutorials/assets/sidescroller/tiles.png differ diff --git a/examples/jrpg/CMakeLists.txt b/examples/jrpg/CMakeLists.txt new file mode 100644 index 0000000..d67abbb --- /dev/null +++ b/examples/jrpg/CMakeLists.txt @@ -0,0 +1,72 @@ +# The top-down JRPG tutorial game, quoted by docs/20-tutorial-jrpg.md. +# +# It is a real target built by `all`, not a snippet: a tutorial whose program +# does not compile is the failure this whole documentation effort exists to +# stop, and the chapter's `c excerpt=` blocks fail the moment this source moves +# under them. + +add_executable(jrpg + jrpg.c + world.c + textbox.c +) + +target_link_libraries(jrpg + PRIVATE akstdlib::akstdlib akerror::akerror akgl + SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer + jansson::jansson -lm) +target_include_directories(jrpg PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_options(jrpg PRIVATE ${AKGL_WARNING_FLAGS}) + +# The game finds its data through two absolute paths baked in here. A game that +# ships would install its assets and resolve them against SDL_GetBasePath(); a +# tutorial has to run from the build tree, from the source tree, and from +# whatever working directory CTest hands it, so the paths are compiled in. +get_filename_component(JRPG_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +target_compile_definitions(jrpg PRIVATE + JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg" + # tests/assets/akgl_test_mono.ttf, which ships beside its licence file. + JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf" +) + +# The vendored SDL satellite libraries land in per-project subdirectories that +# are not on the loader's default search path, exactly as they do for the test +# suites. AKGL_VENDORED_RPATH is set by the top-level CMakeLists.txt and is only +# defined when something was actually vendored. +if(AKGL_VENDORED_DEPENDENCIES) + set_target_properties(jrpg PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") +endif() + +# The headless smoke run. `--demo` drives the arrow keys from a script and steps +# the physics clock by a fixed 1/60 s, so the run is deterministic and finishes +# in well under a second; `--frames` bounds it. Between them the run walks the +# player into a wall, opens the text box, and exercises the follower's render +# hook -- rather than just proving that main() returns. +# +# add_test() reaches the real command here: the top-level CMakeLists.txt shadows +# it to suppress the vendored projects' registrations and lifts the suppression +# again long before examples/ is added. +add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo) +set_tests_properties(example_jrpg PROPERTIES + TIMEOUT 120 + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +) + +# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has +# to go in the same property rather than a second one. Only needed when the +# dependencies were vendored; an installed build resolves them normally. +if(AKGL_VENDORED_DEPENDENCIES) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") + set(JRPG_TEST_ENV_MOD "") + foreach(dir IN LISTS AKGL_TEST_LIBPATH) + list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") + endforeach() + set_tests_properties(example_jrpg + PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}") + else() + string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") + set_tests_properties(example_jrpg PROPERTIES + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" + ) + endif() +endif() diff --git a/examples/jrpg/jrpg.c b/examples/jrpg/jrpg.c new file mode 100644 index 0000000..e6015a0 --- /dev/null +++ b/examples/jrpg/jrpg.c @@ -0,0 +1,369 @@ +/** + * @file jrpg.c + * @brief A small top-down JRPG: startup, the frame loop, and teardown. + * + * Chapter 20 of the manual quotes this program rather than restating it, so + * what is here is what the chapter teaches. Run it with no arguments for a + * window you can walk around in: + * + * ./examples/jrpg/jrpg + * + * Arrow keys walk, space talks to whoever is standing next to you and dismisses + * the box again. `--frames N` stops after N frames, which is what makes this + * runnable as a headless smoke test; `--demo` drives the arrow keys from a + * script and steps the physics clock by a fixed interval so the run is + * deterministic and takes no wall-clock time. + */ + +#include +#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}"