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 |