Add two tutorial games that build, run in CI, and cannot drift
examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
787
docs/19-tutorial-sidescroller.md
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
# 19. Tutorial: a 2D sidescroller
|
||||||
|
|
||||||
|
A complete game: a Tiled level, a player who runs and jumps, platforms that hold him up,
|
||||||
|
four coins to collect, a blob that patrols and a moth that flies. It is about nine hundred
|
||||||
|
lines of C — a header and four translation units, and rather more comment than that — and it
|
||||||
|
builds and runs as part of this repository's ordinary `ctest` run.
|
||||||
|
|
||||||
|
It is here for one reason above the others. **libakgl has no collision detection at all**,
|
||||||
|
and a sidescroller is the shortest path to finding that out. So this chapter is mostly about
|
||||||
|
what you write when the engine stops, and where exactly that code has to live for the frame
|
||||||
|
to come out right.
|
||||||
|
|
||||||
|
The program is `examples/sidescroller/`; the art, the map and the JSON are in
|
||||||
|
`docs/tutorials/assets/sidescroller/`. Every listing below is quoted straight out of those
|
||||||
|
files by the `docs_examples` test, so this chapter cannot describe a program that no longer
|
||||||
|
exists.
|
||||||
|
|
||||||
|
## Building it and running it
|
||||||
|
|
||||||
|
```sh norun
|
||||||
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||||
|
cmake --build build --parallel --target sidescroller
|
||||||
|
./build/examples/sidescroller/sidescroller
|
||||||
|
```
|
||||||
|
|
||||||
|
Arrow keys to run, space to jump, and holding space longer jumps higher. Three flags exist
|
||||||
|
for the smoke test and are useful by hand too: `--assets DIR` points somewhere other than
|
||||||
|
the compiled-in asset directory, `--frames N` (or `AKGL_SIDESCROLLER_FRAMES`) exits after
|
||||||
|
that many frames, and `--autoplay` drives the player from a script instead of the keyboard.
|
||||||
|
|
||||||
|
## The level
|
||||||
|
|
||||||
|
`level1.tmj` is 40x15 tiles of 16 pixels — a world 640x240 pixels — with three layers:
|
||||||
|
a `background` tile layer, a `terrain` tile layer, and an `actors` object group. The camera
|
||||||
|
is a 480x240 window onto it that scrolls sideways.
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 1 2 3
|
||||||
|
0123456789012345678901234567890123456789
|
||||||
|
0 ........................................
|
||||||
|
1 ........................................
|
||||||
|
2 ........................................
|
||||||
|
3 ........................................
|
||||||
|
4 ........................................
|
||||||
|
5 ........................................
|
||||||
|
6 ............................##..........
|
||||||
|
7 ..............##........................
|
||||||
|
8 ......................##................
|
||||||
|
9 ........##..............................
|
||||||
|
10 ........................................
|
||||||
|
11 ...#......#...............#......#......
|
||||||
|
12 ##################...###################
|
||||||
|
13 ##################...###################
|
||||||
|
14 ##################...###################
|
||||||
|
|
||||||
|
the terrain layer, every row of it. `#` is any non-zero tile; the three
|
||||||
|
empty columns at 18-20 are the pit, the two-tile runs on rows 6 to 9 are
|
||||||
|
the platforms, and the four single tiles on row 11 are steps.
|
||||||
|
```
|
||||||
|
|
||||||
|
The object group places seven actors: `player`, `coin1` through `coin4`, `blob1` and
|
||||||
|
`moth1`. Each is a Tiled object of type `actor` with a `character` string property and a
|
||||||
|
`state` **integer** property — the two custom properties
|
||||||
|
[Chapter 13](13-tilemaps.md) documents, and `state` is a number here even though character
|
||||||
|
JSON accepts the symbolic form. `20` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT`,
|
||||||
|
from the bit table in [Chapter 12](12-actors.md).
|
||||||
|
|
||||||
|
Nothing in the program creates an actor. **Loading the map does**, which is why the load
|
||||||
|
order below is what it is.
|
||||||
|
|
||||||
|
## Starting up
|
||||||
|
|
||||||
|
libakgl has exactly one startup order that works and it is written down at the top of
|
||||||
|
`include/akgl/game.h`. Three of its steps are the ones a first program gets wrong.
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
PASS(errctx, aksl_strncpy(
|
||||||
|
(char *)&akgl_game.name,
|
||||||
|
sizeof(akgl_game.name),
|
||||||
|
"libakgl sidescroller tutorial",
|
||||||
|
sizeof(akgl_game.name) - 1));
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_game.name`, `.version` and `.uri` are required and have no defaults —
|
||||||
|
`akgl_game_init` refuses to run without all three, because the window title, SDL's
|
||||||
|
application metadata and the savegame compatibility check are built from them. `aksl_strncpy`
|
||||||
|
rather than `strncpy`, per [Chapter 18](18-utilities.md) and `AGENTS.md`: these are
|
||||||
|
fixed-width fields that end up as registry keys.
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
|
||||||
|
PASS(errctx, akgl_set_property("game.screenheight", "480"));
|
||||||
|
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties before the renderer.** `akgl_render_2d_init` reads both dimensions out of
|
||||||
|
`AKGL_REGISTRY_PROPERTIES` and an unset one defaults to the string `"0"`, which asks SDL for
|
||||||
|
a zero-sized window rather than reporting anything. See [Chapter 6](06-the-registry.md).
|
||||||
|
|
||||||
|
The window is 960x480 and the view is 480x240, because libakgl draws in map pixels and has
|
||||||
|
no scale factor of its own — the only scaling it applies is the tilemap's perspective band,
|
||||||
|
and this map has none. A 16-pixel tile would otherwise be 16 screen pixels. SDL's logical
|
||||||
|
presentation supplies the missing factor:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
SDL_SetRenderLogicalPresentation(
|
||||||
|
akgl_renderer->sdl_renderer,
|
||||||
|
SS_VIEW_WIDTH,
|
||||||
|
SS_VIEW_HEIGHT,
|
||||||
|
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
||||||
|
AKGL_ERR_SDL,
|
||||||
|
"%s",
|
||||||
|
SDL_GetError()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
That is an SDL call, not a libakgl one; it is documented in the
|
||||||
|
[SDL3 render API](https://wiki.libsdl.org/SDL3/SDL_SetRenderLogicalPresentation). The camera
|
||||||
|
then has to be told the same thing, because `akgl_render_2d_init` sized it from the window.
|
||||||
|
|
||||||
|
### `akgl_game_init` does not choose a physics backend
|
||||||
|
|
||||||
|
This is the one that produces a crash rather than a message, so it gets its own heading.
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_game_init` points `akgl_physics` at `akgl_default_physics`, which is **zeroed storage
|
||||||
|
whose four method pointers are all `NULL`**, and never initializes it. There is no
|
||||||
|
`physics.engine` property either, whatever the `@file` block in `include/akgl/physics.h`
|
||||||
|
says — [Chapter 14](14-physics.md) has the details and the correction. Skip this call and
|
||||||
|
the first `akgl_game_update` calls through a `NULL` `simulate`.
|
||||||
|
|
||||||
|
### The low-FPS hook fires every frame for the first second
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
akgl_game.lowfpsfunc = &ss_lowfps;
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_game.fps` is a completed-second average, so it reads 0 for the whole first second of
|
||||||
|
the process — which is under the threshold, so the default `akgl_game_lowfps` logs a line on
|
||||||
|
every frame until the second is up. The hook exists to be replaced; a real game sheds work
|
||||||
|
there. This one replaces it with an empty function so the log is readable.
|
||||||
|
|
||||||
|
## Loading in dependency order
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
|
||||||
|
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
|
||||||
|
PASS(errctx, akgl_sprite_load_json((char *)&path));
|
||||||
|
}
|
||||||
|
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
|
||||||
|
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
|
||||||
|
PASS(errctx, akgl_character_load_json((char *)&path));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sprites, then characters, then the map. That is not a preference:
|
||||||
|
|
||||||
|
- a character's JSON names its sprites by **registry name**, and
|
||||||
|
`akgl_character_load_json` looks each one up as it reads the mapping, so a character
|
||||||
|
loaded first fails on the first sprite it cannot find ([Chapter 11](11-characters.md));
|
||||||
|
- an `actor` object names its character the same way, and the map loader creates the actor
|
||||||
|
and binds the character during the load ([Chapter 13](13-tilemaps.md)).
|
||||||
|
|
||||||
|
Nine sprite files and four character files, in a table rather than thirteen calls:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
static char *ss_sprite_files[] = {
|
||||||
|
"sprite_ss_player_idle_left.json",
|
||||||
|
"sprite_ss_player_idle_right.json",
|
||||||
|
"sprite_ss_player_run_left.json",
|
||||||
|
"sprite_ss_player_run_right.json",
|
||||||
|
"sprite_ss_player_jump_left.json",
|
||||||
|
"sprite_ss_player_jump_right.json",
|
||||||
|
"sprite_ss_coin.json",
|
||||||
|
"sprite_ss_hazard_blob.json",
|
||||||
|
"sprite_ss_hazard_moth.json",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The map goes into `akgl_gamemap`, which already points at `akgl_default_gamemap`:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
|
||||||
|
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
|
||||||
|
```
|
||||||
|
|
||||||
|
That is 25 MiB of static storage in the library, and using it rather than declaring one is
|
||||||
|
not a micro-optimisation: `sizeof(akgl_Tilemap)` is three times a default 8 MiB thread
|
||||||
|
stack, so a local one is a segfault before the loader writes a byte. [Chapter 13](13-tilemaps.md)
|
||||||
|
has the size breakdown.
|
||||||
|
|
||||||
|
## The map brings its own physics
|
||||||
|
|
||||||
|
`level1.tmj` carries `physics.model`, `physics.gravity.y` and `physics.drag.y` as map-level
|
||||||
|
custom properties, so the loader built a backend for it and set `use_own_physics`. **Nothing
|
||||||
|
in the library acts on that flag** — `akgl_game_update` steps the global `akgl_physics` and
|
||||||
|
never looks at the map's — so honouring it is one line the game writes:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
if ( akgl_gamemap->use_own_physics == true ) {
|
||||||
|
akgl_physics = &akgl_gamemap->physics;
|
||||||
|
```
|
||||||
|
|
||||||
|
The numbers are gravity 900 px/s² and drag 1.5. The drag is doing a job the engine has no
|
||||||
|
other word for. **There is no terminal velocity in libakgl**: `akgl_physics_arcade_gravity`
|
||||||
|
adds `gravity_y * dt` to the actor's `ey` every step and nothing bounds it — the simulation
|
||||||
|
in `tests/physics_sim.c` reaches 560 px/s in 0.7 s and keeps going. Drag is a first-order
|
||||||
|
decay applied to the same term, so `ey` converges on `gravity_y / drag_y` instead of
|
||||||
|
diverging: 900 / 1.5 = **600 px/s, and that is this level's terminal velocity**. It is the
|
||||||
|
only brake there is; `TODO.md`, "Arcade physics feel", records the gap.
|
||||||
|
|
||||||
|
Keep `drag * max_timestep` below 1. `ex -= ex * drag_x * dt` only decays while
|
||||||
|
`drag * dt < 1`; past that it overshoots zero and past 2 it diverges, and nothing rejects
|
||||||
|
it. With `physics.max_timestep` at its default 0.05 s that needs a drag above 20.
|
||||||
|
|
||||||
|
One more line before the loop starts:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
akgl_physics->gravity_time = SDL_GetTicksNS();
|
||||||
|
```
|
||||||
|
|
||||||
|
`dt` is measured from `gravity_time`, not passed in, and everything above — nine sprite
|
||||||
|
files, four characters, a map and a tileset image — happened between the backend being
|
||||||
|
created and the first step. The `max_timestep` bound would have caught it, at the cost of
|
||||||
|
one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md).
|
||||||
|
|
||||||
|
## The collision libakgl does not have
|
||||||
|
|
||||||
|
Here is the whole problem, stated as three facts about `src/physics.c`:
|
||||||
|
|
||||||
|
- **`akgl_physics_arcade_collide` raises `AKERR_API`** with the message "Not implemented".
|
||||||
|
- **`akgl_physics_simulate` never calls `collide` at all** — not for the arcade backend, not
|
||||||
|
for the null one. The vtable slot exists and the simulation does not use it.
|
||||||
|
- **`akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.** It does
|
||||||
|
not clamp to the map, consult the tilemap, or test anything.
|
||||||
|
|
||||||
|
So an actor walks through a wall and off the edge of the world, and none of that announces
|
||||||
|
itself at compile time. [Chapter 14](14-physics.md) says so plainly and this game is what
|
||||||
|
the consequence looks like.
|
||||||
|
|
||||||
|
The only hook the physics step calls is the actor's own `movementlogicfunc`, so that is
|
||||||
|
where the collision goes. And that placement is awkward in exactly one way, which shapes
|
||||||
|
everything in `collision.c`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
akgl_physics_simulate, per actor:
|
||||||
|
|
||||||
|
tx += ax * dt <- thrust, from the previous step's ax
|
||||||
|
cap (tx,ty,tz) to the speed ellipse
|
||||||
|
movementlogicfunc(actor, dt) <- YOU ARE HERE
|
||||||
|
gravity(self, actor, dt) ey += gravity_y * dt
|
||||||
|
ex -= ex * drag_x * dt (and y, z)
|
||||||
|
vx = ex + tx (and y, z)
|
||||||
|
move(self, actor, dt) x += vx * dt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Your hook runs before the step it has to resolve.** It cannot look at where the actor
|
||||||
|
ended up, because the actor has not moved yet. So the game predicts the step instead:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
ex = obj->ex;
|
||||||
|
ey = obj->ey;
|
||||||
|
if ( akgl_physics->gravity_x != 0 ) {
|
||||||
|
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
||||||
|
}
|
||||||
|
if ( akgl_physics->gravity_y != 0 ) {
|
||||||
|
ey += (float32_t)akgl_physics->gravity_y * dt;
|
||||||
|
}
|
||||||
|
if ( akgl_physics->drag_x != 0 ) {
|
||||||
|
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
|
||||||
|
}
|
||||||
|
if ( akgl_physics->drag_y != 0 ) {
|
||||||
|
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
|
||||||
|
}
|
||||||
|
*dx = (ex + obj->tx) * dt;
|
||||||
|
*dy = (ey + obj->ty) * dt;
|
||||||
|
```
|
||||||
|
|
||||||
|
That is `akgl_physics_simulate`'s own arithmetic in its own order, `!= 0` guards included —
|
||||||
|
dropping them would let a zero-gravity axis pick up drag, which the library does not do. Get
|
||||||
|
it wrong and the actor is resolved against a step it never takes.
|
||||||
|
|
||||||
|
Be clear about what this is: a copy of somebody else's implementation, in a file that will
|
||||||
|
not be recompiled when that implementation changes. It is a liability, and it is not a
|
||||||
|
design — it is what the missing `collide` call costs. When `akgl_physics_simulate` grows a
|
||||||
|
collision hook, this function is the first thing to delete.
|
||||||
|
|
||||||
|
### Solid is a layer, not a flag
|
||||||
|
|
||||||
|
There is no per-tile "solid" property. The level says what is solid by which layer a tile is
|
||||||
|
drawn on:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Finding that layer takes a small piece of knowledge that is easy to lose an afternoon to:
|
||||||
|
**`akgl_TilemapLayer` does not record the layer's name.** `akgl_tilemap_load_layers` reads
|
||||||
|
`id`, `opacity`, `visible`, `x`, `y` and `type` and nothing else, so there is no way to ask
|
||||||
|
for "the layer called terrain". The game matches on Tiled's numeric layer id instead:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
||||||
|
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
||||||
|
ss_terrain = &map->layers[i];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Off the left or right edge of the map counts as solid, so the level has walls at its ends.
|
||||||
|
Off the top or the bottom does not: the sky is open, and falling into the pit is the whole
|
||||||
|
point of the pit.
|
||||||
|
|
||||||
|
### Sweeping, not testing
|
||||||
|
|
||||||
|
A fall at 600 px/s with the step bounded to 0.05 s covers 30 pixels, which is nearly two
|
||||||
|
tiles. A single test at the destination walks straight through a floor. So the motion is
|
||||||
|
walked in sub-steps of at most half a tile, each axis separately — testing the axes
|
||||||
|
separately is what lets an actor slide along a wall instead of sticking to it — and a
|
||||||
|
blocked axis is snapped to the boundary it was about to cross rather than simply not moved,
|
||||||
|
so an actor lands flush at whatever speed it arrives:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
if ( stepy != 0.0f ) {
|
||||||
|
trial = *box;
|
||||||
|
trial.y += stepy;
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||||
|
if ( solid == true ) {
|
||||||
|
if ( stepy > 0.0f ) {
|
||||||
|
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
|
||||||
|
} else {
|
||||||
|
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||||
|
}
|
||||||
|
stepy = 0.0f;
|
||||||
|
dest->blocked_y = true;
|
||||||
|
} else {
|
||||||
|
box->y = trial.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only a blocked axis is written back to the actor. The free axis is left for
|
||||||
|
`akgl_physics_arcade_move` to advance by exactly the amount that was predicted — writing it
|
||||||
|
here as well would move the actor twice.
|
||||||
|
|
||||||
|
### The quarter of a pixel that breaks everything
|
||||||
|
|
||||||
|
This one cost an afternoon and is the most useful thing in the chapter.
|
||||||
|
|
||||||
|
The obvious thing to do on a blocked axis is to zero the environmental term. It is wrong,
|
||||||
|
and the reason is the ordering again: the step is about to add `gravity_y * dt` back, and
|
||||||
|
`move` commits `gravity_y * dt²` of fall. At 900 px/s² and 60 Hz that is a quarter of a
|
||||||
|
pixel. Invisible — and fatal.
|
||||||
|
|
||||||
|
A quarter of a pixel of overlap means the actor's box intersects the floor tile. On the next
|
||||||
|
step the **horizontal** sweep therefore finds itself blocked wherever it tries to go, and
|
||||||
|
snaps the actor back to a tile boundary. The symptom is a character who cannot walk, jerking
|
||||||
|
backwards by up to a tile every time it tries. Nothing about it looks like a vertical
|
||||||
|
problem.
|
||||||
|
|
||||||
|
The fix is to pre-load the cancellation instead of zeroing:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
if ( dest->blocked_y == true ) {
|
||||||
|
obj->y = box.y - body->y;
|
||||||
|
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
||||||
|
obj->ty = 0.0f;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After the step's gravity, `ey` is exactly zero; after drag, still zero; `move` commits
|
||||||
|
nothing. A standing actor rests exactly on the surface, forever, at a `y` that is a whole
|
||||||
|
number of pixels.
|
||||||
|
|
||||||
|
### Standing on something is measured, not recorded
|
||||||
|
|
||||||
|
Nothing in libakgl knows whether an actor is on the ground. It is one probe, a pixel below
|
||||||
|
where the box will be when the step finishes:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
probe = box;
|
||||||
|
probe.y += 1.0f;
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
||||||
|
dest->grounded = solid;
|
||||||
|
```
|
||||||
|
|
||||||
|
The verdict is one frame stale by the time the jump reads it, because this hook runs before
|
||||||
|
the step it is deciding about. One frame of coyote time is not something a player can feel.
|
||||||
|
|
||||||
|
### Spawning inside the scenery
|
||||||
|
|
||||||
|
A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the
|
||||||
|
player at x=32 with a step at tile (3,11) — under the right half of the player's frame. The
|
||||||
|
swept resolution cannot help: it stops an actor *entering* terrain and has nothing to say
|
||||||
|
about one that began inside it. What it does instead is refuse every horizontal move,
|
||||||
|
because the box is blocked wherever it goes.
|
||||||
|
|
||||||
|
So spawn points get lifted clear once, before the first step:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/collision.c
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
||||||
|
if ( solid == false ) {
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
obj->y -= (float32_t)SS_TILE_SIZE;
|
||||||
|
```
|
||||||
|
|
||||||
|
The player and the blob both need it, and both end up standing on the block they were
|
||||||
|
overlapping. Failing after four tiles is deliberate: a spawn point buried that deep is a
|
||||||
|
level bug, and a silent nudge would hide it.
|
||||||
|
|
||||||
|
## Making it feel like a platformer
|
||||||
|
|
||||||
|
Two of libakgl's documented gaps are about feel rather than correctness, and both are in
|
||||||
|
`TODO.md` under "Arcade physics feel". The tutorial works around both rather than pretending.
|
||||||
|
|
||||||
|
### Releasing a direction stops the actor dead
|
||||||
|
|
||||||
|
`akgl_actor_cmhf_left_off` clears the movement bit, zeroes `ax`, **and zeroes `tx`**. There
|
||||||
|
is no friction and no deceleration anywhere in the arcade backend, so a character at full
|
||||||
|
speed stops within one frame — 0.0 px of drift, measured. That is correct for a top-down
|
||||||
|
Zelda and wrong for a sidescroller.
|
||||||
|
|
||||||
|
The game binds its own release handlers, which do everything the library's do except the
|
||||||
|
last part:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
obj->ax = 0.0f;
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and decays `tx` in the movement logic instead, faster on the ground than in the air:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
friction = SS_FRICTION_AIR;
|
||||||
|
if ( ss_game.grounded == true ) {
|
||||||
|
friction = SS_FRICTION_GROUND;
|
||||||
|
}
|
||||||
|
obj->tx -= obj->tx * friction * dt;
|
||||||
|
if ( fabsf(obj->tx) < 1.0f ) {
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The snap to zero below a pixel per second is there because an exponential decay never
|
||||||
|
actually arrives.
|
||||||
|
|
||||||
|
The `_on` handlers are the library's unchanged. `akgl_actor_cmhf_right_on` clears
|
||||||
|
`FACE_ALL | MOVING_ALL`, sets `MOVING_RIGHT | FACE_RIGHT` and signs `ax` from the character
|
||||||
|
— exactly right. Only the release half needed replacing. [Chapter 15](15-input.md) covers
|
||||||
|
building a control map; the whole of this game's is six bindings, three keyboard and three
|
||||||
|
gamepad.
|
||||||
|
|
||||||
|
### A jump is an impulse into `ey`, not thrust
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
||||||
|
obj->ey = -SS_JUMP_SPEED;
|
||||||
|
}
|
||||||
|
ss_game.jump_requested = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
It has to be the environmental term. `ey` is the axis gravity accumulates on, and the two
|
||||||
|
have to cancel for the arc to come back down. Written as thrust it would not work at all:
|
||||||
|
`ty` is capped against the character's `speed_y`, which is `0.0` for this character, so
|
||||||
|
`akgl_physics_simulate`'s ellipse cap scales it to nothing. See the thrust-versus-velocity
|
||||||
|
model in [Chapter 14](14-physics.md).
|
||||||
|
|
||||||
|
`ey` being an ordinary field that nothing else owns between steps also buys variable jump
|
||||||
|
height for four lines — releasing the button while still rising cuts the remaining
|
||||||
|
velocity:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
if ( obj->ey < 0.0f ) {
|
||||||
|
obj->ey *= 0.4f;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The state word chooses the sprite, and one bit is missing
|
||||||
|
|
||||||
|
An actor's whole 32-bit `state` is the key that selects a sprite ([Chapter 12](12-actors.md)).
|
||||||
|
The player's character binds ten combinations: idle and running each way, and jumping each
|
||||||
|
way.
|
||||||
|
|
||||||
|
The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever the actor is
|
||||||
|
not on the ground:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
if ( contact.grounded == true ) {
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||||
|
} else {
|
||||||
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing else reads the bit here. `speed_y` and `acceleration_y` are both `0.0` in
|
||||||
|
`character_ss_player.json`, so the vertical thrust it would authorise is capped to nothing —
|
||||||
|
the bit is being used purely as an animation selector, which is a thing the state mask is
|
||||||
|
good for.
|
||||||
|
|
||||||
|
The missing bit is the one that makes the player disappear. The default `facefunc`,
|
||||||
|
`akgl_actor_automatic_face`, clears every facing bit and then sets one from the *movement*
|
||||||
|
bits — so **an actor that stops moving is left facing nowhere**, its state drops to bare
|
||||||
|
`ALIVE`, and there is no sprite for that. It is not an error: `akgl_actor_render` handles
|
||||||
|
the `AKERR_KEY`, treats the actor as invisible, and draws nothing. A player who stops
|
||||||
|
walking vanishes.
|
||||||
|
|
||||||
|
The documented way out is to take the facing bits off the library entirely:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
obj->movement_controls_face = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_actor_automatic_face` leaves an actor alone when that is clear, so the facing bits stay
|
||||||
|
wherever the control handlers last put them — and `akgl_actor_cmhf_left_off` clears
|
||||||
|
`MOVING_LEFT` without touching `FACE_LEFT`, which is exactly the behaviour a standing sprite
|
||||||
|
needs. The alternative, mapping bare `ALIVE` to something, is in
|
||||||
|
[Chapter 12](12-actors.md).
|
||||||
|
|
||||||
|
## `AKGL_ERR_LOGICINTERRUPT`, in a game
|
||||||
|
|
||||||
|
The one status in libakgl that is not a failure. Raised from a `movementlogicfunc` it means
|
||||||
|
"skip the rest of this tick for me" — no gravity, no drag, no move — and
|
||||||
|
`akgl_physics_simulate` handles it and carries on to the next actor.
|
||||||
|
|
||||||
|
Two of this game's three non-player behaviours are built on it, for two different reasons.
|
||||||
|
|
||||||
|
The coins need it because **gravity is not thrust**. Their character declares no speed and
|
||||||
|
no acceleration, so they cannot thrust; `akgl_physics_arcade_gravity` accumulates into `ey`
|
||||||
|
for every actor it is handed regardless, and a coin left to the default logic falls out of
|
||||||
|
the level with everything else.
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/actors.c
|
||||||
|
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
(void)dt;
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The moth needs it because it flies and the level's physics has gravity, which the player
|
||||||
|
needs. Rather than fighting the backend it writes its own position and opts out:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/actors.c
|
||||||
|
data->phase += dt;
|
||||||
|
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
|
||||||
|
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rules go with it, both from [Chapter 4](04-errors.md):
|
||||||
|
|
||||||
|
- **Only `movementlogicfunc` gets this treatment.** The backend's `gravity` and `move` are
|
||||||
|
called through `PASS` in the same block, so a raise from either aborts the whole step —
|
||||||
|
leaving every remaining actor unsimulated and `gravity_time` unadvanced.
|
||||||
|
- The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN`
|
||||||
|
inside one returns past `CLEANUP`.
|
||||||
|
|
||||||
|
The blob is the counter-example: it stays inside the physics step, walks under the level's
|
||||||
|
gravity like the player does, and uses the same swept resolution. It turns around when the
|
||||||
|
sweep blocks it or when there is no floor one pixel past its leading edge:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/actors.c
|
||||||
|
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
||||||
|
data->facing = -data->facing;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-actor data
|
||||||
|
|
||||||
|
`akgl_Actor::actorData` is a `void *` the library never reads or frees, and it is where the
|
||||||
|
blob's patrol direction and the moth's flight phase live. It points into a fixed table:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/actors.c
|
||||||
|
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
|
||||||
|
```
|
||||||
|
|
||||||
|
A table rather than an allocation, for the same reason libakgl has pools rather than a
|
||||||
|
`malloc`: the level places a known number of things, and a game that cannot run out of
|
||||||
|
memory at runtime is one fewer failure mode. See [Chapter 5](05-the-heap.md).
|
||||||
|
|
||||||
|
## Picking things up
|
||||||
|
|
||||||
|
Collecting a coin is a rectangle test from [Chapter 18](18-utilities.md) and a pool release:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
|
||||||
|
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
||||||
|
if ( hit == true ) {
|
||||||
|
```
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
|
||||||
|
ss_game.coins[i] = NULL;
|
||||||
|
ss_game.coins_taken += 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is no despawn call.** Giving the pool slot back is what clears the registry entry
|
||||||
|
and stops the actor being drawn, and it is safe to do from inside `akgl_game_update`'s actor
|
||||||
|
sweep: that loop re-reads `refcount` at the top of every iteration and skips a slot that has
|
||||||
|
gone free. Releasing an actor does not touch its character, so the other coins are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
This runs in the player's `updatefunc` rather than its `movementlogicfunc`, and the split is
|
||||||
|
the frame's own order. `akgl_game_update` calls every actor's `updatefunc`, *then* steps the
|
||||||
|
physics, *then* draws. Game logic that is not movement belongs in the first pass; anything
|
||||||
|
that has to happen inside the physics step has only the one hook.
|
||||||
|
|
||||||
|
## The frame
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||||
|
```
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
PASS(errctx, akgl_game_update(NULL));
|
||||||
|
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It does **not**
|
||||||
|
clear or present, so the frame is bracketed by the backend's own calls — see
|
||||||
|
[Chapter 7](07-the-game-and-the-frame.md) and [Chapter 8](08-rendering.md).
|
||||||
|
|
||||||
|
Note the failure contract. **`akgl_game_update` returns holding the game state lock on every
|
||||||
|
one of its failure paths.** SDL mutexes are recursive so a single-threaded loop does not
|
||||||
|
deadlock on the next frame, but a frame that failed has left the world half-stepped: some
|
||||||
|
actors advanced and some not. Treat it as terminal, which is what `PASS` does here.
|
||||||
|
|
||||||
|
Events go through `akgl_controller_handle_event` unconditionally — an event that no control
|
||||||
|
map binds is not an error, it is a call that did nothing, which is what lets a host pump
|
||||||
|
everything through one path.
|
||||||
|
|
||||||
|
## The camera
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
|
||||||
|
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_camera` is a plain `SDL_FRect` in map pixels that `akgl_tilemap_draw` and
|
||||||
|
`akgl_actor_render` both read. Moving it is the whole of scrolling; there is no camera
|
||||||
|
object and no follow behaviour to configure. It is floored to a whole pixel afterwards
|
||||||
|
because `akgl_tilemap_draw` truncates it when working out how much of the edge tiles to
|
||||||
|
show, and a camera that is fractionally different every frame makes the tile grid shimmer.
|
||||||
|
|
||||||
|
The camera is updated before `akgl_game_update` and therefore uses the player's position
|
||||||
|
from the end of the *previous* step. Correcting that would mean splitting the update from
|
||||||
|
the draw, which `akgl_game_update` does not offer.
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/main.c
|
||||||
|
IGNORE(akgl_text_unloadallfonts());
|
||||||
|
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||||
|
if ( akgl_heap_actors[i].refcount > 0 ) {
|
||||||
|
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( akgl_gamemap != NULL ) {
|
||||||
|
IGNORE(akgl_tilemap_release(akgl_gamemap));
|
||||||
|
}
|
||||||
|
TTF_Quit();
|
||||||
|
MIX_Quit();
|
||||||
|
SDL_Quit();
|
||||||
|
```
|
||||||
|
|
||||||
|
The `NULL` guard on the map is not defensive padding. This function is called from the
|
||||||
|
program's `CLEANUP` block, which is reached whether startup succeeded or not — and a bad
|
||||||
|
`--assets` path fails before `akgl_game_init` has pointed `akgl_gamemap` at anything.
|
||||||
|
|
||||||
|
**There is no `akgl_game_shutdown`.** Teardown belongs to the application, and the order
|
||||||
|
matters in one place: `akgl_text_unloadallfonts` has to run before `TTF_Quit` or `SDL_Quit`,
|
||||||
|
because those destroy the fonts underneath the registry that still points at them
|
||||||
|
([Chapter 16](16-text-and-fonts.md)). This game loads no fonts and calls it anyway, because
|
||||||
|
the ordering is the thing worth copying.
|
||||||
|
|
||||||
|
What this does **not** do is unwind the sprite, spritesheet and character pools. They are
|
||||||
|
static storage in a process that is exiting and the objects still reference each other; a
|
||||||
|
game that loads a second level has to do it properly, and this one does not pretend to. Two
|
||||||
|
things to know before you write that code:
|
||||||
|
|
||||||
|
- `akgl_heap_release_character` calls `akgl_character_state_sprites_iterate` on every
|
||||||
|
release, and that function is an `SDL_EnumerateProperties` callback ending in
|
||||||
|
`FINISH_NORETURN` — **an error inside it exits the process**. This is an ordinary path,
|
||||||
|
not an unusual one. [Chapter 11](11-characters.md) and [Chapter 4](04-errors.md).
|
||||||
|
- `akgl_tilemap_release` does not release the actors an object layer created. Those are
|
||||||
|
yours, which is what the loop above is for.
|
||||||
|
|
||||||
|
## Known defects you will see running this
|
||||||
|
|
||||||
|
Each is cross-referenced to `TODO.md`. None is worked around, because in each case the
|
||||||
|
workaround would be worse than the symptom.
|
||||||
|
|
||||||
|
**The leftmost column of tiles is drawn from the wrong part of the tileset when the camera
|
||||||
|
is within one tile of x=0.** `akgl_tilemap_draw` special-cases the first visible column to
|
||||||
|
show a partial tile, and writes `src.x += (int)viewport->x % map->tilewidth` — a `+=` onto
|
||||||
|
whatever `src.x` was left holding by the last tile of the previous row, rather than an
|
||||||
|
assignment onto that tile's own offset (`src/tilemap.c:766`). At `viewport->x = 0` the
|
||||||
|
added term is zero and the stale value is used unchanged. The visible result in this game is
|
||||||
|
a stray tile at the bottom-left of the screen, which disappears the moment the camera scrolls
|
||||||
|
away from the origin and comes back when the player walks home. `TODO.md`, "Performance"
|
||||||
|
item 5, records it alongside the tileset scan it lives in; the same shape applies to `src.y`
|
||||||
|
and the top row.
|
||||||
|
|
||||||
|
**An actor on layer 16 or higher is never drawn.** `akgl_Actor::layer` is a `uint32_t` and
|
||||||
|
nothing range-checks it, but `akgl_render_2d_draw_world` stops at `AKGL_TILEMAP_MAX_LAYERS`.
|
||||||
|
This level's object group is layer index 2, so it does not bite here — but a map with
|
||||||
|
seventeen layers loses its actors silently. [Chapter 12](12-actors.md).
|
||||||
|
|
||||||
|
**A wrong name in an asset file can kill the process rather than raise.** The six
|
||||||
|
`SDL_EnumerateProperties` callbacks in libakgl end in `FINISH_NORETURN`, and libakerror's
|
||||||
|
default unhandled-error handler calls `akerr_exit()`. `akgl_game_update` does not go through
|
||||||
|
one — it sweeps the pool directly and propagates — so this game reaches it only through
|
||||||
|
`akgl_heap_release_character`. Install your own `akerr_handler_unhandled_error` if a game
|
||||||
|
should survive it, and call `akerr_exit()` from it. [Chapter 4](04-errors.md).
|
||||||
|
|
||||||
|
**`akgl_tilemap_load` releases nothing on a failed load.** Textures already uploaded and
|
||||||
|
actors already created stay where they are and the destination holds a half-built map. This
|
||||||
|
game treats a failed load as fatal, which is the only honest thing to do with one map.
|
||||||
|
[Chapter 13](13-tilemaps.md).
|
||||||
|
|
||||||
|
## The smoke test
|
||||||
|
|
||||||
|
The game is registered as a CTest case, so a change that breaks it turns the suite red
|
||||||
|
rather than waiting for a reader to notice:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
|
||||||
|
set_tests_properties(example_sidescroller PROPERTIES
|
||||||
|
TIMEOUT 120
|
||||||
|
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Four seconds of scripted play under the headless drivers. `--autoplay` holds *right* and
|
||||||
|
requests a jump every forty-five frames by calling the handlers a keyboard would have
|
||||||
|
called:
|
||||||
|
|
||||||
|
```c excerpt=examples/sidescroller/player.c
|
||||||
|
if ( frame == 1 ) {
|
||||||
|
PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
|
||||||
|
}
|
||||||
|
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
|
||||||
|
ss_game.jump_requested = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That works because a control-map handler takes the actor and an event, and the keyboard
|
||||||
|
handlers do not read the event beyond requiring one to be there. In four seconds the script
|
||||||
|
walks the level, jumps, collides with terrain, collects a coin, falls in the pit and
|
||||||
|
respawns — so the smoke test exercises the collision and the pickup, not just the startup.
|
||||||
|
|
||||||
|
A run ends with a line naming what happened, which is worth reading when a change moves the
|
||||||
|
feel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sidescroller: 240 frames, 1 of 4 coins, 1 deaths
|
||||||
|
```
|
||||||
|
|
||||||
|
The two counts are not fixed. `dt` is measured from the wall clock rather than passed in
|
||||||
|
([Chapter 14](14-physics.md)), so a busier machine takes slightly different steps and the
|
||||||
|
script lands in slightly different places. That is why the smoke test asserts a clean exit
|
||||||
|
rather than a score — an assertion on the numbers would be a test of the scheduler.
|
||||||
|
|
||||||
|
## Where to look next
|
||||||
|
|
||||||
|
- [Chapter 14](14-physics.md) — thrust, environmental velocity, the speed ellipse, and the
|
||||||
|
full list of what is not implemented.
|
||||||
|
- [Chapter 12](12-actors.md) — the state mask, the six hooks, and the control handlers.
|
||||||
|
- [Chapter 13](13-tilemaps.md) — what the map loader accepts, and the three extensions to
|
||||||
|
Tiled this level uses.
|
||||||
|
- [Chapter 15](15-input.md) — control maps, and why a binding that never fires is usually
|
||||||
|
the wrong device id.
|
||||||
|
- [Chapter 20](20-tutorial-jrpg.md) — the same library from the other end: a top-down game
|
||||||
|
with no gravity, where the content pipeline is the interesting part.
|
||||||
675
docs/20-tutorial-jrpg.md
Normal file
@@ -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.
|
||||||
121
docs/tutorials/assets/LICENSE
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
Creative Commons Legal Code
|
||||||
|
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||||
|
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
||||||
|
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||||
|
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||||
|
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
||||||
|
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
||||||
|
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
||||||
|
HEREUNDER.
|
||||||
|
|
||||||
|
Statement of Purpose
|
||||||
|
|
||||||
|
The laws of most jurisdictions throughout the world automatically confer
|
||||||
|
exclusive Copyright and Related Rights (defined below) upon the creator
|
||||||
|
and subsequent owner(s) (each and all, an "owner") of an original work of
|
||||||
|
authorship and/or a database (each, a "Work").
|
||||||
|
|
||||||
|
Certain owners wish to permanently relinquish those rights to a Work for
|
||||||
|
the purpose of contributing to a commons of creative, cultural and
|
||||||
|
scientific works ("Commons") that the public can reliably and without fear
|
||||||
|
of later claims of infringement build upon, modify, incorporate in other
|
||||||
|
works, reuse and redistribute as freely as possible in any form whatsoever
|
||||||
|
and for any purposes, including without limitation commercial purposes.
|
||||||
|
These owners may contribute to the Commons to promote the ideal of a free
|
||||||
|
culture and the further production of creative, cultural and scientific
|
||||||
|
works, or to gain reputation or greater distribution for their Work in
|
||||||
|
part through the use and efforts of others.
|
||||||
|
|
||||||
|
For these and/or other purposes and motivations, and without any
|
||||||
|
expectation of additional consideration or compensation, the person
|
||||||
|
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
||||||
|
is an owner of Copyright and Related Rights in the Work, voluntarily
|
||||||
|
elects to apply CC0 to the Work and publicly distribute the Work under its
|
||||||
|
terms, with knowledge of his or her Copyright and Related Rights in the
|
||||||
|
Work and the meaning and intended legal effect of CC0 on those rights.
|
||||||
|
|
||||||
|
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||||
|
protected by copyright and related or neighboring rights ("Copyright and
|
||||||
|
Related Rights"). Copyright and Related Rights include, but are not
|
||||||
|
limited to, the following:
|
||||||
|
|
||||||
|
i. the right to reproduce, adapt, distribute, perform, display,
|
||||||
|
communicate, and translate a Work;
|
||||||
|
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||||
|
iii. publicity and privacy rights pertaining to a person's image or
|
||||||
|
likeness depicted in a Work;
|
||||||
|
iv. rights protecting against unfair competition in regards to a Work,
|
||||||
|
subject to the limitations in paragraph 4(a), below;
|
||||||
|
v. rights protecting the extraction, dissemination, use and reuse of data
|
||||||
|
in a Work;
|
||||||
|
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||||
|
European Parliament and of the Council of 11 March 1996 on the legal
|
||||||
|
protection of databases, and under any national implementation
|
||||||
|
thereof, including any amended or successor version of such
|
||||||
|
directive); and
|
||||||
|
vii. other similar, equivalent or corresponding rights throughout the
|
||||||
|
world based on applicable law or treaty, and any national
|
||||||
|
implementations thereof.
|
||||||
|
|
||||||
|
2. Waiver. To the greatest extent permitted by, but not in contravention
|
||||||
|
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
||||||
|
irrevocably and unconditionally waives, abandons, and surrenders all of
|
||||||
|
Affirmer's Copyright and Related Rights and associated claims and causes
|
||||||
|
of action, whether now known or unknown (including existing as well as
|
||||||
|
future claims and causes of action), in the Work (i) in all territories
|
||||||
|
worldwide, (ii) for the maximum duration provided by applicable law or
|
||||||
|
treaty (including future time extensions), (iii) in any current or future
|
||||||
|
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
||||||
|
including without limitation commercial, advertising or promotional
|
||||||
|
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
||||||
|
member of the public at large and to the detriment of Affirmer's heirs and
|
||||||
|
successors, fully intending that such Waiver shall not be subject to
|
||||||
|
revocation, rescission, cancellation, termination, or any other legal or
|
||||||
|
equitable action to disrupt the quiet enjoyment of the Work by the public
|
||||||
|
as contemplated by Affirmer's express Statement of Purpose.
|
||||||
|
|
||||||
|
3. Public License Fallback. Should any part of the Waiver for any reason
|
||||||
|
be judged legally invalid or ineffective under applicable law, then the
|
||||||
|
Waiver shall be preserved to the maximum extent permitted taking into
|
||||||
|
account Affirmer's express Statement of Purpose. In addition, to the
|
||||||
|
extent the Waiver is so judged Affirmer hereby grants to each affected
|
||||||
|
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
||||||
|
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
||||||
|
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
||||||
|
maximum duration provided by applicable law or treaty (including future
|
||||||
|
time extensions), (iii) in any current or future medium and for any number
|
||||||
|
of copies, and (iv) for any purpose whatsoever, including without
|
||||||
|
limitation commercial, advertising or promotional purposes (the
|
||||||
|
"License"). The License shall be deemed effective as of the date CC0 was
|
||||||
|
applied by Affirmer to the Work. Should any part of the License for any
|
||||||
|
reason be judged legally invalid or ineffective under applicable law, such
|
||||||
|
partial invalidity or ineffectiveness shall not invalidate the remainder
|
||||||
|
of the License, and in such case Affirmer hereby affirms that he or she
|
||||||
|
will not (i) exercise any of his or her remaining Copyright and Related
|
||||||
|
Rights in the Work or (ii) assert any associated claims and causes of
|
||||||
|
action with respect to the Work, in either case contrary to Affirmer's
|
||||||
|
express Statement of Purpose.
|
||||||
|
|
||||||
|
4. Limitations and Disclaimers.
|
||||||
|
|
||||||
|
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||||
|
surrendered, licensed or otherwise affected by this document.
|
||||||
|
b. Affirmer offers the Work as-is and makes no representations or
|
||||||
|
warranties of any kind concerning the Work, express, implied,
|
||||||
|
statutory or otherwise, including without limitation warranties of
|
||||||
|
title, merchantability, fitness for a particular purpose, non
|
||||||
|
infringement, or the absence of latent or other defects, accuracy, or
|
||||||
|
the present or absence of errors, whether or not discoverable, all to
|
||||||
|
the greatest extent permissible under applicable law.
|
||||||
|
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||||
|
that may apply to the Work or any use thereof, including without
|
||||||
|
limitation any person's Copyright and Related Rights in the Work.
|
||||||
|
Further, Affirmer disclaims responsibility for obtaining any necessary
|
||||||
|
consents, permissions or other rights required for any use of the
|
||||||
|
Work.
|
||||||
|
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||||
|
party to this document and has no duty or obligation with respect to
|
||||||
|
this CC0 or use of the Work.
|
||||||
21
docs/tutorials/assets/LICENSE.kenney_music-jingles.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
|
||||||
|
Music Jingles
|
||||||
|
|
||||||
|
by Kenney Vleugels (Kenney.nl)
|
||||||
|
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
License (Creative Commons Zero, CC0)
|
||||||
|
http://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
You may use these assets in personal and commercial projects.
|
||||||
|
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||||
|
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Donate: http://support.kenney.nl
|
||||||
|
Request: http://request.kenney.nl
|
||||||
|
|
||||||
|
Follow on Twitter for updates:
|
||||||
|
@KenneyNL
|
||||||
@@ -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:
|
||||||
23
docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
|
||||||
|
RPG Urban Pack 1.0
|
||||||
|
|
||||||
|
Created/distributed by Kenney (www.kenney.nl)
|
||||||
|
Creation date: 05-01-2019
|
||||||
|
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
License: (Creative Commons Zero, CC0)
|
||||||
|
http://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
This content is free to use in personal, educational and commercial projects.
|
||||||
|
|
||||||
|
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||||
|
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Donate: http://support.kenney.nl
|
||||||
|
Request: http://request.kenney.nl
|
||||||
|
Patreon: http://patreon.com/kenney/
|
||||||
|
|
||||||
|
Follow on Twitter for updates:
|
||||||
151
docs/tutorials/assets/PROVENANCE.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Provenance of the tutorial assets
|
||||||
|
|
||||||
|
Everything under `docs/tutorials/assets/` is **CC0 1.0** or was written for this
|
||||||
|
repository. Nothing here carries an attribution requirement, a share-alike
|
||||||
|
clause, or a field-of-use restriction, which is the whole point: a reader who
|
||||||
|
copies a tutorial into their own game inherits whatever obligation these assets
|
||||||
|
carry, and CC0 carries none. "Free to download" is not the same thing and was
|
||||||
|
not accepted -- each pack's licence was read on its Kenney page *and* in the
|
||||||
|
`License.txt` it ships, and `scripts/fetch_tutorial_assets.sh` re-checks both on
|
||||||
|
every refresh and refuses to vendor a pack that fails either.
|
||||||
|
|
||||||
|
`LICENSE` is the CC0 1.0 legal code. The three `LICENSE.kenney_*.txt` files are
|
||||||
|
the licence statements the packs themselves ship, kept beside the art in the
|
||||||
|
same way `tests/assets/akgl_test_mono.LICENSE.txt` sits beside its font.
|
||||||
|
|
||||||
|
The two tables below account for all 56 asset files: 14 carry upstream bytes and
|
||||||
|
42 were written here. This file and `README.md` are the remaining two, and are
|
||||||
|
documentation of the same kind as the rest of `docs/`.
|
||||||
|
|
||||||
|
## Upstream packs
|
||||||
|
|
||||||
|
| Pack | URL | Licence | Licence file in this tree |
|
||||||
|
|-----------------------|------------------------------------------------|---------|--------------------------------------------|
|
||||||
|
| Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `LICENSE.kenney_pixel-line-platformer.txt` |
|
||||||
|
| RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `LICENSE.kenney_rpg-urban-pack.txt` |
|
||||||
|
| Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `LICENSE.kenney_music-jingles.txt` |
|
||||||
|
|
||||||
|
Kenney asks for credit and does not require it. Credit is given here and in the
|
||||||
|
tutorial chapters.
|
||||||
|
|
||||||
|
## Files carrying upstream bytes
|
||||||
|
|
||||||
|
| File | Pack | Source URL | Licence | Cropped / repacked into |
|
||||||
|
|--------------------------------------------|-----------------------|-----------------------------------------------------------------|---------|----------------------------------------------------------------------------------------------------|
|
||||||
|
| `LICENSE` | creativecommons.org | https://creativecommons.org/publicdomain/zero/1.0/legalcode.txt | CC0 1.0 | The CC0 1.0 Universal legal code, fetched verbatim |
|
||||||
|
| `LICENSE.kenney_pixel-line-platformer.txt` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
|
||||||
|
| `LICENSE.kenney_rpg-urban-pack.txt` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
|
||||||
|
| `LICENSE.kenney_music-jingles.txt` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
|
||||||
|
| `sidescroller/tiles.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 160x96, 10x6 tiles of 16x16, no spacing, no margin |
|
||||||
|
| `sidescroller/player.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 40, 41, 42 and their horizontal mirrors, bottom-centred in six 32x32 cells |
|
||||||
|
| `sidescroller/coin.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tile 44, bottom-centred in one 32x32 cell |
|
||||||
|
| `sidescroller/hazard.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 55, 56, 51, 52, bottom-centred in four 32x32 cells |
|
||||||
|
| `sidescroller/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/8-Bit jingles/jingles_NES00.ogg`, copied verbatim (1.8 s) |
|
||||||
|
| `jrpg/tiles.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 432x288, 27x18 tiles of 16x16, no spacing, no margin |
|
||||||
|
| `jrpg/player.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 0 (tileset columns 23-26, rows 0-2) regrouped into twelve 32x32 cells |
|
||||||
|
| `jrpg/npc_shopkeeper.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 3 (tileset columns 23-26, rows 9-11) regrouped into twelve 32x32 cells |
|
||||||
|
| `jrpg/npc_elder.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 2 (tileset columns 23-26, rows 6-8) regrouped into twelve 32x32 cells |
|
||||||
|
| `jrpg/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/Pizzicato jingles/jingles_PIZZI07.ogg`, copied verbatim (1.3 s) |
|
||||||
|
|
||||||
|
## Files written for this repository
|
||||||
|
|
||||||
|
These contain no upstream content. They are libakgl source in JSON form: they
|
||||||
|
name the art above, they do not embed it.
|
||||||
|
|
||||||
|
| File | Kind | What it is |
|
||||||
|
|-------------------------------------------------|-----------|---------------------------------------------------------------------------------|
|
||||||
|
| `jrpg/character_jrpg_elder.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `jrpg/character_jrpg_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `jrpg/character_jrpg_shopkeeper.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `jrpg/sprite_jrpg_elder_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_elder_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_player_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/sprite_jrpg_shopkeeper_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `jrpg/town.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 30x20 cells, ground + decoration + actors |
|
||||||
|
| `sidescroller/character_ss_coin.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `sidescroller/character_ss_hazard_blob.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `sidescroller/character_ss_hazard_moth.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `sidescroller/character_ss_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
||||||
|
| `sidescroller/level1.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 40x15 cells, background + terrain + actors |
|
||||||
|
| `sidescroller/sprite_ss_coin.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_hazard_blob.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_hazard_moth.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_player_jump_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_player_jump_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_player_run_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
| `sidescroller/sprite_ss_player_run_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
||||||
|
|
||||||
|
## How the repacking works, and why
|
||||||
|
|
||||||
|
**Tilesets are copied verbatim.** Both packs already ship a 16x16 grid with zero
|
||||||
|
spacing and zero margin, which is the only geometry
|
||||||
|
`akgl_tilemap_compute_tileset_offsets` gets right -- it adds `spacing` to the
|
||||||
|
tile pitch but sets the *first* row's y offset to `spacing` rather than zero,
|
||||||
|
and it ignores `margin` completely. A packed sheet with a 1 px gutter, which is
|
||||||
|
what most of Kenney's older packs ship, would render every tile in row 0 one
|
||||||
|
pixel low. That ruled several otherwise-suitable packs out.
|
||||||
|
|
||||||
|
**Character sheets are repacked into one row of 32x32 cells.** The asset
|
||||||
|
contract for these tutorials is 16x16 tiles and 32x32 character frames, and
|
||||||
|
neither pack ships 32x32 art, so each 16x16 source tile is composited at (8, 16)
|
||||||
|
inside its cell -- horizontally centred, sitting on the cell's bottom edge. The
|
||||||
|
art is not resampled: a 2x upscale would put 2x2 pixel blocks next to 1x1 tiles,
|
||||||
|
and every sprite in the repository would stop matching the source pixel for
|
||||||
|
pixel. The cost is that three quarters of each cell is empty, and the benefit is
|
||||||
|
one rule the tutorials can rely on: **an actor drawn into a 32x32 destination
|
||||||
|
rectangle has its feet on the bottom edge of that rectangle and its body
|
||||||
|
centred**, whichever sheet it came from.
|
||||||
|
|
||||||
|
**Left-facing frames are stored, not mirrored at draw time.**
|
||||||
|
`akgl_actor_render` calls `draw_texture` with `SDL_FLIP_NONE` hard-coded
|
||||||
|
(`src/actor.c`), so there is no way to ask for a mirrored blit. The sidescroller
|
||||||
|
sheet therefore carries frames 0-2 facing right and frames 3-5 as their
|
||||||
|
horizontal mirrors. The JRPG sheets need no mirroring: the RPG Urban Pack draws
|
||||||
|
both side views.
|
||||||
|
|
||||||
|
**One frame is used twice.** Pixel Line Platformer has no dedicated jump pose.
|
||||||
|
Frame 1 is the passing position of the run cycle, which is the airborne one, and
|
||||||
|
`ss_player_jump_right` reuses it. Said here rather than left for a reader to
|
||||||
|
notice.
|
||||||
|
|
||||||
|
**The coin does not animate.** Pixel Line Platformer ships one gold pickup tile
|
||||||
|
and no rotation frames, so `sprite_ss_coin.json` is a single frame. Nothing was
|
||||||
|
invented to pad it out.
|
||||||
|
|
||||||
|
## Reproducing this
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/fetch_tutorial_assets.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetches all three packs, checks each page and each shipped `License.txt` says
|
||||||
|
CC0, verifies the source images are the size the frame arithmetic assumes,
|
||||||
|
repacks into a temporary directory, checks every staged file's dimensions, and
|
||||||
|
only then moves anything into place. A failure at any step leaves the tracked
|
||||||
|
copies untouched and exits non-zero. The output is byte-for-byte reproducible --
|
||||||
|
ImageMagick's wall-clock `date:create` and `date:modify` chunks are stripped, so
|
||||||
|
a refresh that changes nothing produces no diff.
|
||||||
|
|
||||||
|
The script does not touch the JSON or the TMJ maps. Those are hand-maintained.
|
||||||
162
docs/tutorials/assets/README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# The tutorial asset contract
|
||||||
|
|
||||||
|
What `examples/sidescroller/` and `examples/jrpg/` are drawing, and the exact
|
||||||
|
numbers they have to use. Licensing is in `PROVENANCE.md`; refreshing the art
|
||||||
|
from upstream is `scripts/fetch_tutorial_assets.sh`.
|
||||||
|
|
||||||
|
Every claim below was checked against `src/`, not against header prose, and the
|
||||||
|
whole set was loaded through the real library -- `akgl_sprite_load_json`,
|
||||||
|
`akgl_character_load_json`, `akgl_tilemap_load` -- before being committed.
|
||||||
|
|
||||||
|
## Geometry
|
||||||
|
|
||||||
|
| Thing | Value | Why it is that value |
|
||||||
|
|-------------------------|--------|---------------------------------------------------------------------------|
|
||||||
|
| Tile | 16x16 | Both tilesets ship on a 16x16 grid |
|
||||||
|
| Tileset spacing, margin | 0, 0 | `akgl_tilemap_compute_tileset_offsets` gets nothing else right; see below |
|
||||||
|
| Character frame | 32x32 | The fixed contract for these tutorials |
|
||||||
|
| Art inside a frame | 16x16 | Composited at (8, 16): centred, sitting on the cell's bottom edge |
|
||||||
|
| Frames per sheet row | all | Every sheet is a single row, so `coords_for_frame` never has to wrap |
|
||||||
|
| Frames per animation | max 16 | `AKGL_SPRITE_MAX_FRAMES`; a 17th is `AKERR_OUTOFBOUNDS` at load |
|
||||||
|
| Frame id | 0..255 | `uint8_t`; a larger id is `AKERR_OUTOFBOUNDS` at load |
|
||||||
|
|
||||||
|
**The one alignment rule.** `akgl_actor_render` draws into a rectangle of
|
||||||
|
`sprite->width` by `sprite->height` with its top-left at the actor's `x`, `y`.
|
||||||
|
Because every frame's art is bottom-centred in its cell, an actor at `(x, y)`
|
||||||
|
has its feet at `y + 32` and its 16 px body spanning `x + 8` to `x + 23`. Ground
|
||||||
|
contact, pickup tests and hitboxes should use that inner rectangle, not the
|
||||||
|
32x32 cell.
|
||||||
|
|
||||||
|
## Sidescroller sheets
|
||||||
|
|
||||||
|
`sidescroller/player.png` -- 192x32, six frames.
|
||||||
|
|
||||||
|
| Frame | Facing | Pose | Used by |
|
||||||
|
|-------|--------|---------------------------------|-----------------------------------------------|
|
||||||
|
| 0 | right | contact | `ss_player_idle_right`, `ss_player_run_right` |
|
||||||
|
| 1 | right | passing, airborne | `ss_player_run_right`, `ss_player_jump_right` |
|
||||||
|
| 2 | right | opposite contact | `ss_player_run_right` |
|
||||||
|
| 3 | left | contact (mirror of 0) | `ss_player_idle_left`, `ss_player_run_left` |
|
||||||
|
| 4 | left | passing, airborne (mirror of 1) | `ss_player_run_left`, `ss_player_jump_left` |
|
||||||
|
| 5 | left | opposite contact (mirror of 2) | `ss_player_run_left` |
|
||||||
|
|
||||||
|
The left-facing frames exist as their own art because `akgl_actor_render` passes
|
||||||
|
`SDL_FLIP_NONE` to `draw_texture` unconditionally (`src/actor.c`). There is no
|
||||||
|
way to ask the library for a mirrored blit.
|
||||||
|
|
||||||
|
`sidescroller/coin.png` -- 32x32, one frame. The pack ships no rotation frames.
|
||||||
|
|
||||||
|
`sidescroller/hazard.png` -- 128x32, four frames.
|
||||||
|
|
||||||
|
| Frame | What | Used by |
|
||||||
|
|-------|--------------------------|------------------|
|
||||||
|
| 0 | red blob, ground, pose A | `ss_hazard_blob` |
|
||||||
|
| 1 | red blob, ground, pose B | `ss_hazard_blob` |
|
||||||
|
| 2 | moth, flying, wings up | `ss_hazard_moth` |
|
||||||
|
| 3 | moth, flying, wings down | `ss_hazard_moth` |
|
||||||
|
|
||||||
|
## JRPG sheets
|
||||||
|
|
||||||
|
`jrpg/player.png`, `jrpg/npc_shopkeeper.png` and `jrpg/npc_elder.png` are
|
||||||
|
384x32, twelve frames, and share one layout.
|
||||||
|
|
||||||
|
| Frames | Facing | Poses | Idle sprite uses | Walk sprite uses |
|
||||||
|
|--------|--------|-----------------------|------------------|------------------|
|
||||||
|
| 0-2 | down | stand, step A, step B | 0 | 1, 0, 2, 0 |
|
||||||
|
| 3-5 | left | stand, step A, step B | 3 | 4, 3, 5, 3 |
|
||||||
|
| 6-8 | right | stand, step A, step B | 6 | 7, 6, 8, 6 |
|
||||||
|
| 9-11 | up | stand, step A, step B | 9 | 10, 9, 11, 9 |
|
||||||
|
|
||||||
|
The stand frame between the two steps is what makes it read as a walk rather
|
||||||
|
than a shuffle; it is the same three-frame cycle the source art was drawn for.
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
A character's sprite map is keyed by `SDL_itoa(state)` and looked up with
|
||||||
|
`SDL_GetPointerProperty` (`akgl_character_sprite_get`), so **the match is on the
|
||||||
|
whole integer, not on a mask test**. A state the character has no exact entry
|
||||||
|
for makes the actor invisible for that frame -- `actor_visible` treats
|
||||||
|
`AKERR_KEY` as "nothing to draw", which is an answer, not an error.
|
||||||
|
|
||||||
|
These are the values the library's own input handlers produce.
|
||||||
|
`akgl_actor_cmhf_<dir>_on` clears every `FACE_*` and `MOVING_*` bit before
|
||||||
|
setting its own pair, and `_off` clears only its `MOVING_*` bit, so the facing
|
||||||
|
survives the key release and no two `MOVING_*` bits are ever set at once.
|
||||||
|
|
||||||
|
| State | Bits | Sidescroller sprite | JRPG sprite |
|
||||||
|
|-------|-------------------------------------|---------------------|------------------|
|
||||||
|
| 16 | ALIVE | (coin, hazards) | -- |
|
||||||
|
| 17 | ALIVE, FACE_DOWN | `..._idle_right` | `..._idle_down` |
|
||||||
|
| 18 | ALIVE, FACE_LEFT | `..._idle_left` | `..._idle_left` |
|
||||||
|
| 20 | ALIVE, FACE_RIGHT | `..._idle_right` | `..._idle_right` |
|
||||||
|
| 24 | ALIVE, FACE_UP | -- | `..._idle_up` |
|
||||||
|
| 146 | ALIVE, FACE_LEFT, MOVING_LEFT | `..._run_left` | `..._walk_left` |
|
||||||
|
| 276 | ALIVE, FACE_RIGHT, MOVING_RIGHT | `..._run_right` | `..._walk_right` |
|
||||||
|
| 530 | ALIVE, FACE_LEFT, MOVING_UP | `..._jump_left` | -- |
|
||||||
|
| 532 | ALIVE, FACE_RIGHT, MOVING_UP | `..._jump_right` | -- |
|
||||||
|
| 536 | ALIVE, FACE_UP, MOVING_UP | `..._jump_right` | `..._walk_up` |
|
||||||
|
| 658 | ALIVE, FACE_LEFT, MOVING_LEFT, UP | `..._jump_left` | -- |
|
||||||
|
| 788 | ALIVE, FACE_RIGHT, MOVING_RIGHT, UP | `..._jump_right` | -- |
|
||||||
|
| 1041 | ALIVE, FACE_DOWN, MOVING_DOWN | -- | `..._walk_down` |
|
||||||
|
|
||||||
|
The four jump states are there because a sidescroller that routes its jump
|
||||||
|
through `akgl_actor_cmhf_up_on` lands on 536 -- that handler clears `FACE_RIGHT`
|
||||||
|
and sets `FACE_UP`, which in a side view is not a facing at all. A game that
|
||||||
|
sets `ey` directly instead never reaches those states, and nothing breaks
|
||||||
|
either way.
|
||||||
|
|
||||||
|
State names in character JSON are the **prefixed** spellings from
|
||||||
|
`src/actor_state_string_names.c`: `AKGL_ACTOR_STATE_ALIVE`, not
|
||||||
|
`ACTOR_STATE_ALIVE`. `util/assets/littleguy.json` uses the old unprefixed names
|
||||||
|
and `velocity_x`, and does not load against the current library. Do not copy it.
|
||||||
|
|
||||||
|
## Maps
|
||||||
|
|
||||||
|
Both maps are Tiled 1.8 TMJ with the tileset **embedded**.
|
||||||
|
|
||||||
|
`akgl_tilemap_load` reads `columns`, `firstgid`, `tilecount`, `image`,
|
||||||
|
`imagewidth`, `imageheight`, `margin`, `spacing`, `tilewidth`, `tileheight` and
|
||||||
|
`name` straight out of each element of the map's `tilesets` array
|
||||||
|
(`akgl_tilemap_load_tilesets_each`). The string `source` appears nowhere in
|
||||||
|
`src/tilemap.c` and nothing in the library ever opens a `.tsj`, so an external
|
||||||
|
tileset reference fails with `AKERR_KEY` on the missing `columns`. `README.md`
|
||||||
|
in the repository root says the opposite; it is wrong.
|
||||||
|
|
||||||
|
| Map | Cells | Layers | Objects |
|
||||||
|
|---------------------------|-------|-------------------------------------------|---------|
|
||||||
|
| `sidescroller/level1.tmj` | 40x15 | background (tile), terrain (tile), actors | 7 |
|
||||||
|
| `jrpg/town.tmj` | 30x20 | ground (tile), decoration (tile), actors | 3 |
|
||||||
|
|
||||||
|
Global tile ids are `(row * columns) + column + 1`; `0` means an empty cell and
|
||||||
|
is skipped. `sidescroller/tiles.png` has ten columns and 60 tiles;
|
||||||
|
`jrpg/tiles.png` has twenty-seven columns and 486 tiles, the last four columns
|
||||||
|
of which are the character art the sheets above were cut from. Those cells are
|
||||||
|
never referenced by `town.tmj`.
|
||||||
|
|
||||||
|
Other things the loader insists on, all of them checked:
|
||||||
|
|
||||||
|
- **Every object needs a `type` string**, including ones that are not actors. A
|
||||||
|
missing `type` fails the whole load, not just that object.
|
||||||
|
- An actor object needs a non-empty `name`, a `character` **string** property
|
||||||
|
and a `state` **int** property. The string-array form of `state` works in
|
||||||
|
character JSON and is not accepted here.
|
||||||
|
- The `character` named must already be in `AKGL_REGISTRY_CHARACTER`, which
|
||||||
|
means every sprite and character JSON has to be loaded before the map.
|
||||||
|
- A map's `properties` are optional; if present, `physics.model` must name a
|
||||||
|
backend that exists (`null` or `arcade`) or the load fails. Gravity and drag
|
||||||
|
keys are `float`.
|
||||||
|
- Tileset image paths resolve through `akgl_path_relative` against the map's own
|
||||||
|
directory. Image-layer paths do not -- those go through a plain `"%s/%s"`
|
||||||
|
join, so an absolute path there does not work. Keep every path relative.
|
||||||
|
|
||||||
|
## Physics
|
||||||
|
|
||||||
|
The sidescroller map asks for the `arcade` backend with `physics.gravity.y`
|
||||||
|
900.0 and `physics.drag.y` 1.5. There is no terminal velocity in the backend;
|
||||||
|
`ey` approaches `gravity_y / drag_y`, so those two numbers set it to 600 px/s.
|
||||||
|
The JRPG map asks for `arcade` with both gravity components at 0.0.
|
||||||
|
|
||||||
|
`ss_player` has `speed_y` and `acceleration_y` of 0.0 on purpose: a zero top
|
||||||
|
speed on an axis means `akgl_physics_simulate` zeroes that axis's thrust
|
||||||
|
outright, so holding a vertical direction cannot make the player fly. A jump
|
||||||
|
belongs in `ey`.
|
||||||
70
docs/tutorials/assets/jrpg/character_jrpg_elder.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "jrpg_elder",
|
||||||
|
"speedtime": 150,
|
||||||
|
"speed_x": 60.0,
|
||||||
|
"speed_y": 60.0,
|
||||||
|
"acceleration_x": 400.0,
|
||||||
|
"acceleration_y": 400.0,
|
||||||
|
"sprite_mappings": [
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_idle_down"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_walk_down"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_idle_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_walk_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_idle_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_walk_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_idle_up"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_elder_walk_up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
70
docs/tutorials/assets/jrpg/character_jrpg_player.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "jrpg_player",
|
||||||
|
"speedtime": 150,
|
||||||
|
"speed_x": 60.0,
|
||||||
|
"speed_y": 60.0,
|
||||||
|
"acceleration_x": 400.0,
|
||||||
|
"acceleration_y": 400.0,
|
||||||
|
"sprite_mappings": [
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_idle_down"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_walk_down"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_idle_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_walk_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_idle_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_walk_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_idle_up"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_player_walk_up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
70
docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "jrpg_shopkeeper",
|
||||||
|
"speedtime": 150,
|
||||||
|
"speed_x": 60.0,
|
||||||
|
"speed_y": 60.0,
|
||||||
|
"acceleration_x": 400.0,
|
||||||
|
"acceleration_y": 400.0,
|
||||||
|
"sprite_mappings": [
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_idle_down"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_walk_down"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_idle_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_walk_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_idle_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_walk_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_idle_up"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "jrpg_shopkeeper_walk_up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
docs/tutorials/assets/jrpg/jingle_start.ogg
Normal file
BIN
docs/tutorials/assets/jrpg/npc_elder.png
Normal file
|
After Width: | Height: | Size: 729 B |
BIN
docs/tutorials/assets/jrpg/npc_shopkeeper.png
Normal file
|
After Width: | Height: | Size: 783 B |
BIN
docs/tutorials/assets/jrpg/player.png
Normal file
|
After Width: | Height: | Size: 640 B |
16
docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_idle_down",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_idle_left",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_idle_right",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
6
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_idle_up",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
9
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_walk_down",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_walk_left",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_walk_right",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
7,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
6
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "npc_elder.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_elder_walk_up",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
10,
|
||||||
|
9,
|
||||||
|
11,
|
||||||
|
9
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "player.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_player_idle_down",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "player.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_player_idle_left",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
16
docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "player.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_player_idle_up",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 250,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
9
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "player.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_player_walk_down",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "player.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_player_walk_left",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
19
docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "player.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "jrpg_player_walk_up",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 150,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
10,
|
||||||
|
9,
|
||||||
|
11,
|
||||||
|
9
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
docs/tutorials/assets/jrpg/tiles.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
1356
docs/tutorials/assets/jrpg/town.tmj
Normal file
16
docs/tutorials/assets/sidescroller/character_ss_coin.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "ss_coin",
|
||||||
|
"speedtime": 200,
|
||||||
|
"speed_x": 0.0,
|
||||||
|
"speed_y": 0.0,
|
||||||
|
"acceleration_x": 0.0,
|
||||||
|
"acceleration_y": 0.0,
|
||||||
|
"sprite_mappings": [
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE"
|
||||||
|
],
|
||||||
|
"sprite": "ss_coin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
89
docs/tutorials/assets/sidescroller/character_ss_player.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "ss_player",
|
||||||
|
"speedtime": 120,
|
||||||
|
"speed_x": 90.0,
|
||||||
|
"speed_y": 0.0,
|
||||||
|
"acceleration_x": 600.0,
|
||||||
|
"acceleration_y": 0.0,
|
||||||
|
"sprite_mappings": [
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_idle_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_run_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_idle_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_run_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_DOWN"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_idle_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_UP",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_jump_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_jump_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_jump_left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_RIGHT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_jump_right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": [
|
||||||
|
"AKGL_ACTOR_STATE_ALIVE",
|
||||||
|
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_LEFT",
|
||||||
|
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||||
|
],
|
||||||
|
"sprite": "ss_player_jump_left"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
docs/tutorials/assets/sidescroller/coin.png
Normal file
|
After Width: | Height: | Size: 122 B |
BIN
docs/tutorials/assets/sidescroller/hazard.png
Normal file
|
After Width: | Height: | Size: 246 B |
BIN
docs/tutorials/assets/sidescroller/jingle_start.ogg
Normal file
1448
docs/tutorials/assets/sidescroller/level1.tmj
Normal file
BIN
docs/tutorials/assets/sidescroller/player.png
Normal file
|
After Width: | Height: | Size: 383 B |
16
docs/tutorials/assets/sidescroller/sprite_ss_coin.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"spritesheet": {
|
||||||
|
"filename": "coin.png",
|
||||||
|
"frame_width": 32,
|
||||||
|
"frame_height": 32
|
||||||
|
},
|
||||||
|
"name": "ss_coin",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"speed": 200,
|
||||||
|
"loop": true,
|
||||||
|
"loopReverse": false,
|
||||||
|
"frames": [
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
docs/tutorials/assets/sidescroller/tiles.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
72
examples/jrpg/CMakeLists.txt
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# The top-down JRPG tutorial game, quoted by docs/20-tutorial-jrpg.md.
|
||||||
|
#
|
||||||
|
# It is a real target built by `all`, not a snippet: a tutorial whose program
|
||||||
|
# does not compile is the failure this whole documentation effort exists to
|
||||||
|
# stop, and the chapter's `c excerpt=` blocks fail the moment this source moves
|
||||||
|
# under them.
|
||||||
|
|
||||||
|
add_executable(jrpg
|
||||||
|
jrpg.c
|
||||||
|
world.c
|
||||||
|
textbox.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(jrpg
|
||||||
|
PRIVATE akstdlib::akstdlib akerror::akerror akgl
|
||||||
|
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer
|
||||||
|
jansson::jansson -lm)
|
||||||
|
target_include_directories(jrpg PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
|
target_compile_options(jrpg PRIVATE ${AKGL_WARNING_FLAGS})
|
||||||
|
|
||||||
|
# The game finds its data through two absolute paths baked in here. A game that
|
||||||
|
# ships would install its assets and resolve them against SDL_GetBasePath(); a
|
||||||
|
# tutorial has to run from the build tree, from the source tree, and from
|
||||||
|
# whatever working directory CTest hands it, so the paths are compiled in.
|
||||||
|
get_filename_component(JRPG_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||||
|
target_compile_definitions(jrpg PRIVATE
|
||||||
|
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
|
||||||
|
# tests/assets/akgl_test_mono.ttf, which ships beside its licence file.
|
||||||
|
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The vendored SDL satellite libraries land in per-project subdirectories that
|
||||||
|
# are not on the loader's default search path, exactly as they do for the test
|
||||||
|
# suites. AKGL_VENDORED_RPATH is set by the top-level CMakeLists.txt and is only
|
||||||
|
# defined when something was actually vendored.
|
||||||
|
if(AKGL_VENDORED_DEPENDENCIES)
|
||||||
|
set_target_properties(jrpg PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# The headless smoke run. `--demo` drives the arrow keys from a script and steps
|
||||||
|
# the physics clock by a fixed 1/60 s, so the run is deterministic and finishes
|
||||||
|
# in well under a second; `--frames` bounds it. Between them the run walks the
|
||||||
|
# player into a wall, opens the text box, and exercises the follower's render
|
||||||
|
# hook -- rather than just proving that main() returns.
|
||||||
|
#
|
||||||
|
# add_test() reaches the real command here: the top-level CMakeLists.txt shadows
|
||||||
|
# it to suppress the vendored projects' registrations and lifts the suppression
|
||||||
|
# again long before examples/ is added.
|
||||||
|
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
|
||||||
|
set_tests_properties(example_jrpg PROPERTIES
|
||||||
|
TIMEOUT 120
|
||||||
|
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has
|
||||||
|
# to go in the same property rather than a second one. Only needed when the
|
||||||
|
# dependencies were vendored; an installed build resolves them normally.
|
||||||
|
if(AKGL_VENDORED_DEPENDENCIES)
|
||||||
|
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||||
|
set(JRPG_TEST_ENV_MOD "")
|
||||||
|
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||||
|
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||||
|
endforeach()
|
||||||
|
set_tests_properties(example_jrpg
|
||||||
|
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
|
||||||
|
else()
|
||||||
|
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||||
|
set_tests_properties(example_jrpg PROPERTIES
|
||||||
|
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
369
examples/jrpg/jrpg.c
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* @file jrpg.c
|
||||||
|
* @brief A small top-down JRPG: startup, the frame loop, and teardown.
|
||||||
|
*
|
||||||
|
* Chapter 20 of the manual quotes this program rather than restating it, so
|
||||||
|
* what is here is what the chapter teaches. Run it with no arguments for a
|
||||||
|
* window you can walk around in:
|
||||||
|
*
|
||||||
|
* ./examples/jrpg/jrpg
|
||||||
|
*
|
||||||
|
* Arrow keys walk, space talks to whoever is standing next to you and dismisses
|
||||||
|
* the box again. `--frames N` stops after N frames, which is what makes this
|
||||||
|
* runnable as a headless smoke test; `--demo` drives the arrow keys from a
|
||||||
|
* script and steps the physics clock by a fixed interval so the run is
|
||||||
|
* deterministic and takes no wall-clock time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <SDL3_ttf/SDL_ttf.h>
|
||||||
|
#include <akerror.h>
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/controller.h>
|
||||||
|
#include <akgl/error.h>
|
||||||
|
#include <akgl/game.h>
|
||||||
|
#include <akgl/iterator.h>
|
||||||
|
#include <akgl/physics.h>
|
||||||
|
#include <akgl/registry.h>
|
||||||
|
#include <akgl/renderer.h>
|
||||||
|
#include <akgl/text.h>
|
||||||
|
#include <akgl/tilemap.h>
|
||||||
|
|
||||||
|
#include "jrpg.h"
|
||||||
|
|
||||||
|
/** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */
|
||||||
|
#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
|
||||||
|
/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
|
||||||
|
#define JRPG_FRAME_BUDGET_MS 16
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The scripted walk `--demo` drives. The player starts at (224, 256) and the
|
||||||
|
* elder stands at (272, 112), so this is: right for a second, up for two and a
|
||||||
|
* bit, talk, try to walk while frozen, dismiss, walk away. Every one of those
|
||||||
|
* is a path the interactive game has and a headless run would otherwise never
|
||||||
|
* touch.
|
||||||
|
*
|
||||||
|
* Frame Key Down What it is for
|
||||||
|
*/
|
||||||
|
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
|
||||||
|
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
|
||||||
|
{ 70, SDLK_RIGHT, false },
|
||||||
|
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
|
||||||
|
{ 205, SDLK_UP, false },
|
||||||
|
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
|
||||||
|
{ 216, SDLK_SPACE, false },
|
||||||
|
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
|
||||||
|
{ 245, SDLK_LEFT, false },
|
||||||
|
{ 255, SDLK_SPACE, true }, /* dismiss */
|
||||||
|
{ 256, SDLK_SPACE, false },
|
||||||
|
{ 265, SDLK_DOWN, true }, /* and walk away */
|
||||||
|
{ 285, SDLK_DOWN, false }
|
||||||
|
};
|
||||||
|
|
||||||
|
#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0]))
|
||||||
|
|
||||||
|
static long frame_limit = 0;
|
||||||
|
static bool demo = false;
|
||||||
|
static bool running = true;
|
||||||
|
static int exitstatus = 0;
|
||||||
|
static bool lowfps_warned = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second.
|
||||||
|
*
|
||||||
|
* akgl_game.fps is a completed-second average, so it reads 0 for the first
|
||||||
|
* second of every process -- which is under the threshold, so the default hook
|
||||||
|
* logs a line on every frame until the first second is up. The hook exists to
|
||||||
|
* be replaced; a real game sheds work here rather than talking about it.
|
||||||
|
*/
|
||||||
|
static void lowfps_quiet(void)
|
||||||
|
{
|
||||||
|
if ( lowfps_warned == false ) {
|
||||||
|
lowfps_warned = true;
|
||||||
|
SDL_Log("Frame rate is under 30 and this game does nothing about it");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Read `--frames N` and `--demo` off the command line.
|
||||||
|
*
|
||||||
|
* @param argc Argument count, from `main`.
|
||||||
|
* @param argv Argument vector, from `main`. Required.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p argv is `NULL`.
|
||||||
|
* @throws AKERR_VALUE If `--frames` is last, or its argument is not a number.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *parse_args(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
int i = 0;
|
||||||
|
int frames = 0;
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv");
|
||||||
|
|
||||||
|
for ( i = 1; i < argc; i++ ) {
|
||||||
|
if ( strcmp(argv[i], "--demo") == 0 ) {
|
||||||
|
demo = true;
|
||||||
|
} else if ( strcmp(argv[i], "--frames") == 0 ) {
|
||||||
|
if ( (i + 1) >= argc ) {
|
||||||
|
FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count");
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
PASS(errctx, aksl_atoi(argv[i], &frames));
|
||||||
|
frame_limit = frames;
|
||||||
|
} else {
|
||||||
|
FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bring the library up, load the town, and bind the controls.
|
||||||
|
*
|
||||||
|
* The order below is the one game.h documents and the only one that works.
|
||||||
|
* Three things in it are not obvious and are what chapter 20 spends its time
|
||||||
|
* on: the properties have to be set between akgl_game_init and
|
||||||
|
* akgl_render_2d_init, because that is what reads them; the physics backend has
|
||||||
|
* to be initialized by hand, because akgl_game_init does not; and the assets
|
||||||
|
* have to be loaded sprites-then-characters-then-map.
|
||||||
|
*
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *startup(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_Control talk;
|
||||||
|
|
||||||
|
// Required before akgl_game_init, which refuses to run without all three:
|
||||||
|
// the window title, SDL's application metadata and the savegame
|
||||||
|
// compatibility check are built from them.
|
||||||
|
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", sizeof(akgl_game.name) - 1));
|
||||||
|
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1));
|
||||||
|
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.jrpg", sizeof(akgl_game.uri) - 1));
|
||||||
|
|
||||||
|
PASS(errctx, akgl_game_init());
|
||||||
|
akgl_game.lowfpsfunc = &lowfps_quiet;
|
||||||
|
|
||||||
|
PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH));
|
||||||
|
PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT));
|
||||||
|
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||||
|
|
||||||
|
// akgl_game_init points akgl_physics at akgl_default_physics and stops
|
||||||
|
// there. That storage is BSS, so all four of its method pointers are NULL,
|
||||||
|
// and akgl_game_update calls `akgl_physics->simulate(...)` without checking
|
||||||
|
// it: a NULL function pointer, and a segmentation fault on frame one rather
|
||||||
|
// than an error context.
|
||||||
|
//
|
||||||
|
// town.tmj happens to declare its own physics, and jrpg_world_load switches
|
||||||
|
// to the backend that builds -- so removing this line alone leaves *this*
|
||||||
|
// program working, and removing it along with that switch is a SIGSEGV on
|
||||||
|
// frame one. Measured, by removing both. This line is what makes the
|
||||||
|
// program correct for a map that declares no physics, which is most of
|
||||||
|
// them. Nothing calls the factory for you either way. A top-down game wants
|
||||||
|
// the arcade backend with no gravity, which is what the property defaults
|
||||||
|
// already give.
|
||||||
|
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||||
|
|
||||||
|
PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE));
|
||||||
|
|
||||||
|
PASS(errctx, jrpg_world_load());
|
||||||
|
PASS(errctx, jrpg_world_populate());
|
||||||
|
|
||||||
|
// The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in
|
||||||
|
// one call. Keyboard id 0 and gamepad id 0 mean "the first of each".
|
||||||
|
PASS(errctx, akgl_controller_default(0, "player", 0, 0));
|
||||||
|
|
||||||
|
// And one binding of our own on the same map. A binding is a struct, copied
|
||||||
|
// in, so a stack local is fine. handler_off has to be non-NULL --
|
||||||
|
// akgl_controller_handle_event does not check it before calling it -- and
|
||||||
|
// the button is set to something no gamepad reports, because the gamepad
|
||||||
|
// and keyboard arms of the match are evaluated together.
|
||||||
|
PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk)));
|
||||||
|
talk.event_on = SDL_EVENT_KEY_DOWN;
|
||||||
|
talk.event_off = SDL_EVENT_KEY_UP;
|
||||||
|
talk.key = SDLK_SPACE;
|
||||||
|
talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID;
|
||||||
|
talk.handler_on = &jrpg_cmhf_talk;
|
||||||
|
talk.handler_off = &jrpg_cmhf_ignore;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(0, &talk));
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Feed the scripted keystrokes due on this frame into the control maps.
|
||||||
|
*
|
||||||
|
* Synthesized `SDL_Event`s rather than direct calls to the handlers, so the
|
||||||
|
* demo goes through akgl_controller_handle_event, the control map lookup and
|
||||||
|
* the binding -- the same path a real key takes. A test that skipped that would
|
||||||
|
* not be testing the thing that breaks.
|
||||||
|
*
|
||||||
|
* @param frameno The frame about to be drawn.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *demo_step(long frameno)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
SDL_Event event;
|
||||||
|
size_t i = 0;
|
||||||
|
|
||||||
|
for ( i = 0; i < JRPG_DEMO_STEPS; i++ ) {
|
||||||
|
if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
|
||||||
|
if ( JRPG_DEMO_SCRIPT[i].down ) {
|
||||||
|
event.type = SDL_EVENT_KEY_DOWN;
|
||||||
|
} else {
|
||||||
|
event.type = SDL_EVENT_KEY_UP;
|
||||||
|
}
|
||||||
|
event.key.which = 0;
|
||||||
|
event.key.key = JRPG_DEMO_SCRIPT[i].key;
|
||||||
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief One frame: events, camera, the library's own update, the box, present.
|
||||||
|
*
|
||||||
|
* akgl_game_update is update-every-actor, step the physics, draw the world. It
|
||||||
|
* does *not* clear or present the target, so the frame is bracketed by the
|
||||||
|
* backend's own frame_start and frame_end, and anything drawn between the
|
||||||
|
* update and frame_end lands on top of the world.
|
||||||
|
*
|
||||||
|
* @param frameno The frame number, for the demo script.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *frame(long frameno)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
SDL_Event event;
|
||||||
|
akgl_Iterator opflags = {
|
||||||
|
.flags = AKGL_ITERATOR_OP_UPDATE,
|
||||||
|
.layerid = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
while ( SDL_PollEvent(&event) ) {
|
||||||
|
if ( event.type == SDL_EVENT_QUIT ) {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||||
|
}
|
||||||
|
if ( demo ) {
|
||||||
|
PASS(errctx, demo_step(frameno));
|
||||||
|
// Drive the clock rather than sleeping on it. akgl_physics_simulate
|
||||||
|
// measures dt from gravity_time, which is a public field, so setting it
|
||||||
|
// one fixed step into the past makes every frame worth exactly 1/60 of
|
||||||
|
// a second of simulated time no matter how fast the loop actually runs.
|
||||||
|
// tests/physics_sim.c does the same, for the same reason: a simulated
|
||||||
|
// second should not cost a real one.
|
||||||
|
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
|
||||||
|
}
|
||||||
|
|
||||||
|
PASS(errctx, jrpg_camera_follow());
|
||||||
|
|
||||||
|
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||||
|
// Every failure path out of akgl_game_update returns with the game state
|
||||||
|
// mutex still held. SDL's mutexes are recursive and this loop is single
|
||||||
|
// threaded, so the next frame would not deadlock -- but it would be running
|
||||||
|
// on top of a frame that did not finish. Treat a failed frame as terminal.
|
||||||
|
PASS(errctx, akgl_game_update(&opflags));
|
||||||
|
PASS(errctx, jrpg_textbox_draw());
|
||||||
|
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Give back what has to be given back, in the order that works.
|
||||||
|
*
|
||||||
|
* There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three
|
||||||
|
* lines here are load-bearing and the third is a deliberate omission:
|
||||||
|
*
|
||||||
|
* - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept in
|
||||||
|
* an SDL property registry, and `SDL_Quit` destroys the registry -- taking
|
||||||
|
* the last reference to every font still in it with no way left to close
|
||||||
|
* them.
|
||||||
|
* - akgl_tilemap_release is **not** called. Its layer loop destroys
|
||||||
|
* `tilesets[i].texture` rather than `layers[i].texture`, so it double-frees
|
||||||
|
* every tileset texture and never frees an image layer's, and it NULLs
|
||||||
|
* nothing, so a second call is a use-after-free. TODO.md, "Known and still
|
||||||
|
* open" item 2. `SDL_Quit` reclaims the textures correctly; calling the
|
||||||
|
* function that is supposed to would be worse than not.
|
||||||
|
* - The pools are static storage. There is nothing to free and the process is
|
||||||
|
* about to exit.
|
||||||
|
*/
|
||||||
|
static void teardown(void)
|
||||||
|
{
|
||||||
|
IGNORE(akgl_text_unloadallfonts());
|
||||||
|
if ( akgl_window != NULL ) {
|
||||||
|
SDL_DestroyWindow(akgl_window);
|
||||||
|
akgl_window = NULL;
|
||||||
|
}
|
||||||
|
SDL_Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_Actor *player = NULL;
|
||||||
|
long frameno = 0;
|
||||||
|
uint64_t started = 0;
|
||||||
|
uint64_t spent = 0;
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
CATCH(errctx, parse_args(argc, argv));
|
||||||
|
CATCH(errctx, startup());
|
||||||
|
// Taken here rather than after FINISH: the registry is an SDL property
|
||||||
|
// set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards
|
||||||
|
// finds nothing. The actor itself is in a static pool and outlives both.
|
||||||
|
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||||
|
|
||||||
|
// The loop is the last thing in this ATTEMPT block on purpose. CATCH
|
||||||
|
// reports failure by `break`ing, and a `break` inside a loop binds to
|
||||||
|
// the loop rather than to the block -- so a failing frame leaves the
|
||||||
|
// loop and falls straight into CLEANUP, which is what is wanted. Put
|
||||||
|
// anything after this loop and it would run after a failure instead.
|
||||||
|
// src/tilemap.c carries the same note over the same shape.
|
||||||
|
while ( running ) {
|
||||||
|
started = SDL_GetTicks();
|
||||||
|
CATCH(errctx, frame(frameno));
|
||||||
|
frameno += 1;
|
||||||
|
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
if ( demo == false ) {
|
||||||
|
spent = SDL_GetTicks() - started;
|
||||||
|
if ( spent < JRPG_FRAME_BUDGET_MS ) {
|
||||||
|
SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} CLEANUP {
|
||||||
|
teardown();
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} HANDLE_DEFAULT(errctx) {
|
||||||
|
LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish");
|
||||||
|
// Set a flag and act on it after FINISH. A bare `return` from inside a
|
||||||
|
// HANDLE block leaves before RELEASE_ERROR and leaks the context's pool
|
||||||
|
// slot; AGENTS.md spells that out, and an example is a bad place to
|
||||||
|
// teach it wrong.
|
||||||
|
exitstatus = 1;
|
||||||
|
} FINISH_NORETURN(errctx);
|
||||||
|
|
||||||
|
// Enough for the smoke run to be read rather than merely passed. A run that
|
||||||
|
// exits 0 having drawn nothing looks exactly like one that worked.
|
||||||
|
if ( player != NULL ) {
|
||||||
|
printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
|
||||||
|
} else {
|
||||||
|
printf("jrpg: %ld frames, no player\n", frameno);
|
||||||
|
}
|
||||||
|
return exitstatus;
|
||||||
|
}
|
||||||
188
examples/jrpg/jrpg.h
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @file jrpg.h
|
||||||
|
* @brief Shared declarations for the JRPG tutorial game.
|
||||||
|
*
|
||||||
|
* Every function with external linkage in this program is declared here, the
|
||||||
|
* way AGENTS.md requires of the library itself. It is a three-translation-unit
|
||||||
|
* program and it would compile without the header; declaring them anyway is
|
||||||
|
* what keeps a signature from drifting between the definition and the call.
|
||||||
|
*
|
||||||
|
* Everything this program exports carries the `jrpg_` prefix. `static` helpers
|
||||||
|
* drop it, because the prefix exists only to avoid collisions with libakgl,
|
||||||
|
* SDL and libc -- the same rule, for the same reason.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _JRPG_JRPG_H_
|
||||||
|
#define _JRPG_JRPG_H_
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <akerror.h>
|
||||||
|
#include <akgl/actor.h>
|
||||||
|
#include <akgl/types.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Where the game's data lives. Both are absolute paths baked in by
|
||||||
|
* examples/jrpg/CMakeLists.txt, because the tutorial has to run from the build
|
||||||
|
* tree, from the source tree, and from wherever CTest happens to put its
|
||||||
|
* working directory. The fallbacks are what a reader compiling this by hand
|
||||||
|
* from the repository root would want.
|
||||||
|
*/
|
||||||
|
#ifndef JRPG_ASSET_DIR
|
||||||
|
#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg"
|
||||||
|
#endif
|
||||||
|
#ifndef JRPG_FONT_FILE
|
||||||
|
#define JRPG_FONT_FILE "tests/assets/akgl_test_mono.ttf"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The screen size is written as text because that is the only form
|
||||||
|
* akgl_set_property takes, and akgl_render_2d_init copies it onto `akgl_camera`
|
||||||
|
* on the way past. Everything below reads the camera rather than a second copy
|
||||||
|
* of the same two numbers.
|
||||||
|
*/
|
||||||
|
#define JRPG_SCREEN_WIDTH "320"
|
||||||
|
#define JRPG_SCREEN_HEIGHT "240"
|
||||||
|
|
||||||
|
/** @brief Registry name of the one font this game loads. */
|
||||||
|
#define JRPG_FONT_NAME "jrpg"
|
||||||
|
/** @brief Point size the font is rasterized at. A size is baked into the handle. */
|
||||||
|
#define JRPG_FONT_SIZE 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Index of the tile layer this game treats as solid.
|
||||||
|
*
|
||||||
|
* An index, not a name, because akgl_TilemapLayer has no `name` member: the
|
||||||
|
* loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data,
|
||||||
|
* and drops the name Tiled wrote. A map cannot say "the layer called
|
||||||
|
* collision", so the game and the map agree on a number. See chapter 20.
|
||||||
|
*/
|
||||||
|
#define JRPG_LAYER_SOLID 1
|
||||||
|
|
||||||
|
/** @brief Longest line an NPC can say, terminator included. */
|
||||||
|
#define JRPG_TEXTBOX_MAX_TEXT 256
|
||||||
|
|
||||||
|
/** @brief Registry name of the party member this game attaches to the player. */
|
||||||
|
#define JRPG_FOLLOWER_NAME "companion"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief One entry in the scripted demo the headless smoke run drives.
|
||||||
|
*
|
||||||
|
* The keystrokes go in through akgl_controller_handle_event like any other
|
||||||
|
* event, so the smoke run exercises the same binding, state and physics path a
|
||||||
|
* player does rather than a separate one written to be testable.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
long frame; /**< Frame number this step fires on. */
|
||||||
|
SDL_Keycode key; /**< Key to synthesize. */
|
||||||
|
bool down; /**< True for a press, false for a release. */
|
||||||
|
} jrpg_ScriptStep;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Load every sprite, then every character, then the town map.
|
||||||
|
*
|
||||||
|
* In that order, and the order is not a preference: akgl_character_load_json
|
||||||
|
* resolves each sprite name through #AKGL_REGISTRY_SPRITE, and
|
||||||
|
* akgl_tilemap_load resolves each `character` property through
|
||||||
|
* #AKGL_REGISTRY_CHARACTER while it spawns the map's actor objects.
|
||||||
|
*
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
|
||||||
|
/**
|
||||||
|
* @brief Fix up the actors the map spawned, and attach the party member.
|
||||||
|
*
|
||||||
|
* Clears `movement_controls_face` on everything -- without which the default
|
||||||
|
* `facefunc` erases the facing bit the map set and every NPC stops being drawn
|
||||||
|
* on frame one -- installs the blocking movement logic on the player, and
|
||||||
|
* builds the follower as a child actor.
|
||||||
|
*
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKGL_ERR_REGISTRY If the map spawned no actor named "player".
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
|
||||||
|
/**
|
||||||
|
* @brief Centre the camera on the player, clamped to the edges of the map.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKGL_ERR_REGISTRY If there is no actor named "player".
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
|
||||||
|
/**
|
||||||
|
* @brief The player's `movementlogicfunc`: the library default, plus walls.
|
||||||
|
*
|
||||||
|
* libakgl implements no collision at all -- akgl_physics_arcade_collide raises
|
||||||
|
* AKERR_API, akgl_physics_simulate never calls `collide`, and
|
||||||
|
* akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall
|
||||||
|
* writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open,
|
||||||
|
* which is the documented way to tell the simulator to skip an actor's tick.
|
||||||
|
*
|
||||||
|
* @param obj The actor to compute acceleration for. Required, along with its
|
||||||
|
* `basechar`.
|
||||||
|
* @param dt Seconds this step covers.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||||
|
* @throws AKGL_ERR_LOGICINTERRUPT While a conversation is open.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt);
|
||||||
|
/**
|
||||||
|
* @brief The follower's `renderfunc`: akgl_actor_render with the parent detached.
|
||||||
|
*
|
||||||
|
* The guard for a defect. akgl_physics_simulate writes a child's `x` as
|
||||||
|
* `parent->x + vx` -- an absolute world position -- and akgl_actor_render then
|
||||||
|
* draws it at `parent->x + obj->x`, adding the parent's position a second time.
|
||||||
|
* Nulling `parent` for the duration of the draw takes the branch that does not
|
||||||
|
* add it. See chapter 20.
|
||||||
|
*
|
||||||
|
* @param obj The child actor to draw. Required.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj);
|
||||||
|
/**
|
||||||
|
* @brief Control handler: talk to whoever is standing nearby, or close the box.
|
||||||
|
* @param obj The actor the control map drives -- the player. Required.
|
||||||
|
* @param event The event that triggered this. Required, but not read.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event);
|
||||||
|
/**
|
||||||
|
* @brief Control handler that does nothing, for the release half of a binding.
|
||||||
|
*
|
||||||
|
* akgl_controller_handle_event does not check a matched binding's handler
|
||||||
|
* pointer before calling it, so a binding with a `NULL` `handler_off` is a
|
||||||
|
* crash rather than an error. Every binding gets both halves.
|
||||||
|
*
|
||||||
|
* @param obj The actor the control map drives. Required.
|
||||||
|
* @param event The event that triggered this. Required.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Put a line of dialogue on screen and freeze the world.
|
||||||
|
* @param text The line to show. Required. Truncated at #JRPG_TEXTBOX_MAX_TEXT.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
* @throws AKERR_NULLPOINTER If @p text is `NULL`.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text);
|
||||||
|
/** @brief Dismiss the text box. */
|
||||||
|
void jrpg_textbox_close(void);
|
||||||
|
/** @brief True while a line of dialogue is on screen. */
|
||||||
|
bool jrpg_textbox_showing(void);
|
||||||
|
/**
|
||||||
|
* @brief Draw the panel and its text, if there is anything to draw.
|
||||||
|
*
|
||||||
|
* Called after akgl_game_update, so it lands on top of the world rather than
|
||||||
|
* under it, and in screen coordinates rather than world ones --
|
||||||
|
* akgl_text_rendertextat does not go through the camera, which is what a HUD
|
||||||
|
* wants.
|
||||||
|
*
|
||||||
|
* @return `NULL` on success -- including when the box is closed -- otherwise an
|
||||||
|
* error context owned by the caller.
|
||||||
|
* @throws AKGL_ERR_REGISTRY If #JRPG_FONT_NAME is not a loaded font.
|
||||||
|
*/
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void);
|
||||||
|
|
||||||
|
#endif // _JRPG_JRPG_H_
|
||||||
125
examples/jrpg/textbox.c
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @file textbox.c
|
||||||
|
* @brief The dialogue panel: two rectangles and a string, drawn over the world.
|
||||||
|
*
|
||||||
|
* Text in libakgl is immediate mode. Every akgl_text_rendertextat call
|
||||||
|
* rasterizes the string through SDL_ttf, uploads it as a texture, blits it and
|
||||||
|
* destroys the texture again -- there is no cached glyph atlas and no text
|
||||||
|
* object to hold on to. That is the wrong shape for a page of static prose
|
||||||
|
* redrawn sixty times a second and exactly the right shape for this: one short
|
||||||
|
* line, on screen only while somebody is talking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <SDL3_ttf/SDL_ttf.h>
|
||||||
|
#include <akerror.h>
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/draw.h>
|
||||||
|
#include <akgl/error.h>
|
||||||
|
#include <akgl/game.h>
|
||||||
|
#include <akgl/registry.h>
|
||||||
|
#include <akgl/text.h>
|
||||||
|
|
||||||
|
#include "jrpg.h"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Colour R G B A
|
||||||
|
*/
|
||||||
|
static const SDL_Color TEXTBOX_FILL = { 24, 20, 37, 235 };
|
||||||
|
static const SDL_Color TEXTBOX_EDGE = { 240, 236, 214, 255 };
|
||||||
|
static const SDL_Color TEXTBOX_INK = { 240, 236, 214, 255 };
|
||||||
|
|
||||||
|
/** @brief Pixels between the panel and the edges of the screen. */
|
||||||
|
#define TEXTBOX_MARGIN 8.0f
|
||||||
|
/** @brief Panel height in pixels. Two lines of 12pt monospace, plus padding. */
|
||||||
|
#define TEXTBOX_HEIGHT 56.0f
|
||||||
|
/** @brief Pixels between the panel's edge and the text inside it. */
|
||||||
|
#define TEXTBOX_PADDING 8.0f
|
||||||
|
|
||||||
|
static char textbox_text[JRPG_TEXTBOX_MAX_TEXT];
|
||||||
|
static bool textbox_visible = false;
|
||||||
|
|
||||||
|
bool jrpg_textbox_showing(void)
|
||||||
|
{
|
||||||
|
return textbox_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
void jrpg_textbox_close(void)
|
||||||
|
{
|
||||||
|
textbox_visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_textbox_open(char *text)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
|
||||||
|
// aksl_strncpy, not strncpy: this is a fixed-width field, and strncpy at
|
||||||
|
// exactly the field width leaves it unterminated. Bounded at one less than
|
||||||
|
// the field so an over-long line truncates rather than being refused.
|
||||||
|
PASS(errctx,
|
||||||
|
aksl_strncpy(
|
||||||
|
textbox_text,
|
||||||
|
sizeof(textbox_text),
|
||||||
|
text,
|
||||||
|
sizeof(textbox_text) - 1
|
||||||
|
));
|
||||||
|
textbox_visible = true;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_textbox_draw(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
TTF_Font *font = NULL;
|
||||||
|
SDL_FRect panel;
|
||||||
|
|
||||||
|
if ( textbox_visible == false ) {
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
// akgl_text_rendertextat refuses the empty string -- SDL_ttf reports "Text
|
||||||
|
// has zero width" and the library passes that on as AKERR_NULLPOINTER,
|
||||||
|
// while akgl_text_measure accepts it. The two disagree, so anything drawing
|
||||||
|
// a line that might be empty checks for it. TODO.md, "Known and still
|
||||||
|
// open".
|
||||||
|
if ( textbox_text[0] == '\0' ) {
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonts live in a registry under a name, not in an akgl type. There is no
|
||||||
|
// handle to keep, and no reference counting either: whoever calls
|
||||||
|
// akgl_text_unloadfont invalidates every pointer anybody else fetched.
|
||||||
|
// Fetching it per frame is the cheap way to stay honest about that.
|
||||||
|
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
font,
|
||||||
|
AKGL_ERR_REGISTRY,
|
||||||
|
"No font registered as \"%s\"",
|
||||||
|
JRPG_FONT_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
// Screen coordinates, from the camera's size rather than from a second copy
|
||||||
|
// of the window dimensions. akgl_render_2d_init put them there.
|
||||||
|
panel.x = TEXTBOX_MARGIN;
|
||||||
|
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
|
||||||
|
panel.h = TEXTBOX_HEIGHT;
|
||||||
|
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
|
||||||
|
|
||||||
|
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
|
||||||
|
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
|
||||||
|
PASS(errctx,
|
||||||
|
akgl_text_rendertextat(
|
||||||
|
font,
|
||||||
|
textbox_text,
|
||||||
|
TEXTBOX_INK,
|
||||||
|
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
|
||||||
|
(int)(panel.x + TEXTBOX_PADDING),
|
||||||
|
(int)(panel.y + TEXTBOX_PADDING)
|
||||||
|
));
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
458
examples/jrpg/world.c
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
/**
|
||||||
|
* @file world.c
|
||||||
|
* @brief The content pipeline: sprites, characters, the town map, and what the
|
||||||
|
* map's actors do once they exist.
|
||||||
|
*
|
||||||
|
* Almost nothing here is game logic. It is the four steps between a directory
|
||||||
|
* of JSON and a world with people standing in it, in the one order that works,
|
||||||
|
* plus the two hooks -- a `movementlogicfunc` and a `renderfunc` -- that a game
|
||||||
|
* has to supply because the library does not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <limits.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <akerror.h>
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/actor.h>
|
||||||
|
#include <akgl/character.h>
|
||||||
|
#include <akgl/error.h>
|
||||||
|
#include <akgl/game.h>
|
||||||
|
#include <akgl/heap.h>
|
||||||
|
#include <akgl/physics.h>
|
||||||
|
#include <akgl/registry.h>
|
||||||
|
#include <akgl/sprite.h>
|
||||||
|
#include <akgl/tilemap.h>
|
||||||
|
|
||||||
|
#include "jrpg.h"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The three character templates and the twenty-four sprites that dress them.
|
||||||
|
* The file names are a product of these three tables rather than a list of
|
||||||
|
* twenty-four strings, because that is what the naming convention *is*: one
|
||||||
|
* sprite per character, per motion, per facing. A missing combination shows up
|
||||||
|
* as a load failure naming the file, not as art that silently never appears.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Character template Sprite name prefix */
|
||||||
|
static char *CAST[] = {
|
||||||
|
"player", /* jrpg_player jrpg_player_* */
|
||||||
|
"elder", /* jrpg_elder jrpg_elder_* */
|
||||||
|
"shopkeeper" /* jrpg_shopkeeper jrpg_shopkeeper_* */
|
||||||
|
};
|
||||||
|
|
||||||
|
static char *MOTIONS[] = { "idle", "walk" };
|
||||||
|
static char *FACINGS[] = { "up", "down", "left", "right" };
|
||||||
|
|
||||||
|
#define CAST_COUNT (sizeof(CAST) / sizeof(CAST[0]))
|
||||||
|
#define MOTION_COUNT (sizeof(MOTIONS) / sizeof(MOTIONS[0]))
|
||||||
|
#define FACING_COUNT (sizeof(FACINGS) / sizeof(FACINGS[0]))
|
||||||
|
|
||||||
|
/** @brief One person in the town who has something to say. */
|
||||||
|
typedef struct {
|
||||||
|
char *actor; /**< Registry key, which is the `name` of the map object that spawned them. */
|
||||||
|
char *line; /**< What they say. */
|
||||||
|
} jrpg_Townsfolk;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Actor (the map object's name) What they say
|
||||||
|
*/
|
||||||
|
static jrpg_Townsfolk TOWNSFOLK[] = {
|
||||||
|
{ "elder", "ELDER: The old road north is closed. Nothing to be done about it,\nand nothing beyond it worth the walk." },
|
||||||
|
{ "shopkeeper", "SHOPKEEPER: Nothing in stock but tiles and good intentions.\nCome back when I have inventory." }
|
||||||
|
};
|
||||||
|
|
||||||
|
#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
|
||||||
|
|
||||||
|
/** @brief How close the player has to stand, in pixels between sprite centres. */
|
||||||
|
#define TALK_RANGE 64.0f
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The player's footprint, in pixels from the top-left of a 32x32 frame. A
|
||||||
|
* character stands on the bottom third of their sprite, so that is the part
|
||||||
|
* that has to fit through a doorway -- testing the whole frame would make every
|
||||||
|
* corridor two tiles wide.
|
||||||
|
*
|
||||||
|
* 0 32
|
||||||
|
* +------------------------------+ 0
|
||||||
|
* | |
|
||||||
|
* | (art) |
|
||||||
|
* | |
|
||||||
|
* | +----------------+ | 20 FEET_TOP
|
||||||
|
* | | footprint | |
|
||||||
|
* +------+----------------+------+ 30 FEET_TOP + FEET_H
|
||||||
|
* 6 26
|
||||||
|
* FEET_X FEET_X + FEET_W
|
||||||
|
*/
|
||||||
|
#define FEET_X 6.0f
|
||||||
|
#define FEET_W 20.0f
|
||||||
|
#define FEET_TOP 20.0f
|
||||||
|
#define FEET_H 10.0f
|
||||||
|
|
||||||
|
/** @brief Where the party member walks, in pixels relative to the player. */
|
||||||
|
#define FOLLOWER_OFFSET_X (-14.0f)
|
||||||
|
#define FOLLOWER_OFFSET_Y (10.0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Load one sprite definition by its three naming components.
|
||||||
|
*
|
||||||
|
* @param cast Character stem, e.g. `"player"`.
|
||||||
|
* @param motion `"idle"` or `"walk"`.
|
||||||
|
* @param facing `"up"`, `"down"`, `"left"` or `"right"`.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *sprite_load(char *cast, char *motion, char *facing)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
char path[PATH_MAX];
|
||||||
|
int written = 0;
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
|
||||||
|
FAIL_ZERO_RETURN(errctx, motion, AKERR_NULLPOINTER, "motion");
|
||||||
|
FAIL_ZERO_RETURN(errctx, facing, AKERR_NULLPOINTER, "facing");
|
||||||
|
|
||||||
|
PASS(errctx,
|
||||||
|
aksl_snprintf(
|
||||||
|
&written,
|
||||||
|
path,
|
||||||
|
sizeof(path),
|
||||||
|
"%s/sprite_jrpg_%s_%s_%s.json",
|
||||||
|
JRPG_ASSET_DIR,
|
||||||
|
cast,
|
||||||
|
motion,
|
||||||
|
facing
|
||||||
|
));
|
||||||
|
PASS(errctx, akgl_sprite_load_json(path));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Load one character definition by its stem.
|
||||||
|
* @param cast Character stem, e.g. `"player"`.
|
||||||
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *character_load(char *cast)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
char path[PATH_MAX];
|
||||||
|
int written = 0;
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
|
||||||
|
PASS(errctx,
|
||||||
|
aksl_snprintf(
|
||||||
|
&written,
|
||||||
|
path,
|
||||||
|
sizeof(path),
|
||||||
|
"%s/character_jrpg_%s.json",
|
||||||
|
JRPG_ASSET_DIR,
|
||||||
|
cast
|
||||||
|
));
|
||||||
|
PASS(errctx, akgl_character_load_json(path));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_world_load(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
char path[PATH_MAX];
|
||||||
|
int written = 0;
|
||||||
|
size_t c = 0;
|
||||||
|
size_t m = 0;
|
||||||
|
size_t f = 0;
|
||||||
|
|
||||||
|
// Sprites first. A character JSON names its sprites by registry key and
|
||||||
|
// akgl_character_load_json refuses one it cannot find, so loading the two
|
||||||
|
// the other way round fails on every mapping in the file.
|
||||||
|
for ( c = 0; c < CAST_COUNT; c++ ) {
|
||||||
|
for ( m = 0; m < MOTION_COUNT; m++ ) {
|
||||||
|
for ( f = 0; f < FACING_COUNT; f++ ) {
|
||||||
|
PASS(errctx, sprite_load(CAST[c], MOTIONS[m], FACINGS[f]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ( c = 0; c < CAST_COUNT; c++ ) {
|
||||||
|
PASS(errctx, character_load(CAST[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the map last: loading it creates an actor for every `actor` object in
|
||||||
|
// its object layer, and each of those resolves a character by name.
|
||||||
|
PASS(errctx,
|
||||||
|
aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR));
|
||||||
|
PASS(errctx, akgl_tilemap_load(path, akgl_gamemap));
|
||||||
|
|
||||||
|
// The map declares its own physics -- `physics.model` and zero gravity on
|
||||||
|
// both axes -- and akgl_tilemap_load builds a backend for it and sets
|
||||||
|
// use_own_physics. It does not *switch* to it: akgl_game_update simulates
|
||||||
|
// through the global akgl_physics, whatever that points at. Honouring the
|
||||||
|
// map is the caller's job, and this is it.
|
||||||
|
if ( akgl_gamemap->use_own_physics ) {
|
||||||
|
akgl_physics = &akgl_gamemap->physics;
|
||||||
|
}
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_world_populate(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_Actor *player = NULL;
|
||||||
|
akgl_Actor *follower = NULL;
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
// Every actor the map spawned, including the player.
|
||||||
|
//
|
||||||
|
// akgl_actor_initialize sets movement_controls_face, and the default
|
||||||
|
// facefunc it installs clears every facing bit and then sets the one
|
||||||
|
// matching a *movement* bit. An NPC has no movement bits, so on the first
|
||||||
|
// frame its state falls from ALIVE|FACE_DOWN (17) to ALIVE (16), which is
|
||||||
|
// a state combination no character JSON maps a sprite to -- and an actor
|
||||||
|
// with no sprite for its state is skipped rather than reported. Every NPC
|
||||||
|
// in the town disappears on frame one, silently, and so does the player as
|
||||||
|
// soon as they stop walking.
|
||||||
|
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||||
|
if ( akgl_heap_actors[i].refcount == 0 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
akgl_heap_actors[i].movement_controls_face = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
player,
|
||||||
|
AKGL_ERR_REGISTRY,
|
||||||
|
"town.tmj spawned no actor named \"player\""
|
||||||
|
);
|
||||||
|
player->movementlogicfunc = &jrpg_actor_logic_walk;
|
||||||
|
|
||||||
|
// The party member. Nothing in the map creates it, and nothing has to: an
|
||||||
|
// actor is a pool object with a name, and the character it instantiates is
|
||||||
|
// already registered. This one borrows the elder's template, which is the
|
||||||
|
// whole point of splitting instance from template.
|
||||||
|
PASS(errctx, akgl_heap_next_actor(&follower));
|
||||||
|
PASS(errctx, akgl_actor_initialize(follower, JRPG_FOLLOWER_NAME));
|
||||||
|
PASS(errctx, akgl_actor_set_character(follower, "jrpg_elder"));
|
||||||
|
follower->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN);
|
||||||
|
follower->movement_controls_face = false;
|
||||||
|
follower->visible = true;
|
||||||
|
follower->layer = player->layer;
|
||||||
|
follower->renderfunc = &jrpg_follower_render;
|
||||||
|
PASS(errctx, player->addchild(player, follower));
|
||||||
|
|
||||||
|
// A child's velocity fields are read as a fixed offset from the parent
|
||||||
|
// rather than as a velocity: akgl_physics_simulate snaps a child to
|
||||||
|
// `parent->x + vx` and never simulates it. Set after addchild, because
|
||||||
|
// addchild is what makes them mean an offset.
|
||||||
|
follower->vx = FOLLOWER_OFFSET_X;
|
||||||
|
follower->vy = FOLLOWER_OFFSET_Y;
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Is the map cell containing this pixel one the player cannot enter?
|
||||||
|
*
|
||||||
|
* Two rules, and both of them are the game's rather than the library's. The
|
||||||
|
* outer ring of the map is solid, because nothing else stops an actor walking
|
||||||
|
* off the edge of the world. Everything else is the decoration layer: a cell
|
||||||
|
* with a tile in it is a building, a tree or a lamp post, and a cell with 0 in
|
||||||
|
* it is open ground.
|
||||||
|
*
|
||||||
|
* @param px Position in map pixels along x.
|
||||||
|
* @param py Position in map pixels along y.
|
||||||
|
* @return True when the cell blocks movement.
|
||||||
|
*/
|
||||||
|
static bool cell_solid(float32_t px, float32_t py)
|
||||||
|
{
|
||||||
|
int tx = 0;
|
||||||
|
int ty = 0;
|
||||||
|
|
||||||
|
tx = (int)(px / (float32_t)akgl_gamemap->tilewidth);
|
||||||
|
ty = (int)(py / (float32_t)akgl_gamemap->tileheight);
|
||||||
|
|
||||||
|
if ( (tx <= 0) || (ty <= 0) ||
|
||||||
|
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Would an actor whose frame is at (x, y) have its feet in a wall?
|
||||||
|
*
|
||||||
|
* Tests the four corners of the footprint. Four corners is enough only because
|
||||||
|
* the footprint is smaller than a tile in both directions; a bigger one would
|
||||||
|
* step over a single-cell wall between two of its corners.
|
||||||
|
*
|
||||||
|
* @param x Candidate frame position along x, in map pixels.
|
||||||
|
* @param y Candidate frame position along y, in map pixels.
|
||||||
|
* @return True when any corner of the footprint lands in a solid cell.
|
||||||
|
*/
|
||||||
|
static bool feet_blocked(float32_t x, float32_t y)
|
||||||
|
{
|
||||||
|
float32_t left = x + FEET_X;
|
||||||
|
float32_t right = x + FEET_X + FEET_W;
|
||||||
|
float32_t top = y + FEET_TOP;
|
||||||
|
float32_t bottom = y + FEET_TOP + FEET_H;
|
||||||
|
|
||||||
|
return (cell_solid(left, top) ||
|
||||||
|
cell_solid(right, top) ||
|
||||||
|
cell_solid(left, bottom) ||
|
||||||
|
cell_solid(right, bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "obj->basechar");
|
||||||
|
|
||||||
|
// A conversation freezes the world. AKGL_ERR_LOGICINTERRUPT raised from a
|
||||||
|
// movementlogicfunc means "skip the rest of this tick for this actor", and
|
||||||
|
// akgl_physics_simulate swallows it in a HANDLE block -- so gravity, drag,
|
||||||
|
// the velocity recompute and the move are all skipped and the frame carries
|
||||||
|
// on. Thrust has already been integrated by the time this runs, so it is
|
||||||
|
// zeroed here; leaving it would let it accumulate while frozen and lurch on
|
||||||
|
// the first step after the box closes. The movement bits go too, so the
|
||||||
|
// walk animation stops rather than marching on the spot -- which does mean
|
||||||
|
// a direction held across the close has to be pressed again.
|
||||||
|
if ( jrpg_textbox_showing() ) {
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
obj->ty = 0.0f;
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_ALL);
|
||||||
|
FAIL_RETURN(
|
||||||
|
errctx,
|
||||||
|
AKGL_ERR_LOGICINTERRUPT,
|
||||||
|
"%s does not move while a conversation is open",
|
||||||
|
(char *)obj->name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything the default hook does -- re-copy the character's speed limits,
|
||||||
|
// turn the movement bits into signed acceleration -- is still wanted. This
|
||||||
|
// hook adds to it rather than replacing it.
|
||||||
|
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||||
|
|
||||||
|
// Where this step would put us. akgl_physics_simulate has already
|
||||||
|
// integrated thrust for this step and capped it against the character's
|
||||||
|
// speed ellipse, and it computes velocity as `e + t` -- and with the town's
|
||||||
|
// zero gravity and zero drag, `e` stays zero, so `v` is `t`. That is what
|
||||||
|
// makes the prediction exact rather than approximate. A map with gravity
|
||||||
|
// would have to account for `ey` here as well.
|
||||||
|
//
|
||||||
|
// Axis at a time, so walking into a wall diagonally slides along it rather
|
||||||
|
// than stopping dead.
|
||||||
|
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) {
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
obj->ax = 0.0f;
|
||||||
|
}
|
||||||
|
if ( feet_blocked(obj->x, obj->y + (obj->ty * dt)) ) {
|
||||||
|
obj->ty = 0.0f;
|
||||||
|
obj->ay = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_follower_render(akgl_Actor *obj)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_Actor *parent = NULL;
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
|
||||||
|
// The guard. akgl_physics_simulate has already written this actor's x and y
|
||||||
|
// as `parent->x + vx` and `parent->y + vy`, which is an absolute world
|
||||||
|
// position; akgl_actor_render adds the parent's position to it a second
|
||||||
|
// time. Detaching the parent for the duration of the draw takes the branch
|
||||||
|
// that does not. CLEANUP puts it back on every path, including the failing
|
||||||
|
// one -- an actor left with a NULL parent would be simulated as a free
|
||||||
|
// agent on the very next step.
|
||||||
|
parent = obj->parent;
|
||||||
|
obj->parent = NULL;
|
||||||
|
ATTEMPT {
|
||||||
|
CATCH(errctx, akgl_actor_render(obj));
|
||||||
|
} CLEANUP {
|
||||||
|
obj->parent = parent;
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} FINISH(errctx, true);
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_camera_follow(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_Actor *player = NULL;
|
||||||
|
float32_t mapw = 0.0f;
|
||||||
|
float32_t maph = 0.0f;
|
||||||
|
|
||||||
|
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||||
|
FAIL_ZERO_RETURN(errctx, player, AKGL_ERR_REGISTRY, "No actor named \"player\"");
|
||||||
|
|
||||||
|
mapw = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth);
|
||||||
|
maph = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight);
|
||||||
|
|
||||||
|
akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f);
|
||||||
|
akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f);
|
||||||
|
|
||||||
|
if ( akgl_camera->x < 0.0f ) {
|
||||||
|
akgl_camera->x = 0.0f;
|
||||||
|
}
|
||||||
|
if ( akgl_camera->y < 0.0f ) {
|
||||||
|
akgl_camera->y = 0.0f;
|
||||||
|
}
|
||||||
|
if ( akgl_camera->x > (mapw - akgl_camera->w) ) {
|
||||||
|
akgl_camera->x = mapw - akgl_camera->w;
|
||||||
|
}
|
||||||
|
if ( akgl_camera->y > (maph - akgl_camera->h) ) {
|
||||||
|
akgl_camera->y = maph - akgl_camera->h;
|
||||||
|
}
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
akgl_Actor *npc = NULL;
|
||||||
|
size_t i = 0;
|
||||||
|
float32_t dx = 0.0f;
|
||||||
|
float32_t dy = 0.0f;
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
|
||||||
|
if ( jrpg_textbox_showing() ) {
|
||||||
|
jrpg_textbox_close();
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
for ( i = 0; i < TOWNSFOLK_COUNT; i++ ) {
|
||||||
|
npc = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, TOWNSFOLK[i].actor, NULL);
|
||||||
|
if ( npc == NULL ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dx = npc->x - obj->x;
|
||||||
|
dy = npc->y - obj->y;
|
||||||
|
if ( sqrtf((dx * dx) + (dy * dy)) > TALK_RANGE ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PASS(errctx, jrpg_textbox_open(TOWNSFOLK[i].line));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
48
examples/sidescroller/CMakeLists.txt
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# The sidescroller tutorial game.
|
||||||
|
#
|
||||||
|
# A real target, built by default, so docs/19-tutorial-sidescroller.md cannot
|
||||||
|
# quote a program that does not compile -- and registered as a headless smoke
|
||||||
|
# test, so it cannot quote one that does not run.
|
||||||
|
|
||||||
|
get_filename_component(SS_ASSET_DIR
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../../docs/tutorials/assets/sidescroller" ABSOLUTE)
|
||||||
|
|
||||||
|
add_executable(sidescroller
|
||||||
|
main.c
|
||||||
|
collision.c
|
||||||
|
player.c
|
||||||
|
actors.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
|
target_compile_options(sidescroller PRIVATE ${AKGL_WARNING_FLAGS})
|
||||||
|
|
||||||
|
# Baked in rather than looked up at runtime, so the smoke test can be launched
|
||||||
|
# from any working directory. `--assets DIR` overrides it for a reader who has
|
||||||
|
# moved the art somewhere else.
|
||||||
|
target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"")
|
||||||
|
|
||||||
|
target_link_libraries(sidescroller
|
||||||
|
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf
|
||||||
|
SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||||
|
|
||||||
|
# A vendored build leaves the SDL satellite libraries in per-project
|
||||||
|
# subdirectories that are not on the loader's default search path, which is the
|
||||||
|
# same problem AKGL_VENDORED_RPATH solves for the test executables.
|
||||||
|
if(AKGL_VENDORED_DEPENDENCIES)
|
||||||
|
set_target_properties(sidescroller PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Four seconds of scripted play under the headless drivers: the level loads, the
|
||||||
|
# player walks and jumps, the swept collision runs against real terrain, and the
|
||||||
|
# program tears down and exits 0. A tutorial that stops working fails here rather
|
||||||
|
# than in front of a reader.
|
||||||
|
#
|
||||||
|
# add_test() rather than _add_test(): the root CMakeLists.txt shadows it only
|
||||||
|
# while this project is top-level, and by the time examples/ is added the
|
||||||
|
# suppression has already been lifted. An embedded build gets the builtin.
|
||||||
|
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
|
||||||
|
set_tests_properties(example_sidescroller PROPERTIES
|
||||||
|
TIMEOUT 120
|
||||||
|
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||||
|
)
|
||||||
183
examples/sidescroller/actors.c
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* @file actors.c
|
||||||
|
* @brief Everything level1.tmj places that is not the player.
|
||||||
|
*
|
||||||
|
* None of these are created here. The map's object layer creates all seven
|
||||||
|
* actors, registers them under the names Tiled gave them, and binds each one to
|
||||||
|
* the character its `character` property names -- so this file's whole job is to
|
||||||
|
* find them again by name and give them behaviour.
|
||||||
|
*
|
||||||
|
* Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is the one
|
||||||
|
* status in libakgl that is not a failure. Raised from a `movementlogicfunc` it
|
||||||
|
* means "skip the rest of this tick for me": no gravity, no drag, no move for
|
||||||
|
* that actor this step, and akgl_physics_simulate carries on to the next one.
|
||||||
|
* That is exactly what an actor that has just placed itself wants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/heap.h>
|
||||||
|
#include <akgl/registry.h>
|
||||||
|
|
||||||
|
#include "sidescroller.h"
|
||||||
|
|
||||||
|
/** @brief The blob's collision box, offset into its 32x32 frame. */
|
||||||
|
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Per-actor data for everything in this file, one slot per actor.
|
||||||
|
*
|
||||||
|
* A fixed table rather than an allocation, for the same reason the library has
|
||||||
|
* pools rather than a `malloc`: the level places a known number of things, and a
|
||||||
|
* game that cannot run out of memory at runtime is one fewer failure mode.
|
||||||
|
*/
|
||||||
|
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Look an actor up by the name its Tiled object carried.
|
||||||
|
*
|
||||||
|
* A miss here means the map and the code disagree about what the level
|
||||||
|
* contains, which is worth failing on rather than working around.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
|
||||||
|
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
|
||||||
|
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief A `movementlogicfunc` for something that does not move at all.
|
||||||
|
*
|
||||||
|
* The coins need this. Their character declares no speed and no acceleration, so
|
||||||
|
* they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity`
|
||||||
|
* accumulates into `ey` for every actor it is handed, so a coin left to the
|
||||||
|
* default logic falls out of the level along with everything else.
|
||||||
|
*
|
||||||
|
* Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its
|
||||||
|
* step. It is not an error and nothing logs it.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
(void)dt;
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The blob: walk, and turn around at a wall or a ledge.
|
||||||
|
*
|
||||||
|
* This one stays inside the physics step -- it walks on the ground under the
|
||||||
|
* map's gravity like the player does, and uses the same swept resolution.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||||
|
{
|
||||||
|
ss_ActorData *data = NULL;
|
||||||
|
ss_Contact contact;
|
||||||
|
float32_t probe_x = 0.0f;
|
||||||
|
float32_t probe_y = 0.0f;
|
||||||
|
bool floor_ahead = false;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||||
|
data = (ss_ActorData *)obj->actorData;
|
||||||
|
|
||||||
|
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
||||||
|
if ( data->facing < 0.0f ) {
|
||||||
|
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
|
||||||
|
} else {
|
||||||
|
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
|
||||||
|
}
|
||||||
|
/* Signs the acceleration from the movement bits just set. */
|
||||||
|
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||||
|
|
||||||
|
PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
|
||||||
|
|
||||||
|
/* One pixel past the leading edge of the box and one pixel below its feet:
|
||||||
|
* no floor there means the next step walks off. */
|
||||||
|
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
|
||||||
|
if ( data->facing < 0.0f ) {
|
||||||
|
probe_x = obj->x + ss_blob_body.x - 1.0f;
|
||||||
|
} else {
|
||||||
|
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
|
||||||
|
}
|
||||||
|
PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
|
||||||
|
|
||||||
|
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
||||||
|
data->facing = -data->facing;
|
||||||
|
/* The resolution already zeroed `tx` on a blocked axis; zero it on the
|
||||||
|
* ledge path too, or the blob keeps its old momentum through the turn. */
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The moth: a figure-eight around where the map put it.
|
||||||
|
*
|
||||||
|
* It flies, so it wants no gravity -- and the map's physics has gravity, because
|
||||||
|
* the player needs it. Rather than fighting the backend the moth writes its own
|
||||||
|
* position and then opts out of the rest of the step, which is the second thing
|
||||||
|
* #AKGL_ERR_LOGICINTERRUPT is for.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt)
|
||||||
|
{
|
||||||
|
ss_ActorData *data = NULL;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||||
|
data = (ss_ActorData *)obj->actorData;
|
||||||
|
|
||||||
|
data->phase += dt;
|
||||||
|
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
|
||||||
|
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
|
||||||
|
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||||
|
if ( cosf(data->phase) < 0.0f ) {
|
||||||
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||||
|
} else {
|
||||||
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_actors_bind(void)
|
||||||
|
{
|
||||||
|
char name[32];
|
||||||
|
int count = 0;
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
||||||
|
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
|
||||||
|
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
|
||||||
|
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
|
||||||
|
}
|
||||||
|
|
||||||
|
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
|
||||||
|
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
|
||||||
|
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
|
||||||
|
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
|
||||||
|
ss_hazard_data[0].facing = -1.0f;
|
||||||
|
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
|
||||||
|
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
|
||||||
|
|
||||||
|
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
|
||||||
|
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
|
||||||
|
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
|
||||||
|
ss_hazard_data[1].facing = 1.0f;
|
||||||
|
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
|
||||||
|
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
|
||||||
|
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
383
examples/sidescroller/collision.c
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* @file collision.c
|
||||||
|
* @brief The collision libakgl does not have.
|
||||||
|
*
|
||||||
|
* This file exists because of a gap, and the gap is worth stating exactly:
|
||||||
|
*
|
||||||
|
* - `akgl_physics_arcade_collide` raises `AKERR_API` with the message "Not
|
||||||
|
* implemented".
|
||||||
|
* - `akgl_physics_simulate` never calls `collide` at all -- not for the arcade
|
||||||
|
* backend, not for the null one. The vtable slot exists and the simulation
|
||||||
|
* does not use it.
|
||||||
|
* - `akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.
|
||||||
|
* It does not clamp to the map, consult the tilemap, or test anything.
|
||||||
|
*
|
||||||
|
* So an actor walks through a wall and off the edge of the world, and the only
|
||||||
|
* hook that runs inside the physics step is the actor's own `movementlogicfunc`.
|
||||||
|
* That is where this game's collision lives, and everything here is called from
|
||||||
|
* there.
|
||||||
|
*
|
||||||
|
* The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
|
||||||
|
* drag, the velocity recomputation and `move`, so it cannot look at where the
|
||||||
|
* actor ended up -- the step has not happened yet. ss_collide_predict therefore
|
||||||
|
* repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
|
||||||
|
* resolves against that predicted position. Get the prediction wrong and the
|
||||||
|
* actor is resolved against a step it never takes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/heap.h>
|
||||||
|
#include <akgl/physics.h>
|
||||||
|
|
||||||
|
#include "sidescroller.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Half a pixel-thousandth, taken off the far edge of a box before it is
|
||||||
|
* turned into tile indices.
|
||||||
|
*
|
||||||
|
* A box whose right edge sits exactly on a tile boundary does not overlap the
|
||||||
|
* tile on the far side of it. Without this, an actor standing flush against a
|
||||||
|
* wall reads as inside it, and the sweep snaps it back a tile every frame.
|
||||||
|
*/
|
||||||
|
#define SS_COLLIDE_EPSILON 0.001f
|
||||||
|
|
||||||
|
/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
|
||||||
|
#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
|
||||||
|
|
||||||
|
/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
|
||||||
|
#define SS_COLLIDE_SETTLE_TILES 4
|
||||||
|
|
||||||
|
static akgl_TilemapLayer *ss_terrain = NULL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Is this map cell solid?
|
||||||
|
*
|
||||||
|
* Off the left or right edge of the map is solid, so the level has walls at its
|
||||||
|
* ends. Off the top or the bottom is not: the sky is open and the pit in the
|
||||||
|
* middle of level1.tmj has to be fallable-into, which is the whole point of it.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
|
||||||
|
|
||||||
|
if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
|
||||||
|
*dest = true;
|
||||||
|
} else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
|
||||||
|
*dest = false;
|
||||||
|
} else {
|
||||||
|
/* A tile layer's data is global tile ids in row-major order, and 0 is
|
||||||
|
* the empty cell. Any tile at all on the terrain layer is solid: the
|
||||||
|
* level says what is solid by which layer the tile is drawn on. */
|
||||||
|
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
|
||||||
|
|
||||||
|
ss_terrain = NULL;
|
||||||
|
for ( i = 0; i < map->numlayers; i++ ) {
|
||||||
|
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
||||||
|
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
||||||
|
ss_terrain = &map->layers[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
ss_terrain,
|
||||||
|
AKERR_KEY,
|
||||||
|
"Map has no tile layer with id %d to collide against",
|
||||||
|
SS_TERRAIN_LAYER_ID
|
||||||
|
);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
|
||||||
|
{
|
||||||
|
int x0 = 0;
|
||||||
|
int x1 = 0;
|
||||||
|
int y0 = 0;
|
||||||
|
int y1 = 0;
|
||||||
|
int tilex = 0;
|
||||||
|
int tiley = 0;
|
||||||
|
bool solid = false;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
FAIL_NONZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
((box->w <= 0.0f) || (box->h <= 0.0f)),
|
||||||
|
AKERR_VALUE,
|
||||||
|
"Collision box is %fx%f; both sides must be positive",
|
||||||
|
box->w,
|
||||||
|
box->h
|
||||||
|
);
|
||||||
|
|
||||||
|
*dest = false;
|
||||||
|
x0 = (int)floorf(box->x / SS_TILE_SIZE);
|
||||||
|
x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||||
|
y0 = (int)floorf(box->y / SS_TILE_SIZE);
|
||||||
|
y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||||
|
|
||||||
|
for ( tiley = y0; tiley <= y1; tiley++ ) {
|
||||||
|
for ( tilex = x0; tilex <= x1; tilex++ ) {
|
||||||
|
PASS(errctx, solid_tile(tilex, tiley, &solid));
|
||||||
|
if ( solid == true ) {
|
||||||
|
*dest = true;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
|
||||||
|
{
|
||||||
|
float32_t ex = 0.0f;
|
||||||
|
float32_t ey = 0.0f;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
|
||||||
|
FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Everything below is akgl_physics_simulate's own arithmetic, in its own
|
||||||
|
* order: gravity onto the environmental term, then drag off it, then
|
||||||
|
* velocity as environmental plus thrust, then position plus velocity times
|
||||||
|
* dt. The `!= 0` guards are the library's too -- it skips each axis whose
|
||||||
|
* constant is zero rather than multiplying by it -- and are repeated here
|
||||||
|
* because dropping them would make a zero-gravity axis pick up drag.
|
||||||
|
*
|
||||||
|
* This mirrors the *arcade* backend. Against the null backend gravity does
|
||||||
|
* nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
|
||||||
|
* right.
|
||||||
|
*/
|
||||||
|
ex = obj->ex;
|
||||||
|
ey = obj->ey;
|
||||||
|
if ( akgl_physics->gravity_x != 0 ) {
|
||||||
|
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
||||||
|
}
|
||||||
|
if ( akgl_physics->gravity_y != 0 ) {
|
||||||
|
ey += (float32_t)akgl_physics->gravity_y * dt;
|
||||||
|
}
|
||||||
|
if ( akgl_physics->drag_x != 0 ) {
|
||||||
|
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
|
||||||
|
}
|
||||||
|
if ( akgl_physics->drag_y != 0 ) {
|
||||||
|
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
|
||||||
|
}
|
||||||
|
*dx = (ex + obj->tx) * dt;
|
||||||
|
*dy = (ey + obj->ty) * dt;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
|
||||||
|
*
|
||||||
|
* Each axis is tested on its own, which is what lets an actor slide along a wall
|
||||||
|
* instead of sticking to it, and the sub-step is capped at half a tile so a
|
||||||
|
* fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
|
||||||
|
* the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
|
||||||
|
* step, which is nearly two tiles -- so this is not a theoretical concern.
|
||||||
|
*
|
||||||
|
* On a blocked axis the box is snapped to the tile boundary it was about to
|
||||||
|
* cross rather than simply not moved, so an actor lands flush on a floor at
|
||||||
|
* whatever speed it arrives.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
|
||||||
|
{
|
||||||
|
SDL_FRect trial;
|
||||||
|
float32_t span = 0.0f;
|
||||||
|
float32_t stepx = 0.0f;
|
||||||
|
float32_t stepy = 0.0f;
|
||||||
|
int steps = 0;
|
||||||
|
int i = 0;
|
||||||
|
bool solid = false;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
|
||||||
|
span = fabsf(dx);
|
||||||
|
if ( fabsf(dy) > span ) {
|
||||||
|
span = fabsf(dy);
|
||||||
|
}
|
||||||
|
steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
|
||||||
|
stepx = dx / (float32_t)steps;
|
||||||
|
stepy = dy / (float32_t)steps;
|
||||||
|
|
||||||
|
for ( i = 0; i < steps; i++ ) {
|
||||||
|
if ( stepx != 0.0f ) {
|
||||||
|
trial = *box;
|
||||||
|
trial.x += stepx;
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||||
|
if ( solid == true ) {
|
||||||
|
if ( stepx > 0.0f ) {
|
||||||
|
box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
|
||||||
|
} else {
|
||||||
|
box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||||
|
}
|
||||||
|
stepx = 0.0f;
|
||||||
|
dest->blocked_x = true;
|
||||||
|
} else {
|
||||||
|
box->x = trial.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( stepy != 0.0f ) {
|
||||||
|
trial = *box;
|
||||||
|
trial.y += stepy;
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||||
|
if ( solid == true ) {
|
||||||
|
if ( stepy > 0.0f ) {
|
||||||
|
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
|
||||||
|
} else {
|
||||||
|
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||||
|
}
|
||||||
|
stepy = 0.0f;
|
||||||
|
dest->blocked_y = true;
|
||||||
|
} else {
|
||||||
|
box->y = trial.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
|
||||||
|
{
|
||||||
|
SDL_FRect box;
|
||||||
|
SDL_FRect probe;
|
||||||
|
float32_t dx = 0.0f;
|
||||||
|
float32_t dy = 0.0f;
|
||||||
|
bool solid = false;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
|
||||||
|
dest->blocked_x = false;
|
||||||
|
dest->blocked_y = false;
|
||||||
|
dest->grounded = false;
|
||||||
|
|
||||||
|
PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
|
||||||
|
|
||||||
|
box.x = obj->x + body->x;
|
||||||
|
box.y = obj->y + body->y;
|
||||||
|
box.w = body->w;
|
||||||
|
box.h = body->h;
|
||||||
|
PASS(errctx, sweep(&box, dx, dy, dest));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only a blocked axis is written back. The free axis is left for
|
||||||
|
* akgl_physics_arcade_move to advance by exactly the amount this function
|
||||||
|
* predicted -- writing it here as well would move the actor twice.
|
||||||
|
*
|
||||||
|
* `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
|
||||||
|
* effort; a blocked axis has to give up both, or the actor keeps pressing
|
||||||
|
* into the wall and the next step's prediction is wrong by everything it
|
||||||
|
* accumulated while stuck.
|
||||||
|
*
|
||||||
|
* The environmental term is not set to zero, it is set to *minus one step of
|
||||||
|
* gravity*, and that is not a nicety. Zeroing it leaves the step about to add
|
||||||
|
* `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
|
||||||
|
* quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
|
||||||
|
* still fatal: the box now overlaps the floor tile, so the *horizontal*
|
||||||
|
* sweep on the next step finds itself blocked wherever it is, and the actor
|
||||||
|
* is snapped back a whole tile every time it tries to walk. Pre-loading the
|
||||||
|
* cancellation leaves the actor resting exactly on the surface instead.
|
||||||
|
*/
|
||||||
|
if ( dest->blocked_x == true ) {
|
||||||
|
obj->x = box.x - body->x;
|
||||||
|
obj->ex = (float32_t)akgl_physics->gravity_x * dt;
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
}
|
||||||
|
if ( dest->blocked_y == true ) {
|
||||||
|
obj->y = box.y - body->y;
|
||||||
|
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
||||||
|
obj->ty = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Standing on a floor is not a state the library records, so it is measured:
|
||||||
|
* one pixel below where the box will be at the end of this step. Note that
|
||||||
|
* zeroing `ey` above does not stop gravity -- the step still adds
|
||||||
|
* `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
|
||||||
|
* fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
|
||||||
|
* it straight back off, so a standing actor rests within a pixel of the
|
||||||
|
* surface forever rather than sinking through it.
|
||||||
|
*/
|
||||||
|
probe = box;
|
||||||
|
probe.y += 1.0f;
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
||||||
|
dest->grounded = solid;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
|
||||||
|
{
|
||||||
|
SDL_FRect box;
|
||||||
|
bool solid = false;
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
|
||||||
|
* actor can start out with its box partly inside a step -- level1.tmj puts
|
||||||
|
* the player at x=32 and a block at tiles (3,11), which is under the right
|
||||||
|
* half of the player's frame.
|
||||||
|
*
|
||||||
|
* The swept resolution cannot fix that. It stops an actor *entering*
|
||||||
|
* terrain and has nothing to say about one that began inside it; what it
|
||||||
|
* does instead is refuse every horizontal move, because the box is already
|
||||||
|
* blocked wherever it goes. So a spawn point gets lifted clear once, before
|
||||||
|
* the first step, a tile at a time.
|
||||||
|
*/
|
||||||
|
for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
|
||||||
|
box.x = obj->x + body->x;
|
||||||
|
box.y = obj->y + body->y;
|
||||||
|
box.w = body->w;
|
||||||
|
box.h = body->h;
|
||||||
|
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
||||||
|
if ( solid == false ) {
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
obj->y -= (float32_t)SS_TILE_SIZE;
|
||||||
|
SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
|
||||||
|
}
|
||||||
|
FAIL_RETURN(
|
||||||
|
errctx,
|
||||||
|
AKERR_VALUE,
|
||||||
|
"%s spawned inside more than %d tiles of terrain at %f, %f",
|
||||||
|
(char *)obj->name,
|
||||||
|
SS_COLLIDE_SETTLE_TILES,
|
||||||
|
obj->x,
|
||||||
|
obj->y
|
||||||
|
);
|
||||||
|
}
|
||||||
456
examples/sidescroller/main.c
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
/**
|
||||||
|
* @file main.c
|
||||||
|
* @brief Startup, the frame loop, and teardown for the sidescroller tutorial.
|
||||||
|
*
|
||||||
|
* The order everything happens in is the point of this file. libakgl has one
|
||||||
|
* startup sequence that works, documented at the top of `include/akgl/game.h`,
|
||||||
|
* and three of its steps are ones a reader gets wrong the first time:
|
||||||
|
*
|
||||||
|
* - the configuration properties have to be set *before* the renderer and the
|
||||||
|
* physics backend are initialized, because both read them;
|
||||||
|
* - `akgl_game_init` does **not** choose a physics backend, so the application
|
||||||
|
* has to;
|
||||||
|
* - characters name sprites and maps name characters, so the assets load
|
||||||
|
* sprites, then characters, then the map -- and the map creates the actors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#include <SDL3_mixer/SDL_mixer.h>
|
||||||
|
#include <SDL3_ttf/SDL_ttf.h>
|
||||||
|
|
||||||
|
#include <akerror.h>
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/character.h>
|
||||||
|
#include <akgl/controller.h>
|
||||||
|
#include <akgl/heap.h>
|
||||||
|
#include <akgl/physics.h>
|
||||||
|
#include <akgl/registry.h>
|
||||||
|
#include <akgl/renderer.h>
|
||||||
|
#include <akgl/sprite.h>
|
||||||
|
#include <akgl/text.h>
|
||||||
|
#include <akgl/tilemap.h>
|
||||||
|
|
||||||
|
#include "sidescroller.h"
|
||||||
|
|
||||||
|
/** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */
|
||||||
|
#ifndef SS_ASSET_DIR
|
||||||
|
#define SS_ASSET_DIR "."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/** @brief Longest asset path this program will build. */
|
||||||
|
#define SS_PATH_MAX 1024
|
||||||
|
|
||||||
|
ss_Game ss_game;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The sprites, loaded in this order.
|
||||||
|
*
|
||||||
|
* Sprites first and characters second is not a preference. A character's JSON
|
||||||
|
* names its sprites by registry name and `akgl_character_load_json` looks each
|
||||||
|
* one up as it reads the mapping, so a character loaded first fails with
|
||||||
|
* `AKERR_NULLPOINTER` on the first sprite it cannot find.
|
||||||
|
*/
|
||||||
|
static char *ss_sprite_files[] = {
|
||||||
|
"sprite_ss_player_idle_left.json", /* one frame, held */
|
||||||
|
"sprite_ss_player_idle_right.json",
|
||||||
|
"sprite_ss_player_run_left.json", /* four frames at 90 ms */
|
||||||
|
"sprite_ss_player_run_right.json",
|
||||||
|
"sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */
|
||||||
|
"sprite_ss_player_jump_right.json",
|
||||||
|
"sprite_ss_coin.json",
|
||||||
|
"sprite_ss_hazard_blob.json",
|
||||||
|
"sprite_ss_hazard_moth.json",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief The characters. Each one binds state bitmasks to the sprites above. */
|
||||||
|
static char *ss_character_files[] = {
|
||||||
|
"character_ss_player.json",
|
||||||
|
"character_ss_coin.json",
|
||||||
|
"character_ss_hazard_blob.json",
|
||||||
|
"character_ss_hazard_moth.json",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
|
||||||
|
static int ss_failed = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame.
|
||||||
|
*
|
||||||
|
* The default is `akgl_game_lowfps`, and it fires on **every frame** the frame
|
||||||
|
* rate is under 30 -- including every frame of the first second of the process,
|
||||||
|
* because `akgl_game.fps` is a completed-second average and reads 0 until the
|
||||||
|
* first second is up. The hook exists to be replaced; the point of it is that a
|
||||||
|
* game can shed work rather than log about it. This one has nothing to shed.
|
||||||
|
*/
|
||||||
|
static void ss_lowfps(void)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Join the asset directory and a file name into @p dest.
|
||||||
|
*
|
||||||
|
* `aksl_snprintf` rather than `snprintf`, because a path that does not fit has
|
||||||
|
* to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it
|
||||||
|
* reports itself later as a missing file with a name nobody wrote.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
|
||||||
|
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Bring the library up and open the window.
|
||||||
|
*
|
||||||
|
* `akgl_game.name`, `.version` and `.uri` are filled in first because
|
||||||
|
* `akgl_game_init` refuses to run without all three: the window title, SDL's
|
||||||
|
* application metadata and the savegame compatibility check are built from them.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *startup(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
PASS(errctx, aksl_strncpy(
|
||||||
|
(char *)&akgl_game.name,
|
||||||
|
sizeof(akgl_game.name),
|
||||||
|
"libakgl sidescroller tutorial",
|
||||||
|
sizeof(akgl_game.name) - 1));
|
||||||
|
PASS(errctx, aksl_strncpy(
|
||||||
|
(char *)&akgl_game.version,
|
||||||
|
sizeof(akgl_game.version),
|
||||||
|
"1.0.0",
|
||||||
|
sizeof(akgl_game.version) - 1));
|
||||||
|
PASS(errctx, aksl_strncpy(
|
||||||
|
(char *)&akgl_game.uri,
|
||||||
|
sizeof(akgl_game.uri),
|
||||||
|
"net.aklabs.libakgl.sidescroller",
|
||||||
|
sizeof(akgl_game.uri) - 1));
|
||||||
|
|
||||||
|
PASS(errctx, akgl_game_init());
|
||||||
|
akgl_game.lowfpsfunc = &ss_lowfps;
|
||||||
|
|
||||||
|
/* Properties before the renderer: akgl_render_2d_init reads both of these
|
||||||
|
* out of the registry, and an unset one defaults to the string "0", which
|
||||||
|
* asks SDL for a zero-sized window. */
|
||||||
|
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
|
||||||
|
PASS(errctx, akgl_set_property("game.screenheight", "480"));
|
||||||
|
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* libakgl draws in map pixels and has no scale factor of its own -- the only
|
||||||
|
* scaling it applies is the tilemap's perspective band, which this map does
|
||||||
|
* not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more
|
||||||
|
* than that. SDL's logical presentation is the answer: the game renders a
|
||||||
|
* 480x240 view and SDL scales it up by whole multiples to fill the window.
|
||||||
|
*/
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
SDL_SetRenderLogicalPresentation(
|
||||||
|
akgl_renderer->sdl_renderer,
|
||||||
|
SS_VIEW_WIDTH,
|
||||||
|
SS_VIEW_HEIGHT,
|
||||||
|
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
||||||
|
AKGL_ERR_SDL,
|
||||||
|
"%s",
|
||||||
|
SDL_GetError()
|
||||||
|
);
|
||||||
|
|
||||||
|
/* akgl_render_2d_init sized the camera from the window. The view is what the
|
||||||
|
* camera looks through, so it has to say the same thing. */
|
||||||
|
akgl_camera->x = 0.0f;
|
||||||
|
akgl_camera->y = 0.0f;
|
||||||
|
akgl_camera->w = (float32_t)SS_VIEW_WIDTH;
|
||||||
|
akgl_camera->h = (float32_t)SS_VIEW_HEIGHT;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* akgl_game_init does NOT choose a physics backend. It points akgl_physics
|
||||||
|
* at akgl_default_physics, which is zeroed storage -- all four of its method
|
||||||
|
* pointers are NULL -- and never initializes it. There is no
|
||||||
|
* `physics.engine` property either, whatever physics.h says. Skip this call
|
||||||
|
* and the first akgl_game_update calls through a NULL `simulate`.
|
||||||
|
*
|
||||||
|
* The map replaces this backend below with one of its own. It is still done
|
||||||
|
* here, because a game that loads a map without physics properties gets a
|
||||||
|
* working backend rather than a crash.
|
||||||
|
*/
|
||||||
|
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Load the sprites, the characters and the level, in that order.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *load_level(char *assetdir)
|
||||||
|
{
|
||||||
|
char path[SS_PATH_MAX];
|
||||||
|
akgl_Actor *player = NULL;
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
|
||||||
|
|
||||||
|
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
|
||||||
|
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
|
||||||
|
PASS(errctx, akgl_sprite_load_json((char *)&path));
|
||||||
|
}
|
||||||
|
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
|
||||||
|
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
|
||||||
|
PASS(errctx, akgl_character_load_json((char *)&path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static
|
||||||
|
* storage in the library. That is not an accident and it is not a
|
||||||
|
* micro-optimisation: sizeof(akgl_Tilemap) is three times a default 8 MiB
|
||||||
|
* thread stack, so a local one is a segfault before the loader writes a
|
||||||
|
* byte.
|
||||||
|
*
|
||||||
|
* Loading the map creates every `actor` object in its object layers, binds
|
||||||
|
* each to the character its properties name, and publishes it in
|
||||||
|
* AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first.
|
||||||
|
*/
|
||||||
|
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
|
||||||
|
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`,
|
||||||
|
* so the loader built a backend of its own and set `use_own_physics`.
|
||||||
|
* Nothing in the library acts on that flag: akgl_game_update steps the
|
||||||
|
* global akgl_physics and never looks at the map's. Honouring it is this
|
||||||
|
* line, and it is what lets a swimming level and a walking level differ by
|
||||||
|
* data rather than by code.
|
||||||
|
*/
|
||||||
|
if ( akgl_gamemap->use_own_physics == true ) {
|
||||||
|
akgl_physics = &akgl_gamemap->physics;
|
||||||
|
SDL_Log(
|
||||||
|
"Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s",
|
||||||
|
akgl_physics->gravity_y,
|
||||||
|
akgl_physics->drag_y,
|
||||||
|
(akgl_physics->gravity_y / akgl_physics->drag_y));
|
||||||
|
}
|
||||||
|
|
||||||
|
PASS(errctx, ss_collide_bind(akgl_gamemap));
|
||||||
|
|
||||||
|
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||||
|
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
|
||||||
|
PASS(errctx, ss_player_bind(player));
|
||||||
|
PASS(errctx, ss_actors_bind());
|
||||||
|
PASS(errctx, ss_player_controls(0, "player"));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Re-stamp the clock the simulation measures against. Everything above --
|
||||||
|
* nine sprite files, four characters, a map and its tileset image -- happened
|
||||||
|
* between the backend being created and the first step, and dt is measured
|
||||||
|
* from `gravity_time`. The bound on `physics.max_timestep` would catch it,
|
||||||
|
* at the cost of one visibly slow-motion frame.
|
||||||
|
*/
|
||||||
|
akgl_physics->gravity_time = SDL_GetTicksNS();
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Centre the camera on the player, clamped to the level.
|
||||||
|
*
|
||||||
|
* The camera is a plain `SDL_FRect` in map pixels that the library reads; moving
|
||||||
|
* it is the whole of scrolling. It is floored to a whole pixel because
|
||||||
|
* `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles
|
||||||
|
* to show, and a camera that is fractionally different every frame makes the
|
||||||
|
* tile grid shimmer.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *update_camera(void)
|
||||||
|
{
|
||||||
|
float32_t limit = 0.0f;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
|
||||||
|
FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera");
|
||||||
|
|
||||||
|
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
|
||||||
|
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
|
||||||
|
if ( akgl_camera->x > limit ) {
|
||||||
|
akgl_camera->x = limit;
|
||||||
|
}
|
||||||
|
if ( akgl_camera->x < 0.0f ) {
|
||||||
|
akgl_camera->x = 0.0f;
|
||||||
|
}
|
||||||
|
akgl_camera->x = (float32_t)((int)akgl_camera->x);
|
||||||
|
akgl_camera->y = 0.0f;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief One frame: events, then the camera, then the library's own tick.
|
||||||
|
*
|
||||||
|
* `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It
|
||||||
|
* does not clear or present, so the frame is bracketed by the backend's
|
||||||
|
* `frame_start` and `frame_end` here.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *frame(bool *running)
|
||||||
|
{
|
||||||
|
SDL_Event event;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||||
|
|
||||||
|
while ( SDL_PollEvent(&event) == true ) {
|
||||||
|
if ( event.type == SDL_EVENT_QUIT ) {
|
||||||
|
*running = false;
|
||||||
|
}
|
||||||
|
/* Every event, unconditionally: one that no control map binds is not an
|
||||||
|
* error, it is a call that did nothing. */
|
||||||
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||||
|
}
|
||||||
|
|
||||||
|
ss_game.frame += 1;
|
||||||
|
if ( ss_game.autoplay == true ) {
|
||||||
|
PASS(errctx, ss_player_autoplay(ss_game.frame));
|
||||||
|
}
|
||||||
|
PASS(errctx, update_camera());
|
||||||
|
|
||||||
|
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||||
|
/*
|
||||||
|
* Note the failure contract: akgl_game_update takes the game state lock and
|
||||||
|
* every one of its failure paths returns with the lock still held. SDL
|
||||||
|
* mutexes are recursive so a single-threaded loop does not deadlock on the
|
||||||
|
* next frame, but a frame that failed has left the world half-stepped.
|
||||||
|
* Treat it as terminal, which is what PASS does here.
|
||||||
|
*/
|
||||||
|
PASS(errctx, akgl_game_update(NULL));
|
||||||
|
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static akerr_ErrorContext *run(int frames)
|
||||||
|
{
|
||||||
|
bool running = true;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
while ( running == true ) {
|
||||||
|
PASS(errctx, frame(&running));
|
||||||
|
if ( (frames > 0) && (ss_game.frame >= frames) ) {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
/* A crude frame limiter. A game with a window on a real display should
|
||||||
|
* ask SDL for vsync instead; this one has to work under the dummy video
|
||||||
|
* driver, where there is nothing to sync to. */
|
||||||
|
SDL_Delay(16);
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Give back what the process is holding.
|
||||||
|
*
|
||||||
|
* **There is no `akgl_game_shutdown`.** Teardown is the application's, and the
|
||||||
|
* order matters in one place: `akgl_text_unloadallfonts` has to run before
|
||||||
|
* `TTF_Quit` or `SDL_Quit`, because those destroy the fonts underneath the
|
||||||
|
* registry that still points at them.
|
||||||
|
*
|
||||||
|
* The pools are not walked beyond the actors. They are static storage in a
|
||||||
|
* process that is exiting, and the sprites and characters are still referenced
|
||||||
|
* by each other; a game that loads a second level has to unwind that properly,
|
||||||
|
* and this one does not pretend to.
|
||||||
|
*/
|
||||||
|
static void shutdown_game(void)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
IGNORE(akgl_text_unloadallfonts());
|
||||||
|
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||||
|
if ( akgl_heap_actors[i].refcount > 0 ) {
|
||||||
|
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Guarded: this runs from CLEANUP, which is reached even when startup failed
|
||||||
|
* before akgl_game_init pointed akgl_gamemap at anything. */
|
||||||
|
if ( akgl_gamemap != NULL ) {
|
||||||
|
IGNORE(akgl_tilemap_release(akgl_gamemap));
|
||||||
|
}
|
||||||
|
TTF_Quit();
|
||||||
|
MIX_Quit();
|
||||||
|
SDL_Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames)
|
||||||
|
{
|
||||||
|
const char *env = NULL;
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
|
||||||
|
FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
|
||||||
|
|
||||||
|
env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES");
|
||||||
|
if ( env != NULL ) {
|
||||||
|
PASS(errctx, aksl_atoi((char *)env, frames));
|
||||||
|
}
|
||||||
|
for ( i = 1; i < argc; i++ ) {
|
||||||
|
if ( strcmp(argv[i], "--autoplay") == 0 ) {
|
||||||
|
ss_game.autoplay = true;
|
||||||
|
} else if ( strcmp(argv[i], "--frames") == 0 ) {
|
||||||
|
i += 1;
|
||||||
|
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
|
||||||
|
PASS(errctx, aksl_atoi(argv[i], frames));
|
||||||
|
} else if ( strcmp(argv[i], "--assets") == 0 ) {
|
||||||
|
i += 1;
|
||||||
|
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
|
||||||
|
*assetdir = argv[i];
|
||||||
|
} else {
|
||||||
|
FAIL_RETURN(
|
||||||
|
errctx,
|
||||||
|
AKERR_VALUE,
|
||||||
|
"usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
char *assetdir = SS_ASSET_DIR;
|
||||||
|
int frames = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
ATTEMPT {
|
||||||
|
CATCH(errctx, parse_args(argc, argv, &assetdir, &frames));
|
||||||
|
CATCH(errctx, startup());
|
||||||
|
CATCH(errctx, load_level(assetdir));
|
||||||
|
CATCH(errctx, run(frames));
|
||||||
|
} CLEANUP {
|
||||||
|
shutdown_game();
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} HANDLE_DEFAULT(errctx) {
|
||||||
|
LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
|
||||||
|
/*
|
||||||
|
* Set a flag rather than returning: leaving a HANDLE block early skips
|
||||||
|
* FINISH's RELEASE_ERROR and leaks the context's pool slot, and the
|
||||||
|
* 129th such call aborts the process. AGENTS.md spells it out.
|
||||||
|
*/
|
||||||
|
ss_failed = 1;
|
||||||
|
/*
|
||||||
|
* FINISH_NORETURN rather than FINISH: FINISH expands a
|
||||||
|
* `return __err_context` that an int-returning function cannot compile,
|
||||||
|
* even on a branch that cannot be reached.
|
||||||
|
*/
|
||||||
|
} FINISH_NORETURN(errctx);
|
||||||
|
|
||||||
|
SDL_Log(
|
||||||
|
"sidescroller: %d frames, %d of %d coins, %d deaths",
|
||||||
|
ss_game.frame,
|
||||||
|
ss_game.coins_taken,
|
||||||
|
SS_COIN_COUNT,
|
||||||
|
ss_game.deaths);
|
||||||
|
return ss_failed;
|
||||||
|
}
|
||||||
436
examples/sidescroller/player.c
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* @file player.c
|
||||||
|
* @brief The player's two hooks and its control bindings.
|
||||||
|
*
|
||||||
|
* Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the
|
||||||
|
* split between them is the frame's own order. `akgl_game_update` calls every
|
||||||
|
* actor's `updatefunc`, then steps the physics, then draws -- so per-frame game
|
||||||
|
* logic that is not movement (picking a coin up, falling in the pit) goes in
|
||||||
|
* `updatefunc`, and anything that has to happen inside the physics step goes in
|
||||||
|
* `movementlogicfunc`, which is the only hook the step calls.
|
||||||
|
*
|
||||||
|
* Two of the library's documented gaps are worked around here rather than
|
||||||
|
* papered over. Both are in `TODO.md` under "Arcade physics feel".
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
#include <akstdlib.h>
|
||||||
|
|
||||||
|
#include <akgl/controller.h>
|
||||||
|
#include <akgl/heap.h>
|
||||||
|
#include <akgl/physics.h>
|
||||||
|
#include <akgl/registry.h>
|
||||||
|
#include <akgl/util.h>
|
||||||
|
|
||||||
|
#include "sidescroller.h"
|
||||||
|
|
||||||
|
/** @brief The player's collision box, as an offset into its 32x32 sprite frame. */
|
||||||
|
static SDL_FRect ss_player_body = {
|
||||||
|
.x = SS_PLAYER_BOX_X,
|
||||||
|
.y = SS_PLAYER_BOX_Y,
|
||||||
|
.w = SS_PLAYER_BOX_W,
|
||||||
|
.h = SS_PLAYER_BOX_H
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief Where the map put the player, so a death can put it back. */
|
||||||
|
static ss_ActorData ss_player_data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The rectangle an actor is tested against, given a 32x32 sprite frame.
|
||||||
|
*
|
||||||
|
* Smaller than the frame on purpose. Sprite art does not reach the edges, and a
|
||||||
|
* hazard box the full size of the frame kills a player who is visibly nowhere
|
||||||
|
* near it.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||||
|
|
||||||
|
dest->x = obj->x + inset;
|
||||||
|
dest->y = obj->y + inset;
|
||||||
|
dest->w = 32.0f - (inset * 2.0f);
|
||||||
|
dest->h = 32.0f - (inset * 2.0f);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Put the player back where the map placed it, at rest.
|
||||||
|
*
|
||||||
|
* Everything the simulation carries between steps has to be cleared, not just
|
||||||
|
* the position: `ey` is where gravity has been accumulating, and a player who
|
||||||
|
* respawns still holding a full-speed fall lands dead again immediately.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *respawn(akgl_Actor *obj)
|
||||||
|
{
|
||||||
|
ss_ActorData *data = NULL;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||||
|
|
||||||
|
data = (ss_ActorData *)obj->actorData;
|
||||||
|
obj->x = data->home_x;
|
||||||
|
obj->y = data->home_y;
|
||||||
|
obj->ex = 0.0f;
|
||||||
|
obj->ey = 0.0f;
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
obj->ty = 0.0f;
|
||||||
|
obj->vx = 0.0f;
|
||||||
|
obj->vy = 0.0f;
|
||||||
|
ss_game.deaths += 1;
|
||||||
|
SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
|
||||||
|
*
|
||||||
|
* Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
|
||||||
|
* drag, the velocity recomputation and `move`.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||||
|
{
|
||||||
|
ss_Contact contact;
|
||||||
|
float32_t friction = 0.0f;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
|
||||||
|
/* The default logic still has to run: it is what copies the character's
|
||||||
|
* speeds onto the actor and signs its acceleration by the movement bits. */
|
||||||
|
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Workaround 1: there is no friction anywhere in the arcade backend.
|
||||||
|
*
|
||||||
|
* akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction
|
||||||
|
* stops the actor dead inside one frame -- correct for a top-down Zelda,
|
||||||
|
* wrong for a sidescroller. This game binds its own `_off` handlers that
|
||||||
|
* leave `tx` alone (see below) and decays it here instead, faster on the
|
||||||
|
* ground than in the air. Below a pixel per second it is snapped to zero,
|
||||||
|
* because an exponential decay never actually arrives.
|
||||||
|
*/
|
||||||
|
if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) &&
|
||||||
|
AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||||
|
friction = SS_FRICTION_AIR;
|
||||||
|
if ( ss_game.grounded == true ) {
|
||||||
|
friction = SS_FRICTION_GROUND;
|
||||||
|
}
|
||||||
|
obj->tx -= obj->tx * friction * dt;
|
||||||
|
if ( fabsf(obj->tx) < 1.0f ) {
|
||||||
|
obj->tx = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A jump is an impulse straight into the environmental term, because `ey` is
|
||||||
|
* the axis gravity accumulates on and the two have to cancel for the arc to
|
||||||
|
* come back down. Thrust would not: `ty` is capped against the character's
|
||||||
|
* `speed_y`, which is 0 for this character, so a jump written as thrust is
|
||||||
|
* scaled to nothing by the ellipse cap in akgl_physics_simulate.
|
||||||
|
*
|
||||||
|
* `ss_game.grounded` is last step's verdict, one frame stale. That is the
|
||||||
|
* ordering again -- this hook runs before the step it is deciding about --
|
||||||
|
* and one frame of coyote time is not a thing a player can feel.
|
||||||
|
*/
|
||||||
|
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
||||||
|
obj->ey = -SS_JUMP_SPEED;
|
||||||
|
}
|
||||||
|
ss_game.jump_requested = false;
|
||||||
|
|
||||||
|
PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact));
|
||||||
|
ss_game.grounded = contact.grounded;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The character maps MOVING_UP to the jump sprites, so the state word says
|
||||||
|
* "in the air" whether the actor is rising or falling. Nothing else reads
|
||||||
|
* the bit here: `speed_y` and `acceleration_y` are both 0 in
|
||||||
|
* character_ss_player.json, so the thrust it would authorise is capped to
|
||||||
|
* nothing.
|
||||||
|
*/
|
||||||
|
if ( contact.grounded == true ) {
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||||
|
} else {
|
||||||
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The player's `updatefunc`: the animation, then what it is touching.
|
||||||
|
*
|
||||||
|
* Runs in `akgl_game_update`'s actor sweep, before the physics step and before
|
||||||
|
* anything is drawn.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_player_update(akgl_Actor *obj)
|
||||||
|
{
|
||||||
|
SDL_FRect player;
|
||||||
|
SDL_FRect other;
|
||||||
|
bool hit = false;
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
|
||||||
|
/* Facing, then the animation frame. Replacing a hook does not mean
|
||||||
|
* reimplementing it. */
|
||||||
|
PASS(errctx, akgl_actor_update(obj));
|
||||||
|
|
||||||
|
/* Off the bottom of the world. Nothing in the library stops an actor
|
||||||
|
* leaving the map -- akgl_physics_arcade_move does not clamp -- so falling
|
||||||
|
* out of the level is a thing the game has to notice for itself. */
|
||||||
|
if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) {
|
||||||
|
PASS(errctx, respawn(obj));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
PASS(errctx, hitbox(obj, 8.0f, &player));
|
||||||
|
|
||||||
|
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
||||||
|
if ( ss_game.coins[i] == NULL ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
|
||||||
|
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
||||||
|
if ( hit == true ) {
|
||||||
|
/*
|
||||||
|
* Giving the pool slot back is what unregisters the actor and stops
|
||||||
|
* it being drawn; there is no "despawn" call. Releasing another
|
||||||
|
* actor from inside this sweep is safe -- akgl_game_update re-reads
|
||||||
|
* `refcount` at the top of every iteration and skips a slot that has
|
||||||
|
* gone free.
|
||||||
|
*/
|
||||||
|
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
|
||||||
|
ss_game.coins[i] = NULL;
|
||||||
|
ss_game.coins_taken += 1;
|
||||||
|
SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ( i = 0; i < SS_HAZARD_COUNT; i++ ) {
|
||||||
|
if ( ss_game.hazards[i] == NULL ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other));
|
||||||
|
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
||||||
|
if ( hit == true ) {
|
||||||
|
PASS(errctx, respawn(obj));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Workaround 2, the release half.
|
||||||
|
*
|
||||||
|
* akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax`
|
||||||
|
* *and* zero `tx`. That last one is the "stops dead" behaviour; these two do
|
||||||
|
* everything else it does and leave `tx` for ss_player_movement to decay.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
obj->ax = 0.0f;
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static akerr_ErrorContext *ss_control_right_off(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
obj->ax = 0.0f;
|
||||||
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */
|
||||||
|
static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
ss_game.jump_requested = true;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Cut a rising jump short when the button is let go.
|
||||||
|
*
|
||||||
|
* Variable jump height for four lines, and it works because `ey` is an ordinary
|
||||||
|
* field that nothing else owns between steps.
|
||||||
|
*/
|
||||||
|
static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||||
|
if ( obj->ey < 0.0f ) {
|
||||||
|
obj->ey *= 0.4f;
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_player_bind(akgl_Actor *obj)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
|
||||||
|
ss_game.player = obj;
|
||||||
|
|
||||||
|
/* Lift the spawn point clear of the step it overlaps *before* recording it,
|
||||||
|
* so a respawn does not put the player back inside the geometry. */
|
||||||
|
PASS(errctx, ss_collide_settle(obj, &ss_player_body));
|
||||||
|
ss_player_data.home_x = obj->x;
|
||||||
|
ss_player_data.home_y = obj->y;
|
||||||
|
obj->actorData = (void *)&ss_player_data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The default `facefunc` clears every facing bit and then sets one from the
|
||||||
|
* movement bits -- so an actor that stops moving is left facing nowhere, its
|
||||||
|
* state drops to bare ALIVE, and character_ss_player.json has no sprite for
|
||||||
|
* that. The actor becomes invisible while standing still.
|
||||||
|
*
|
||||||
|
* Clearing this is the documented way out: akgl_actor_automatic_face leaves
|
||||||
|
* an actor alone entirely when it is clear, and the facing bits stay
|
||||||
|
* wherever the control handlers last put them.
|
||||||
|
*/
|
||||||
|
obj->movement_controls_face = false;
|
||||||
|
|
||||||
|
/* Replace the hooks after akgl_actor_initialize, never before -- it
|
||||||
|
* overwrites all six. The tilemap loader has already run it here. */
|
||||||
|
obj->movementlogicfunc = &ss_player_movement;
|
||||||
|
obj->updatefunc = &ss_player_update;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
|
||||||
|
{
|
||||||
|
akgl_ControlMap *controlmap = NULL;
|
||||||
|
akgl_Control control;
|
||||||
|
SDL_KeyboardID *keyboards = NULL;
|
||||||
|
SDL_JoystickID *gamepads = NULL;
|
||||||
|
int count = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname");
|
||||||
|
/* akgl_controller_pushmap checks the upper bound and not the lower one, so
|
||||||
|
* a negative id indexes before the start of akgl_controlmaps. TODO.md,
|
||||||
|
* "Known and still open" item 11. */
|
||||||
|
FAIL_NONZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)),
|
||||||
|
AKERR_OUTOFBOUNDS,
|
||||||
|
"Control map id %d is outside 0..%d",
|
||||||
|
controlmapid,
|
||||||
|
(AKGL_MAX_CONTROL_MAPS - 1)
|
||||||
|
);
|
||||||
|
PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
|
||||||
|
|
||||||
|
controlmap = &akgl_controlmaps[controlmapid];
|
||||||
|
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
controlmap->target,
|
||||||
|
AKGL_ERR_REGISTRY,
|
||||||
|
"Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded",
|
||||||
|
actorname
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A control map listens to exactly one keyboard and one gamepad, matched by
|
||||||
|
* id, and a binding whose id does not match the event's is not consulted.
|
||||||
|
* That is what keeps two local players on two keyboards apart -- and it is
|
||||||
|
* also why "the arrow keys do nothing" is usually the wrong id rather than
|
||||||
|
* the wrong key.
|
||||||
|
*/
|
||||||
|
keyboards = SDL_GetKeyboards(&count);
|
||||||
|
if ( keyboards != NULL ) {
|
||||||
|
if ( count > 0 ) {
|
||||||
|
controlmap->kbid = keyboards[0];
|
||||||
|
}
|
||||||
|
SDL_free(keyboards);
|
||||||
|
}
|
||||||
|
gamepads = SDL_GetGamepads(&count);
|
||||||
|
if ( gamepads != NULL ) {
|
||||||
|
if ( count > 0 ) {
|
||||||
|
controlmap->jsid = gamepads[0];
|
||||||
|
}
|
||||||
|
SDL_free(gamepads);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- keyboard ---- */
|
||||||
|
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||||
|
control.event_off = SDL_EVENT_KEY_UP;
|
||||||
|
|
||||||
|
control.key = SDLK_LEFT;
|
||||||
|
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||||
|
control.handler_off = &ss_control_left_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||||
|
|
||||||
|
control.key = SDLK_RIGHT;
|
||||||
|
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||||
|
control.handler_off = &ss_control_right_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||||
|
|
||||||
|
control.key = SDLK_SPACE;
|
||||||
|
control.handler_on = &ss_control_jump_on;
|
||||||
|
control.handler_off = &ss_control_jump_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||||
|
|
||||||
|
/* ---- gamepad ---- */
|
||||||
|
/* Clear the keycode first: a keyboard event is matched on `key` whatever
|
||||||
|
* else the binding carries, and 0 is a keycode like any other. */
|
||||||
|
control.key = 0;
|
||||||
|
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||||
|
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||||
|
|
||||||
|
control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
|
||||||
|
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||||
|
control.handler_off = &ss_control_left_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||||
|
|
||||||
|
control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT;
|
||||||
|
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||||
|
control.handler_off = &ss_control_right_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||||
|
|
||||||
|
control.button = SDL_GAMEPAD_BUTTON_SOUTH;
|
||||||
|
control.handler_on = &ss_control_jump_on;
|
||||||
|
control.handler_off = &ss_control_jump_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||||
|
|
||||||
|
SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid);
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *ss_player_autoplay(int frame)
|
||||||
|
{
|
||||||
|
SDL_Event synthetic;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
|
||||||
|
PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event)));
|
||||||
|
synthetic.type = SDL_EVENT_KEY_DOWN;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The headless smoke run has no keyboard, so it calls the handlers a
|
||||||
|
* keyboard would have called. They take the actor and an event, and the
|
||||||
|
* keyboard handlers do not read the event beyond requiring one -- which is
|
||||||
|
* what makes a scripted run possible at all.
|
||||||
|
*/
|
||||||
|
if ( frame == 1 ) {
|
||||||
|
PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
|
||||||
|
}
|
||||||
|
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
|
||||||
|
ss_game.jump_requested = true;
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
135
examples/sidescroller/sidescroller.h
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @file sidescroller.h
|
||||||
|
* @brief Shared declarations for the sidescroller tutorial game.
|
||||||
|
*
|
||||||
|
* The game is four translation units and this is the seam between them:
|
||||||
|
*
|
||||||
|
* main.c startup, asset loading, the frame loop, teardown
|
||||||
|
* collision.c the tile queries and the swept resolution libakgl does not have
|
||||||
|
* player.c the player's hooks and its control bindings
|
||||||
|
* actors.c the coins, the patrolling blob, the flying moth
|
||||||
|
*
|
||||||
|
* Nothing here is prefixed `akgl_`. That prefix belongs to the library; a game
|
||||||
|
* built on it takes its own, and this one is `ss_`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SIDESCROLLER_H_
|
||||||
|
#define _SIDESCROLLER_H_
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
|
||||||
|
#include <akerror.h>
|
||||||
|
|
||||||
|
#include <akgl/actor.h>
|
||||||
|
#include <akgl/error.h>
|
||||||
|
#include <akgl/game.h>
|
||||||
|
#include <akgl/tilemap.h>
|
||||||
|
#include <akgl/types.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The level's geometry, in the units the map is authored in. level1.tmj is 40x15
|
||||||
|
* tiles of 16 pixels, so the world is 640x240 pixels and the view is a 480x240
|
||||||
|
* window onto it that scrolls horizontally.
|
||||||
|
*/
|
||||||
|
#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */
|
||||||
|
#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */
|
||||||
|
#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
|
||||||
|
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Which layer of the map is solid.
|
||||||
|
*
|
||||||
|
* akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the
|
||||||
|
* layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`,
|
||||||
|
* `y` and `type`, and nothing else. So a game cannot ask for "the layer called
|
||||||
|
* terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2.
|
||||||
|
*/
|
||||||
|
#define SS_TERRAIN_LAYER_ID 2
|
||||||
|
|
||||||
|
/* The player's collision box, as an offset into its 32x32 sprite frame. The art
|
||||||
|
* does not fill the frame edge to edge, and a box the full width of the frame
|
||||||
|
* catches on doorways the character visibly clears. */
|
||||||
|
#define SS_PLAYER_BOX_X 8.0f
|
||||||
|
#define SS_PLAYER_BOX_Y 0.0f
|
||||||
|
#define SS_PLAYER_BOX_W 16.0f
|
||||||
|
#define SS_PLAYER_BOX_H 32.0f
|
||||||
|
|
||||||
|
/* Upward velocity a jump installs directly into the actor's environmental term,
|
||||||
|
* in pixels per second. Under the map's 900 px/s^2 gravity this peaks a little
|
||||||
|
* under 100 px -- six tiles -- which is what the platform heights are cut to. */
|
||||||
|
#define SS_JUMP_SPEED 420.0f
|
||||||
|
|
||||||
|
/* How fast thrust bleeds away when no direction is held, as a fraction per
|
||||||
|
* second. The library has no friction at all: see ss_player_friction. */
|
||||||
|
#define SS_FRICTION_GROUND 12.0f
|
||||||
|
#define SS_FRICTION_AIR 1.5f
|
||||||
|
|
||||||
|
/* How many of each kind of thing level1.tmj places. */
|
||||||
|
#define SS_COIN_COUNT 4
|
||||||
|
#define SS_HAZARD_COUNT 2
|
||||||
|
|
||||||
|
/* How far the autoplay script waits between jumps, in frames. */
|
||||||
|
#define SS_AUTOPLAY_JUMP_PERIOD 45
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief What one call to ss_collide_resolve found.
|
||||||
|
*
|
||||||
|
* `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this
|
||||||
|
* step; `grounded` says there is solid terrain one pixel under where the actor
|
||||||
|
* will be when the step finishes, which is the test a jump is gated on.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
bool blocked_x;
|
||||||
|
bool blocked_y;
|
||||||
|
bool grounded;
|
||||||
|
} ss_Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Per-actor game data, hung off akgl_Actor::actorData.
|
||||||
|
*
|
||||||
|
* The library never reads or frees `actorData`, so it is the place a game puts
|
||||||
|
* what the library has no field for. These live in a fixed table in actors.c
|
||||||
|
* rather than being allocated, which is the same discipline the library's own
|
||||||
|
* pools follow.
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
|
||||||
|
float32_t home_y;
|
||||||
|
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
|
||||||
|
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
|
||||||
|
} ss_ActorData;
|
||||||
|
|
||||||
|
/** @brief The whole game's state. One player, one level, no menus. */
|
||||||
|
typedef struct {
|
||||||
|
akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */
|
||||||
|
akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */
|
||||||
|
akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
|
||||||
|
int coins_taken;
|
||||||
|
int deaths;
|
||||||
|
bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
|
||||||
|
bool grounded; /**< Last step's verdict; what gates the next jump. */
|
||||||
|
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
|
||||||
|
int frame; /**< Frames drawn so far. */
|
||||||
|
} ss_Game;
|
||||||
|
|
||||||
|
extern ss_Game ss_game;
|
||||||
|
|
||||||
|
/* collision.c -- everything akgl_physics_arcade_collide would have done. */
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body);
|
||||||
|
|
||||||
|
/* player.c */
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
|
||||||
|
|
||||||
|
/* actors.c */
|
||||||
|
akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
|
||||||
|
|
||||||
|
#endif // _SIDESCROLLER_H_
|
||||||
348
scripts/fetch_tutorial_assets.sh
Executable file
@@ -0,0 +1,348 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Refresh the tutorial art in docs/tutorials/assets/ from Kenney.nl.
|
||||||
|
#
|
||||||
|
# Those PNGs are tracked on purpose. They are what the two tutorial games in
|
||||||
|
# examples/ draw, what docs/tutorials/PROVENANCE.md accounts for, and what a
|
||||||
|
# reader copies into their own project -- so the tree has to build and the
|
||||||
|
# tutorials have to run with no network access at all. This script therefore
|
||||||
|
# has the same rule mkcontrollermappings.sh was fixed into for 0.5.0: **it must
|
||||||
|
# never replace a good tracked asset with a worse one**. Everything is fetched
|
||||||
|
# and repacked in a temporary directory, every step is checked, and the staged
|
||||||
|
# files are moved into place only once all of them have come out right. Any
|
||||||
|
# failure leaves the tracked copies exactly as they were and exits non-zero.
|
||||||
|
#
|
||||||
|
# Nothing in the build runs this. It is a deliberate, standalone refresh, and
|
||||||
|
# its output should land in its own commit so the diff is reviewable.
|
||||||
|
#
|
||||||
|
# Two of the eight images are verbatim copies of a Kenney tileset. The other six
|
||||||
|
# are repacked: 16x16 source art composited bottom-centre into 32x32 cells, one
|
||||||
|
# row, left to right, which is the layout akgl_spritesheet_coords_for_frame
|
||||||
|
# indexes. The repack lives here rather than in a second script so that the
|
||||||
|
# tracked bytes are reproducible from one command.
|
||||||
|
#
|
||||||
|
# The JSON (sprites, characters) and the TMJ maps are hand-authored source, not
|
||||||
|
# generated. This script does not touch them.
|
||||||
|
#
|
||||||
|
# Usage: scripts/fetch_tutorial_assets.sh [repository-root]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AKGL_KENNEY_ASSET_URL="${AKGL_KENNEY_ASSET_URL:-https://kenney.nl/assets}"
|
||||||
|
# A floor, not a target. Each pack page carries its own licence line; a page
|
||||||
|
# that does not say CC0 is either the wrong page or a relicensed pack, and
|
||||||
|
# either way its bytes must not land in this repository.
|
||||||
|
AKGL_KENNEY_LICENSE_TEXT="${AKGL_KENNEY_LICENSE_TEXT:-Creative Commons CC0}"
|
||||||
|
|
||||||
|
function die()
|
||||||
|
{
|
||||||
|
echo "fetch_tutorial_assets: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function note()
|
||||||
|
{
|
||||||
|
echo "fetch_tutorial_assets: $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
rootdir="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
assetdir="${rootdir}/docs/tutorials/assets"
|
||||||
|
|
||||||
|
if [[ ! -d "${assetdir}/sidescroller" ]] || [[ ! -d "${assetdir}/jrpg" ]]; then
|
||||||
|
die "no such directory: ${assetdir}/{sidescroller,jrpg}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || die "curl is not available"
|
||||||
|
command -v unzip >/dev/null 2>&1 || die "unzip is not available"
|
||||||
|
command -v convert >/dev/null 2>&1 || die "ImageMagick 'convert' is not available"
|
||||||
|
command -v identify >/dev/null 2>&1 || die "ImageMagick 'identify' is not available"
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
|
||||||
|
function cleanup()
|
||||||
|
{
|
||||||
|
rm -rf "${workdir}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
staging="${workdir}/staging"
|
||||||
|
mkdir -p "${staging}/sidescroller" "${staging}/jrpg"
|
||||||
|
|
||||||
|
##
|
||||||
|
## Fetching
|
||||||
|
##
|
||||||
|
|
||||||
|
# Download one pack, check its page says CC0, and unpack it under ${workdir}/<slug>.
|
||||||
|
function fetch_pack()
|
||||||
|
{
|
||||||
|
local slug="$1"
|
||||||
|
local page="${workdir}/${slug}.html"
|
||||||
|
local zip="${workdir}/${slug}.zip"
|
||||||
|
local dest="${workdir}/${slug}"
|
||||||
|
local url=""
|
||||||
|
|
||||||
|
# --fail so an HTTP error status is an exit status rather than an error page
|
||||||
|
# in the body. Not a pipeline: the status being checked is curl's, and a
|
||||||
|
# pipeline reports the last command's.
|
||||||
|
if ! curl --fail --silent --show-error --location "${AKGL_KENNEY_ASSET_URL}/${slug}" --output "${page}"; then
|
||||||
|
die "could not fetch the pack page for ${slug}; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "${AKGL_KENNEY_LICENSE_TEXT}" "${page}"; then
|
||||||
|
die "the ${slug} page does not say '${AKGL_KENNEY_LICENSE_TEXT}'; refusing to vendor it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
url="$(grep -o "https://[^'\"]*${slug}\.zip" "${page}" | head -1)"
|
||||||
|
if [[ -z "${url}" ]]; then
|
||||||
|
die "no download link found on the ${slug} page; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! curl --fail --silent --show-error --location "${url}" --output "${zip}"; then
|
||||||
|
die "download failed from ${url}; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! unzip -tq "${zip}" >/dev/null 2>&1; then
|
||||||
|
die "${slug}.zip did not survive an integrity check; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${dest}"
|
||||||
|
if ! unzip -qo "${zip}" -d "${dest}"; then
|
||||||
|
die "could not unpack ${slug}.zip; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "Creative Commons Zero" "${dest}/License.txt"; then
|
||||||
|
die "${slug} does not carry a CC0 License.txt; refusing to vendor it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
note "fetched ${slug} from ${url}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refuse to publish an audio file that is not actually Ogg. `file(1)` is not
|
||||||
|
# guaranteed to be installed; the container magic is four bytes at offset zero.
|
||||||
|
function require_ogg()
|
||||||
|
{
|
||||||
|
local file="$1"
|
||||||
|
local minbytes="$2"
|
||||||
|
|
||||||
|
[[ -f "${file}" ]] || die "expected file is missing: ${file}"
|
||||||
|
if [[ "$(head -c 4 "${file}")" != "OggS" ]]; then
|
||||||
|
die "${file} is not an Ogg stream; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
if [[ "$(wc -c < "${file}")" -lt "${minbytes}" ]]; then
|
||||||
|
die "${file} is under ${minbytes} bytes; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refuse to work from an image that is not the size the frame arithmetic assumes.
|
||||||
|
function require_size()
|
||||||
|
{
|
||||||
|
local file="$1"
|
||||||
|
local want="$2"
|
||||||
|
local got=""
|
||||||
|
|
||||||
|
[[ -f "${file}" ]] || die "expected file is missing: ${file}"
|
||||||
|
got="$(identify -format '%wx%h' "${file}")"
|
||||||
|
if [[ "${got}" != "${want}" ]]; then
|
||||||
|
die "${file} is ${got}, expected ${want}; the pack layout has changed and the frame offsets no longer hold"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
##
|
||||||
|
## Repacking
|
||||||
|
##
|
||||||
|
## A sheet is one row of 32x32 cells. Each cell holds one 16x16 source tile
|
||||||
|
## composited at (8, 16) -- horizontally centred, sitting on the cell's bottom
|
||||||
|
## edge -- so an actor drawn into a 32x32 destination rectangle has its feet on
|
||||||
|
## the bottom of that rectangle whichever sheet it came from.
|
||||||
|
##
|
||||||
|
|
||||||
|
AKGL_SHEET_CELL=32
|
||||||
|
AKGL_SOURCE_TILE=16
|
||||||
|
AKGL_CELL_OFFSET_X=8
|
||||||
|
AKGL_CELL_OFFSET_Y=16
|
||||||
|
|
||||||
|
# build_sheet <source-png> <output-png> <spec>...
|
||||||
|
#
|
||||||
|
# Each spec is "X,Y" or "X,Y,flop": the source pixel offset of a 16x16 tile, and
|
||||||
|
# whether to mirror it horizontally. Specs are laid into cells left to right, so
|
||||||
|
# the position of a spec in the argument list *is* its frame id.
|
||||||
|
function build_sheet()
|
||||||
|
{
|
||||||
|
local src="$1"
|
||||||
|
local out="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local -a args=()
|
||||||
|
local count=$#
|
||||||
|
local idx=0
|
||||||
|
local spec=""
|
||||||
|
local sx=""
|
||||||
|
local sy=""
|
||||||
|
local flop=""
|
||||||
|
|
||||||
|
args+=( -size "$((count * AKGL_SHEET_CELL))x${AKGL_SHEET_CELL}" xc:none )
|
||||||
|
for spec in "$@"; do
|
||||||
|
IFS=',' read -r sx sy flop <<< "${spec}"
|
||||||
|
args+=( '(' "${src}" -crop "${AKGL_SOURCE_TILE}x${AKGL_SOURCE_TILE}+${sx}+${sy}" +repage )
|
||||||
|
if [[ "${flop:-}" == "flop" ]]; then
|
||||||
|
args+=( -flop )
|
||||||
|
fi
|
||||||
|
args+=( ')' -geometry "+$(( (idx * AKGL_SHEET_CELL) + AKGL_CELL_OFFSET_X ))+${AKGL_CELL_OFFSET_Y}" -composite )
|
||||||
|
idx=$((idx + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# -strip, and the two date properties by name, because ImageMagick writes
|
||||||
|
# date:create/date:modify tEXt chunks from the wall clock. Without this a
|
||||||
|
# refresh that changed nothing at all still produced eight different files,
|
||||||
|
# and the diff would say the art had changed when it had not.
|
||||||
|
args+=( -strip +set date:create +set date:modify +set date:timestamp )
|
||||||
|
if ! convert "${args[@]}" "${out}"; then
|
||||||
|
die "could not repack ${out}; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# rpg_urban_character <character-index> <output-png>
|
||||||
|
#
|
||||||
|
# The RPG Urban Pack lays each character out as a 4-column by 3-row block in the
|
||||||
|
# last four columns of the tileset: columns are left, down, up, right; rows are
|
||||||
|
# stand, step A, step B. This regroups one block into the twelve-frame order the
|
||||||
|
# tutorial sprite JSON expects -- down, left, right, up, three frames each.
|
||||||
|
function rpg_urban_character()
|
||||||
|
{
|
||||||
|
local charidx="$1"
|
||||||
|
local out="$2"
|
||||||
|
local src="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
|
||||||
|
local left=368
|
||||||
|
local down=384
|
||||||
|
local up=400
|
||||||
|
local right=416
|
||||||
|
local y0=$(( charidx * 3 * 16 ))
|
||||||
|
local y1=$(( y0 + 16 ))
|
||||||
|
local y2=$(( y0 + 32 ))
|
||||||
|
|
||||||
|
build_sheet "${src}" "${out}" \
|
||||||
|
"${down},${y0}" "${down},${y1}" "${down},${y2}" \
|
||||||
|
"${left},${y0}" "${left},${y1}" "${left},${y2}" \
|
||||||
|
"${right},${y0}" "${right},${y1}" "${right},${y2}" \
|
||||||
|
"${up},${y0}" "${up},${y1}" "${up},${y2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
##
|
||||||
|
## Do the work
|
||||||
|
##
|
||||||
|
|
||||||
|
fetch_pack "pixel-line-platformer"
|
||||||
|
fetch_pack "rpg-urban-pack"
|
||||||
|
fetch_pack "music-jingles"
|
||||||
|
|
||||||
|
plp="${workdir}/pixel-line-platformer/Tilemap/tilemap_packed.png"
|
||||||
|
rup="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
|
||||||
|
|
||||||
|
require_size "${plp}" "160x96"
|
||||||
|
require_size "${rup}" "432x288"
|
||||||
|
|
||||||
|
# Tilesets are copied verbatim. Both are already 16x16 with zero spacing and
|
||||||
|
# zero margin, which is the only geometry akgl_tilemap_compute_tileset_offsets
|
||||||
|
# gets right: it adds `spacing` to the tile pitch but sets the first row's y
|
||||||
|
# offset to `spacing` rather than zero, and it ignores `margin` entirely.
|
||||||
|
cp "${plp}" "${staging}/sidescroller/tiles.png"
|
||||||
|
cp "${rup}" "${staging}/jrpg/tiles.png"
|
||||||
|
|
||||||
|
# Pixel Line Platformer tile ids, ten columns: id -> ((id % 10) * 16, (id / 10) * 16)
|
||||||
|
# 40, 41, 42 the armed rabbit: contact, passing (also the jump pose), contact
|
||||||
|
# 44 the gold pickup
|
||||||
|
# 51, 52 the moth, wings up and down
|
||||||
|
# 55, 56 the red blob, two frames
|
||||||
|
#
|
||||||
|
# akgl_actor_render always draws with SDL_FLIP_NONE, so a left-facing run has to
|
||||||
|
# exist in the sheet as its own frames rather than being mirrored at draw time.
|
||||||
|
build_sheet "${plp}" "${staging}/sidescroller/player.png" \
|
||||||
|
"0,64" "16,64" "32,64" \
|
||||||
|
"0,64,flop" "16,64,flop" "32,64,flop"
|
||||||
|
build_sheet "${plp}" "${staging}/sidescroller/coin.png" \
|
||||||
|
"64,64"
|
||||||
|
build_sheet "${plp}" "${staging}/sidescroller/hazard.png" \
|
||||||
|
"80,80" "96,80" "16,80" "32,80"
|
||||||
|
|
||||||
|
rpg_urban_character 0 "${staging}/jrpg/player.png"
|
||||||
|
rpg_urban_character 3 "${staging}/jrpg/npc_shopkeeper.png"
|
||||||
|
rpg_urban_character 2 "${staging}/jrpg/npc_elder.png"
|
||||||
|
|
||||||
|
# One jingle per game, copied unchanged. akgl_load_start_bgm() is the library's
|
||||||
|
# only file-audio entry point -- akgl_audio_* is a synthesiser, and there is no
|
||||||
|
# sound-effect loader at all -- so a jingle is what a tutorial can actually
|
||||||
|
# play. Both are stings of under two seconds, and the loop request in
|
||||||
|
# akgl_load_start_bgm does not take effect, so each plays once.
|
||||||
|
cp "${workdir}/music-jingles/Audio/8-Bit jingles/jingles_NES00.ogg" \
|
||||||
|
"${staging}/sidescroller/jingle_start.ogg"
|
||||||
|
cp "${workdir}/music-jingles/Audio/Pizzicato jingles/jingles_PIZZI07.ogg" \
|
||||||
|
"${staging}/jrpg/jingle_start.ogg"
|
||||||
|
|
||||||
|
cp "${workdir}/pixel-line-platformer/License.txt" "${staging}/LICENSE.kenney_pixel-line-platformer.txt"
|
||||||
|
cp "${workdir}/rpg-urban-pack/License.txt" "${staging}/LICENSE.kenney_rpg-urban-pack.txt"
|
||||||
|
cp "${workdir}/music-jingles/License.txt" "${staging}/LICENSE.kenney_music-jingles.txt"
|
||||||
|
|
||||||
|
##
|
||||||
|
## Check the staging area before anything is allowed near the tracked copies
|
||||||
|
##
|
||||||
|
|
||||||
|
# name expected size
|
||||||
|
AKGL_TUTORIAL_ASSETS=(
|
||||||
|
"sidescroller/tiles.png 160x96"
|
||||||
|
"sidescroller/player.png 192x32"
|
||||||
|
"sidescroller/coin.png 32x32"
|
||||||
|
"sidescroller/hazard.png 128x32"
|
||||||
|
"jrpg/tiles.png 432x288"
|
||||||
|
"jrpg/player.png 384x32"
|
||||||
|
"jrpg/npc_shopkeeper.png 384x32"
|
||||||
|
"jrpg/npc_elder.png 384x32"
|
||||||
|
)
|
||||||
|
|
||||||
|
staged=0
|
||||||
|
for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
|
||||||
|
read -r name want <<< "${entry}"
|
||||||
|
require_size "${staging}/${name}" "${want}"
|
||||||
|
if [[ ! -s "${staging}/${name}" ]]; then
|
||||||
|
die "staged ${name} is empty; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
staged=$((staged + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${staged}" -ne "${#AKGL_TUTORIAL_ASSETS[@]}" ]]; then
|
||||||
|
die "staged ${staged} images, expected ${#AKGL_TUTORIAL_ASSETS[@]}; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
|
||||||
|
AKGL_TUTORIAL_AUDIO=(
|
||||||
|
"sidescroller/jingle_start.ogg"
|
||||||
|
"jrpg/jingle_start.ogg"
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in "${AKGL_TUTORIAL_AUDIO[@]}"; do
|
||||||
|
require_ogg "${staging}/${name}" 4096
|
||||||
|
done
|
||||||
|
|
||||||
|
AKGL_TUTORIAL_LICENSES=(
|
||||||
|
"LICENSE.kenney_pixel-line-platformer.txt"
|
||||||
|
"LICENSE.kenney_rpg-urban-pack.txt"
|
||||||
|
"LICENSE.kenney_music-jingles.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in "${AKGL_TUTORIAL_LICENSES[@]}"; do
|
||||||
|
if [[ ! -s "${staging}/${name}" ]]; then
|
||||||
|
die "staged ${name} is empty; tracked assets left as they were"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
##
|
||||||
|
## Publish
|
||||||
|
##
|
||||||
|
|
||||||
|
for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
|
||||||
|
read -r name want <<< "${entry}"
|
||||||
|
mv "${staging}/${name}" "${assetdir}/${name}"
|
||||||
|
done
|
||||||
|
for name in "${AKGL_TUTORIAL_AUDIO[@]}" "${AKGL_TUTORIAL_LICENSES[@]}"; do
|
||||||
|
mv "${staging}/${name}" "${assetdir}/${name}"
|
||||||
|
done
|
||||||
|
|
||||||
|
note "wrote ${staged} images, ${#AKGL_TUTORIAL_AUDIO[@]} jingles and ${#AKGL_TUTORIAL_LICENSES[@]} upstream licence files to ${assetdir}"
|
||||||