Fill the gaps two readers found in the tutorials
Two agents were given nothing but the chapter text and asked to build the game from it. Between them they named every place the chapters showed a fragment and called it an explanation. This closes those. Chapter 20 now shows what it previously only named: the whole ss_Game struct rather than a truncated one, SS_COIN_COUNT and SS_HAZARD_COUNT, ss_ActorData, the player and blob body rectangles, asset_path, hitbox, find_actor, respawn, both jump handlers, the control-map function head, the gamepad buttons, the blob's probe arithmetic, and a complete standalone CMakeLists rather than three lines out of one. It also names the library globals a reader is expected to use without declaring, lists all seven actor hooks rather than the two this game replaces, and says in order what each of the two hook functions ends up doing. Chapter 21 gains the same treatment: the header's includes and declarations, the textbox colours and their SDL_Color type, TTF_Font, the jrpg_Townsfolk row type, character_load in full, the player lookup, the frame loop, and per-file include tables for both games. Two claims were wrong and are corrected. "Each step is complete on its own" was not true of a cumulative tutorial. And the first argument to akgl_controller_handle_event is not a game-state word the dispatcher reads -- the header says nothing reads it and it is required only as a non-NULL token. Every step that produces code now ends with what the reader should see when they run it. Excerpts across docs/ go from 137 to 190. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
@@ -49,6 +49,18 @@ library — you never call `malloc`.
|
||||
**A tilemap is the level.** It is a [Tiled](https://mapeditor.org) `.tmj` file. libakgl draws
|
||||
its tile layers, and creates an actor for every object in its object layers.
|
||||
|
||||
**Four things are library globals you use rather than create.** You do not declare these;
|
||||
including the right header is enough:
|
||||
|
||||
| Global | Is |
|
||||
|---|---|
|
||||
| `akgl_game` | The game's own name, version, frame rate and hooks |
|
||||
| `akgl_renderer` | The render backend. `akgl_render_2d_init` fills it in |
|
||||
| `akgl_physics` | The physics backend. `akgl_physics_init_arcade` fills it in |
|
||||
| `akgl_camera` | An `SDL_FRect` in map pixels. Moving it is scrolling |
|
||||
| `akgl_gamemap` | The level. It already points at storage the library owns |
|
||||
| `akgl_heap_actors` | The actor pool, as an array of `AKGL_MAX_HEAP_ACTOR` slots |
|
||||
|
||||
**One call per frame does the work.** `akgl_game_update()` updates every actor, steps the
|
||||
physics, and draws the world. Your frame loop reads input, then calls it.
|
||||
|
||||
@@ -71,7 +83,11 @@ physics, and draws the world. Your frame loop reads input, then calls it.
|
||||
12. **Add moving enemies** — a blob that patrols and a moth that flies.
|
||||
13. **Tear down** — the order that matters.
|
||||
|
||||
Each step below is complete on its own. Build and run after each one.
|
||||
The steps are cumulative — each one adds to the same files rather than replacing them. Steps
|
||||
3, 4 and 5 produce data files rather than code; step 1 produces a header. From step 2 onward
|
||||
the program builds and runs at the end of every step, and the text says what you should see.
|
||||
Where a step refers forward to a function a later step writes, it says so and tells you what
|
||||
to leave out until then.
|
||||
|
||||
---
|
||||
|
||||
@@ -88,10 +104,18 @@ sidescroller/
|
||||
actors.c everything else the map places
|
||||
```
|
||||
|
||||
Splitting into three `.c` files is not required — it is what keeps each one readable. The
|
||||
build file:
|
||||
Splitting into three `.c` files is not required — it is what keeps each one readable.
|
||||
|
||||
A complete `CMakeLists.txt` for a game built beside libakgl:
|
||||
|
||||
```cmake
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(sidescroller VERSION 1.0.0 LANGUAGES C)
|
||||
|
||||
# libakgl and its dependencies. add_subdirectory if you have the source beside
|
||||
# you; find_package if you have it installed. Chapter 3 covers both.
|
||||
add_subdirectory(../libakgl libakgl)
|
||||
|
||||
add_executable(sidescroller
|
||||
main.c
|
||||
player.c
|
||||
@@ -103,17 +127,26 @@ target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(sidescroller
|
||||
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf
|
||||
SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
|
||||
target_compile_definitions(sidescroller
|
||||
PRIVATE "SS_ASSET_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/assets\"")
|
||||
```
|
||||
|
||||
You link all four SDL libraries even though this game has no text and no sound, because
|
||||
libakgl itself is built against them.
|
||||
The parts that matter:
|
||||
|
||||
Your assets — the PNGs, the JSON and the map — go in a directory of their own. Tell the
|
||||
program where at compile time, so it can be run from any working directory:
|
||||
Each `.c` file includes `sidescroller.h` plus whatever it calls directly. `main.c` adds
|
||||
`akgl/character.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/registry.h`, `akgl/renderer.h`,
|
||||
`akgl/sprite.h` and `akgl/text.h`; `player.c` adds `akgl/controller.h`, `akgl/heap.h`,
|
||||
`akgl/registry.h`, `akgl/util.h` and `<math.h>`; `actors.c` adds `akgl/heap.h`,
|
||||
`akgl/registry.h` and `<math.h>`. libakgl's headers are self-contained, so including the one
|
||||
that declares what you are calling is always enough.
|
||||
|
||||
```cmake
|
||||
target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"")
|
||||
```
|
||||
The build file:
|
||||
|
||||
- You link all four SDL libraries even though this game has no text and no sound, because
|
||||
libakgl itself is built against them.
|
||||
- `SS_ASSET_DIR` is baked in at compile time, so the program can be run from any working
|
||||
directory. The code falls back to `"."` if it is not defined.
|
||||
|
||||
### The header
|
||||
|
||||
@@ -127,7 +160,40 @@ with the level's geometry:
|
||||
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
|
||||
```
|
||||
|
||||
The game state is one struct with one instance, declared `extern` here and defined in
|
||||
How many of each thing the level places, and how the player moves:
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
#define SS_COIN_COUNT 4
|
||||
#define SS_HAZARD_COUNT 2
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
#define SS_JUMP_SPEED 420.0f
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
#define SS_PLAYER_BOX_X 8.0f
|
||||
#define SS_PLAYER_BOX_Y 0.0f
|
||||
#define SS_PLAYER_BOX_W 16.0f
|
||||
#define SS_PLAYER_BOX_H 32.0f
|
||||
```
|
||||
|
||||
The player's collision box is an offset into its 32×32 sprite frame — step 7 explains the
|
||||
inset.
|
||||
|
||||
Per-actor data the library has no field for. `akgl_Actor::actorData` is a `void *` the
|
||||
library never reads or frees, and this is what this game hangs off it:
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
typedef struct {
|
||||
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
|
||||
float32_t home_y;
|
||||
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
|
||||
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
|
||||
} ss_ActorData;
|
||||
```
|
||||
|
||||
And the game state — one struct, one instance, declared `extern` here and defined in
|
||||
`main.c`:
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
@@ -137,10 +203,75 @@ typedef struct {
|
||||
akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
|
||||
int coins_taken;
|
||||
int deaths;
|
||||
bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
|
||||
bool grounded; /**< Last step's verdict; what gates the next jump. */
|
||||
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
|
||||
int frame; /**< Frames drawn so far. */
|
||||
float32_t final_x; /**< Where the player finished, stamped before teardown for the summary line. */
|
||||
float32_t final_y;
|
||||
} ss_Game;
|
||||
```
|
||||
|
||||
Fixed arrays, not allocations. The level places a known number of things.
|
||||
|
||||
### What the header has to include, and declare
|
||||
|
||||
Everything the three `.c` files share goes here. The includes first:
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
#include <stdbool.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
```
|
||||
|
||||
Then the two globals `main.c` defines, and the functions the other files call:
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
extern ss_Game ss_game;
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
extern akgl_CollisionWorld ss_collision;
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest);
|
||||
|
||||
/* player.c */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
|
||||
|
||||
/* actors.c */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
|
||||
```
|
||||
|
||||
Those five are what the steps below fill in:
|
||||
|
||||
| Function | Defined in | Called from | Step |
|
||||
|---|---|---|---|
|
||||
| `ss_grounded` | `main.c` | `player.c`, `actors.c` | 9 |
|
||||
| `ss_player_bind` | `player.c` | `main.c`, after the map loads | 7, 8 |
|
||||
| `ss_player_controls` | `player.c` | `main.c`, after the map loads | 8 |
|
||||
| `ss_actors_bind` | `actors.c` | `main.c`, after the map loads | 12 |
|
||||
| `ss_player_autoplay` | `player.c` | the frame loop, only with `--autoplay` | — |
|
||||
|
||||
`AKERR_NOIGNORE` on a declaration is what makes the compiler warn if a caller
|
||||
throws the return value away. Put it on every function of your own that returns
|
||||
an error context.
|
||||
|
||||
Wrap the whole file in the usual include guard.
|
||||
|
||||
---
|
||||
|
||||
## 2. Open a window and run a frame loop
|
||||
@@ -198,8 +329,24 @@ Here is the first part:
|
||||
sizeof(akgl_game.name),
|
||||
"libakgl sidescroller tutorial",
|
||||
sizeof(akgl_game.name) - 1));
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&akgl_game.version,
|
||||
sizeof(akgl_game.version),
|
||||
"1.0.0",
|
||||
sizeof(akgl_game.version) - 1));
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&akgl_game.uri,
|
||||
sizeof(akgl_game.uri),
|
||||
"net.aklabs.libakgl.sidescroller",
|
||||
sizeof(akgl_game.uri) - 1));
|
||||
|
||||
PASS(errctx, akgl_game_init());
|
||||
```
|
||||
|
||||
`version` is your game's, in any form you like. `uri` is a reverse-DNS application identifier
|
||||
— SDL uses it as the application id, and libakgl stamps it into savegames so a file from
|
||||
another game is refused.
|
||||
|
||||
`aksl_strncpy` rather than `strncpy`: it reports a truncation as an error instead of quietly
|
||||
producing a shortened string. The same goes for `aksl_snprintf` and `aksl_atoi` later on.
|
||||
libakstdlib is documented in `deps/libakstdlib`.
|
||||
@@ -258,6 +405,13 @@ Three things happen per frame: drain the event queue, position the camera, and c
|
||||
call with the backend's own `frame_start` and `frame_end`:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
static akerr_ErrorContext *frame(bool *running)
|
||||
{
|
||||
SDL_Event event;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||
|
||||
while ( SDL_PollEvent(&event) == true ) {
|
||||
if ( event.type == SDL_EVENT_QUIT ) {
|
||||
*running = false;
|
||||
@@ -271,20 +425,66 @@ call with the backend's own `frame_start` and `frame_end`:
|
||||
Hand *every* event to `akgl_controller_handle_event`. An event nothing is bound to is not an
|
||||
error; it is a call that does nothing.
|
||||
|
||||
The first argument is the app-state pointer SDL's callback API passes around. **Nothing in
|
||||
libakgl reads it**; it is required only as a non-`NULL` token, so any non-`NULL` pointer will
|
||||
do. `&akgl_game.state` is a convenient one — it is a struct of application-defined flags the
|
||||
library also never reads, and it is already there.
|
||||
|
||||
Then the drawing half of the same function:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
```
|
||||
|
||||
`akgl_game_update(NULL)` goes between `frame_start` and `frame_end`. The `NULL` means "no
|
||||
iterator options" — update every actor on every layer.
|
||||
|
||||
The outer loop calls that once per frame:
|
||||
iterator options" — update every actor on every layer:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
PASS(errctx, akgl_game_update(NULL));
|
||||
```
|
||||
|
||||
and then present what it drew, which ends the function:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The outer loop calls that once per frame, and sleeps so it does not spin:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
static akerr_ErrorContext *run(int frames)
|
||||
{
|
||||
bool running = true;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
while ( running == true ) {
|
||||
PASS(errctx, frame(&running));
|
||||
if ( (frames > 0) && (ss_game.frame >= frames) ) {
|
||||
running = false;
|
||||
}
|
||||
/* A crude frame limiter. A game with a window on a real display should
|
||||
* ask SDL for vsync instead; this one has to work under the dummy video
|
||||
* driver, where there is nothing to sync to. */
|
||||
SDL_Delay(16);
|
||||
}
|
||||
```
|
||||
|
||||
The frame counter is incremented inside `frame()`, between the events and the drawing:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
ss_game.frame += 1;
|
||||
if ( ss_game.autoplay == true ) {
|
||||
PASS(errctx, ss_player_autoplay(ss_game.frame));
|
||||
}
|
||||
PASS(errctx, update_camera());
|
||||
```
|
||||
|
||||
`update_camera` is step 10 and `ss_player_autoplay` is the scripted-input hook described at
|
||||
the end of the chapter. Leave both out for now — a `frame()` that polls events, calls
|
||||
`frame_start`, `akgl_game_update(NULL)` and `frame_end` is a complete program.
|
||||
|
||||
Build and run now. You get a window of the renderer's clear colour and nothing else. That is
|
||||
correct — there is nothing in the world yet.
|
||||
|
||||
@@ -563,8 +763,25 @@ characters, and characters name sprites.
|
||||
}
|
||||
```
|
||||
|
||||
`asset_path` is a three-line helper that joins the directory and the file name with
|
||||
`aksl_snprintf`.
|
||||
`asset_path` is the helper that joins the directory and the file name:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
|
||||
{
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`aksl_snprintf` rather than `snprintf`, so a path that does not fit arrives as
|
||||
`AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it reports itself much later as
|
||||
a missing file with a name nobody wrote.
|
||||
|
||||
Then the map:
|
||||
|
||||
@@ -611,11 +828,19 @@ The map published them by name. Look each one up:
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
|
||||
PASS(errctx, ss_player_bind(player));
|
||||
PASS(errctx, ss_actors_bind());
|
||||
PASS(errctx, ss_player_controls(0, "player"));
|
||||
```
|
||||
|
||||
A miss means the map and the code disagree about what the level contains, which is worth
|
||||
failing on.
|
||||
|
||||
Those three calls are where steps 7 to 12 attach: `ss_player_bind` gives the player its shape
|
||||
and its hooks, `ss_actors_bind` does the same for the coins and the hazards, and
|
||||
`ss_player_controls` binds the keys. All three run **after** the map has loaded, because the
|
||||
map is what creates the actors they touch.
|
||||
|
||||
Run now. The level draws, the actors appear, and everything falls through the floor.
|
||||
|
||||
---
|
||||
@@ -650,11 +875,28 @@ character visibly clears:
|
||||
#define SS_PLAYER_BOX_H 32.0f
|
||||
```
|
||||
|
||||
`ss_player_body` is a file-scope rectangle in `player.c`, built from the constants in your
|
||||
header:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
static SDL_FRect ss_player_body = {
|
||||
.x = SS_PLAYER_BOX_X,
|
||||
.y = SS_PLAYER_BOX_Y,
|
||||
.w = SS_PLAYER_BOX_W,
|
||||
.h = SS_PLAYER_BOX_H
|
||||
};
|
||||
```
|
||||
|
||||
Turn it into a shape on the actor:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f));
|
||||
obj->shape_override = true;
|
||||
```
|
||||
|
||||
`akgl_collision_shape_box(dest, body, depth)` takes the shape to fill in, the frame-relative
|
||||
rectangle, and a z depth.
|
||||
|
||||
The `0.0f` is the depth along z; passing 0 lets libakgl choose one.
|
||||
[Chapter 15](15-collision.md) explains why a 2D shape has a depth at all.
|
||||
`shape_override = true` says this actor's shape is its own rather than its character's.
|
||||
@@ -681,13 +923,26 @@ Run now. The player and the blob stand on the ground. Nothing moves yet.
|
||||
|
||||
### The two hooks
|
||||
|
||||
An actor carries seven function pointers. Two matter here, and the difference between them is
|
||||
*when in the frame they run*:
|
||||
An actor carries seven function pointers. `akgl_actor_initialize` installs a working default
|
||||
on every one of them, and a game replaces the ones it wants to change **on the actor it wants
|
||||
to change them on**:
|
||||
|
||||
| Hook | Called by | Put here |
|
||||
| Hook | Called by | Default does |
|
||||
|---|---|---|
|
||||
| `updatefunc` | the actor sweep, before physics | per-frame game logic: picking things up, dying |
|
||||
| `movementlogicfunc` | the physics step, before gravity and the move | anything that has to affect this step's motion |
|
||||
| `updatefunc` | the actor sweep, before physics | choose the facing, then advance the animation |
|
||||
| `movementlogicfunc` | the physics step, before gravity and the move | turn the movement bits into signed acceleration |
|
||||
| `renderfunc` | the draw pass | draw the sprite the state selects |
|
||||
| `facefunc` | `updatefunc` | choose the facing bits from the movement bits |
|
||||
| `changeframefunc` | `updatefunc`, when a frame is due | step to the next animation frame |
|
||||
| `collidefunc` | collision, after the move | push the actor out and stop it going further in |
|
||||
| `addchild` | you | attach a child actor |
|
||||
|
||||
Two matter here, and the difference between them is *when in the frame they run*:
|
||||
|
||||
| Hook | Put here |
|
||||
|---|---|
|
||||
| `updatefunc` | per-frame game logic: picking things up, dying |
|
||||
| `movementlogicfunc` | anything that has to affect this step's motion |
|
||||
|
||||
Install them **after** the actor exists. `akgl_actor_initialize` — which the map loader
|
||||
already ran — overwrites all seven:
|
||||
@@ -708,8 +963,28 @@ movement bits into signed acceleration.
|
||||
|
||||
### Binding keys
|
||||
|
||||
A control map ties an event to a handler and a target actor. Fill in an `akgl_Control` and
|
||||
push it:
|
||||
Control maps live in a library array, `akgl_controlmaps`, indexed by a small integer you
|
||||
choose. A one-player game uses 0. Point one at your actor:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
|
||||
{
|
||||
akgl_ControlMap *controlmap = NULL;
|
||||
akgl_Control control;
|
||||
PREPARE_ERROR(errctx);
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
controlmap = &akgl_controlmaps[controlmapid];
|
||||
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
|
||||
```
|
||||
|
||||
Then fill in an `akgl_Control` and push it. Zero the struct first — a binding is copied into
|
||||
the map, so a stack local is fine, but an uninitialized field matches against garbage:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
@@ -742,8 +1017,16 @@ is matched on `key` whatever else the binding carries, and 0 is a keycode like a
|
||||
control.key = 0;
|
||||
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &ss_control_left_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
```
|
||||
|
||||
`SDL_GAMEPAD_BUTTON_DPAD_RIGHT` and `SDL_GAMEPAD_BUTTON_SOUTH` — the A button — get the same
|
||||
treatment for right and jump.
|
||||
|
||||
Bind controls **after** the map is loaded. The map is what creates the actor the control map
|
||||
targets.
|
||||
|
||||
@@ -814,15 +1097,31 @@ of 0 and scaled to nothing.
|
||||
The key handler only *asks*; whether a jump is allowed is the movement function's decision:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
ss_game.jump_requested = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Releasing the button early cuts the jump short. Four lines gives you variable jump height:
|
||||
Every control handler has that signature: an actor, an SDL event, an error context back.
|
||||
|
||||
Releasing the button early cuts the jump short. That is variable jump height, for four lines:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
if ( obj->ey < 0.0f ) {
|
||||
obj->ey *= 0.4f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
### Picking the jump sprite
|
||||
@@ -857,6 +1156,8 @@ static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event
|
||||
}
|
||||
```
|
||||
|
||||
`ss_control_right_off` is the same function with `AKGL_ACTOR_STATE_MOVING_RIGHT`.
|
||||
|
||||
Then decay the thrust yourself, faster on the ground than in the air:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
@@ -878,6 +1179,20 @@ in one frame — right for a top-down game, wrong for a sidescroller. Friction a
|
||||
in the backend are tracked in `TODO.md` under "Arcade physics feel"; when they land, this
|
||||
whole section becomes a setting.
|
||||
|
||||
### The whole movement function, in order
|
||||
|
||||
`ss_player_movement` now does five things, and the order is the order they were introduced:
|
||||
|
||||
1. `akgl_actor_logic_movement(obj, dt)` — the default logic, first.
|
||||
2. `ss_grounded(...)` — record whether there is a floor underfoot.
|
||||
3. Decay `tx` if no direction is held.
|
||||
4. If a jump was requested and the actor is grounded, write `ey`. Clear the request either
|
||||
way.
|
||||
5. Set or clear `AKGL_ACTOR_STATE_MOVING_UP` from `grounded`.
|
||||
|
||||
Steps 2 and 3 have to come before 4: the jump reads the same `grounded` the friction does,
|
||||
and reading it twice in one step would cost a query for nothing.
|
||||
|
||||
Run now. The player runs, slides to a stop, jumps, and holds the jump sprite through the arc.
|
||||
|
||||
---
|
||||
@@ -905,6 +1220,8 @@ that is fractionally different every frame makes the tile grid shimmer.
|
||||
|
||||
Call it once per frame, before `akgl_game_update`.
|
||||
|
||||
Run now. The level scrolls as the player walks, and stops scrolling at both ends.
|
||||
|
||||
---
|
||||
|
||||
## 11. Collect coins and die on hazards
|
||||
@@ -926,7 +1243,26 @@ the right tool for a pickup — you want to know, not to be pushed:
|
||||
```
|
||||
|
||||
`hitbox` insets a rectangle into the 32×32 frame, for the same reason the collision shape was
|
||||
inset: a hazard box the full size of the frame kills a player who is visibly nowhere near it.
|
||||
inset — a hazard box the full size of the frame kills a player who is visibly nowhere near
|
||||
it:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
dest->x = obj->x + inset;
|
||||
dest->y = obj->y + inset;
|
||||
dest->w = 32.0f - (inset * 2.0f);
|
||||
dest->h = 32.0f - (inset * 2.0f);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The coin test uses an inset of 8, the hazard test 6 — a coin should be easy to take and a
|
||||
spike should be hard to hit.
|
||||
|
||||
### Removing an actor
|
||||
|
||||
@@ -956,9 +1292,33 @@ Nothing stops an actor leaving the map, so check for it:
|
||||
|
||||
### Respawning
|
||||
|
||||
Clear everything the simulation carries between steps, not just the position:
|
||||
The player needs a data slot of its own, filled in when you set the player up:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
static ss_ActorData ss_player_data;
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
ss_player_data.home_x = obj->x;
|
||||
ss_player_data.home_y = obj->y;
|
||||
obj->actorData = (void *)&ss_player_data;
|
||||
```
|
||||
|
||||
Record it **after** settling the spawn point, so a respawn does not put the player back inside
|
||||
the geometry it was lifted out of.
|
||||
|
||||
Respawning clears everything the simulation carries between steps, not just the position:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
static akerr_ErrorContext *respawn(akgl_Actor *obj)
|
||||
{
|
||||
ss_ActorData *data = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||
|
||||
data = (ss_ActorData *)obj->actorData;
|
||||
obj->x = data->home_x;
|
||||
obj->y = data->home_y;
|
||||
obj->ex = 0.0f;
|
||||
@@ -967,6 +1327,7 @@ Clear everything the simulation carries between steps, not just the position:
|
||||
obj->ty = 0.0f;
|
||||
obj->vx = 0.0f;
|
||||
obj->vy = 0.0f;
|
||||
ss_game.deaths += 1;
|
||||
```
|
||||
|
||||
`ey` is where the fall accumulated. A player who respawns still holding a full-speed fall
|
||||
@@ -981,14 +1342,20 @@ table:
|
||||
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/sidescroller.h
|
||||
typedef struct {
|
||||
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
|
||||
float32_t home_y;
|
||||
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
|
||||
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
|
||||
} ss_ActorData;
|
||||
```
|
||||
The player's slot is `ss_player_data` in `player.c`; the hazards share a table in `actors.c`.
|
||||
|
||||
### The whole update function, in order
|
||||
|
||||
`ss_player_update` does four things:
|
||||
|
||||
1. `akgl_actor_update(obj)` — the default, which chooses the facing and advances the
|
||||
animation.
|
||||
2. If the actor is below the bottom of the map, respawn and return.
|
||||
3. Test the player's hitbox against each remaining coin; take any it overlaps.
|
||||
4. Test it against each hazard; respawn on any it overlaps.
|
||||
|
||||
Run now. Walking into a coin takes it, walking into the blob or falling into the pit
|
||||
respawns you at the start.
|
||||
|
||||
---
|
||||
|
||||
@@ -1033,7 +1400,14 @@ Set the facing and movement bits, then let the default logic sign the accelerati
|
||||
}
|
||||
```
|
||||
|
||||
Then probe for a wall ahead and a floor ahead:
|
||||
Then probe. `step` is one pixel in the direction of travel:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
step = 1.0f;
|
||||
if ( data->facing < 0.0f ) {
|
||||
step = -1.0f;
|
||||
}
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead));
|
||||
@@ -1041,10 +1415,21 @@ Then probe for a wall ahead and a floor ahead:
|
||||
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded));
|
||||
```
|
||||
|
||||
The floor probe is one pixel past the leading edge of the box and one pixel below its feet:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
|
||||
if ( data->facing < 0.0f ) {
|
||||
probe_x = obj->x + ss_blob_body.x - 1.0f;
|
||||
} else {
|
||||
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
|
||||
}
|
||||
PASS(errctx, akgl_collision_solid_at(&ss_collision, probe_x, probe_y, &floor_ahead));
|
||||
```
|
||||
|
||||
One pixel, not one tile. The probe is asking "is there ground under my next step", and a
|
||||
longer reach turns the blob round a tile before the ledge.
|
||||
|
||||
`akgl_collision_solid_at` is the cheapest query there is: is the tile under this one point
|
||||
solid.
|
||||
|
||||
@@ -1074,15 +1459,68 @@ Two sines at a 1:2 ratio is a figure eight.
|
||||
|
||||
### Wiring them up
|
||||
|
||||
Find each actor by its Tiled name and install its hook:
|
||||
`actors.c` needs its own lookup helper. A miss means the map and the code disagree about what
|
||||
the level contains, which is worth failing on rather than working around:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Then find each actor by its Tiled name and install its hook:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
|
||||
PASS(errctx, akgl_collision_shape_box(&ss_game.hazards[0]->shape, &ss_blob_body, 0.0f));
|
||||
ss_game.hazards[0]->shape_override = true;
|
||||
PASS(errctx, akgl_collision_settle(&ss_collision, &ss_game.hazards[0]->shape,
|
||||
&ss_game.hazards[0]->x, &ss_game.hazards[0]->y, 0));
|
||||
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
|
||||
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
|
||||
ss_hazard_data[0].facing = -1.0f;
|
||||
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
|
||||
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
|
||||
```
|
||||
|
||||
The moth needs no shape — it never touches terrain.
|
||||
The coins are found the same way, by the names Tiled gave them — `coin1` through `coin4`:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
||||
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
|
||||
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
|
||||
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
|
||||
}
|
||||
```
|
||||
|
||||
The moth needs no shape — it never touches terrain — but it does need its home position, since
|
||||
that is what it orbits:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
|
||||
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
|
||||
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
|
||||
ss_hazard_data[1].facing = 1.0f;
|
||||
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
|
||||
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
|
||||
```
|
||||
|
||||
All of that lives in one function, `ss_actors_bind`, which `main.c` calls after the map is
|
||||
loaded.
|
||||
|
||||
Run now. The coins hang in the air instead of falling, the blob patrols its platform and
|
||||
turns at the edges, and the moth traces a figure eight. That is the whole game.
|
||||
|
||||
---
|
||||
|
||||
@@ -1127,8 +1565,13 @@ exiting can leave the textures to `SDL_Quit`.
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
|
||||
```
|
||||
|
||||
- `parse_args` is ordinary `strcmp` over `argv`, filling in `assetdir` and `frames`. It
|
||||
exists for the headless run at the end of this chapter; a game that only ever opens a
|
||||
window can drop it and pass `SS_ASSET_DIR` and `0` straight in.
|
||||
- `ATTEMPT` … `CLEANUP` … `PROCESS` … `HANDLE_DEFAULT` … `FINISH_NORETURN` is the
|
||||
error-handling block. `CATCH` inside it is what `PASS` is outside it.
|
||||
- `LOG_ERROR_WITH_MESSAGE` is libakerror's, like every other macro here. It prints the
|
||||
status, your message and the stack trace the context accumulated on its way up.
|
||||
- `CLEANUP` runs on every path, success or failure. Teardown goes there.
|
||||
- **Never use a `*_RETURN` macro inside `ATTEMPT`.** It returns past `CLEANUP`, so every
|
||||
release and `fclose` is skipped.
|
||||
@@ -1160,6 +1603,22 @@ SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
|
||||
./build/examples/sidescroller/sidescroller --frames 240 --autoplay
|
||||
```
|
||||
|
||||
Those two flags are scaffolding for that run and are not part of the game. `--frames N` stops
|
||||
after N frames, which is the `if` in `run()` above. `--autoplay` synthesizes key events on a
|
||||
fixed schedule so the level plays itself:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
|
||||
synthetic.type = SDL_EVENT_KEY_DOWN;
|
||||
synthetic.key.which = SS_AUTOPLAY_KBID;
|
||||
synthetic.key.key = SDLK_SPACE;
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
|
||||
```
|
||||
|
||||
Feed synthetic events through `akgl_controller_handle_event` rather than calling the handlers
|
||||
directly. A scripted run then exercises the binding table the same way a player does, so a
|
||||
control map that matches nothing fails the smoke test instead of passing it.
|
||||
|
||||
It prints where the player finished:
|
||||
|
||||
```text
|
||||
|
||||
Reference in New Issue
Block a user