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>
2026-08-01 20:59:00 -04:00
|
|
|
/**
|
|
|
|
|
* @file sidescroller.h
|
|
|
|
|
* @brief Shared declarations for the sidescroller tutorial game.
|
|
|
|
|
*
|
|
|
|
|
* The game is four translation units and this is the seam between them:
|
|
|
|
|
*
|
|
|
|
|
* main.c startup, asset loading, the frame loop, teardown
|
|
|
|
|
* collision.c the tile queries and the swept resolution libakgl does not have
|
|
|
|
|
* player.c the player's hooks and its control bindings
|
|
|
|
|
* actors.c the coins, the patrolling blob, the flying moth
|
|
|
|
|
*
|
|
|
|
|
* Nothing here is prefixed `akgl_`. That prefix belongs to the library; a game
|
|
|
|
|
* built on it takes its own, and this one is `ss_`.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
#ifndef _SIDESCROLLER_H_
|
|
|
|
|
#define _SIDESCROLLER_H_
|
|
|
|
|
|
|
|
|
|
#include <stdbool.h>
|
|
|
|
|
|
|
|
|
|
#include <SDL3/SDL.h>
|
|
|
|
|
|
|
|
|
|
#include <akerror.h>
|
|
|
|
|
|
|
|
|
|
#include <akgl/actor.h>
|
|
|
|
|
#include <akgl/error.h>
|
|
|
|
|
#include <akgl/game.h>
|
|
|
|
|
#include <akgl/tilemap.h>
|
|
|
|
|
#include <akgl/types.h>
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* The level's geometry, in the units the map is authored in. level1.tmj is 40x15
|
|
|
|
|
* tiles of 16 pixels, so the world is 640x240 pixels and the view is a 480x240
|
|
|
|
|
* window onto it that scrolls horizontally.
|
|
|
|
|
*/
|
|
|
|
|
#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */
|
|
|
|
|
#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */
|
|
|
|
|
#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
|
|
|
|
|
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Which layer of the map is solid.
|
|
|
|
|
*
|
|
|
|
|
* akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the
|
|
|
|
|
* layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`,
|
|
|
|
|
* `y` and `type`, and nothing else. So a game cannot ask for "the layer called
|
|
|
|
|
* terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2.
|
|
|
|
|
*/
|
|
|
|
|
#define SS_TERRAIN_LAYER_ID 2
|
|
|
|
|
|
|
|
|
|
/* The player's collision box, as an offset into its 32x32 sprite frame. The art
|
|
|
|
|
* does not fill the frame edge to edge, and a box the full width of the frame
|
|
|
|
|
* catches on doorways the character visibly clears. */
|
|
|
|
|
#define SS_PLAYER_BOX_X 8.0f
|
|
|
|
|
#define SS_PLAYER_BOX_Y 0.0f
|
|
|
|
|
#define SS_PLAYER_BOX_W 16.0f
|
|
|
|
|
#define SS_PLAYER_BOX_H 32.0f
|
|
|
|
|
|
|
|
|
|
/* Upward velocity a jump installs directly into the actor's environmental term,
|
|
|
|
|
* in pixels per second. Under the map's 900 px/s^2 gravity this peaks a little
|
|
|
|
|
* under 100 px -- six tiles -- which is what the platform heights are cut to. */
|
|
|
|
|
#define SS_JUMP_SPEED 420.0f
|
|
|
|
|
|
|
|
|
|
/* How fast thrust bleeds away when no direction is held, as a fraction per
|
|
|
|
|
* second. The library has no friction at all: see ss_player_friction. */
|
|
|
|
|
#define SS_FRICTION_GROUND 12.0f
|
|
|
|
|
#define SS_FRICTION_AIR 1.5f
|
|
|
|
|
|
|
|
|
|
/* How many of each kind of thing level1.tmj places. */
|
|
|
|
|
#define SS_COIN_COUNT 4
|
|
|
|
|
#define SS_HAZARD_COUNT 2
|
|
|
|
|
|
|
|
|
|
/* How far the autoplay script waits between jumps, in frames. */
|
|
|
|
|
#define SS_AUTOPLAY_JUMP_PERIOD 45
|
Let a control map listen to any keyboard, and fix the dead arrow keys
The sidescroller's controls did nothing. `akgl_controller_handle_event` matched
`event->key.which == curmap->kbid` exactly, with no way to say "whatever
keyboard the player is typing on", so the game did the obvious thing and bound
`SDL_GetKeyboards()[0]`.
That cannot work. The id a key event *carries* is chosen by the video backend
and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without
XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while
`SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`,
which is 1; with XInput2 the events carry the physical slave device's
`sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map
matched nothing and every key press was dropped.
A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend:
SDL documents `which` as 0 when the source is unknown or virtual, and joystick
ids start at 1, so no real device is 0. A non-zero id still matches only that
device, which is what keeps two local players on two keyboards apart, and there
is a test for that half too. `util/charviewer.c` already passed 0 and depended
on the old behaviour by accident; it works under XInput2 now as well.
Two reasons this shipped, both closed:
- Every test in tests/controller.c dispatched an event whose id equalled the id
the map was bound with, so none of them could see it.
- The sidescroller's smoke test called the control handlers directly instead of
dispatching events, so it passed against a control map that matched nothing.
It now pushes synthetic events through akgl_controller_handle_event from a
deliberately non-zero device id and fails if the press does not arrive.
Verified by breaking the binding and watching the run exit non-zero.
`test_controller_wildcard_device_ids` was written first and failed against the
unfixed library with "a map bound to keyboard 0 ignored a key press from device
11", which is the whole reason to trust it now that it passes.
The controls were documented, in one sentence. Chapter 19 has a proper table of
them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The
header said the match was exact and now says what it does.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:23:02 -04:00
|
|
|
/* How long it holds the jump key, in frames. A tap jumps lower than a hold. */
|
|
|
|
|
#define SS_AUTOPLAY_JUMP_HOLD 8
|
|
|
|
|
/*
|
|
|
|
|
* The device id the autoplay script stamps on its synthetic key events.
|
|
|
|
|
* Deliberately not 0: the control map binds keyboard 0 meaning "any keyboard",
|
|
|
|
|
* and driving it from a non-zero id is what proves that binding works.
|
|
|
|
|
*/
|
|
|
|
|
#define SS_AUTOPLAY_KBID 11
|
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>
2026-08-01 20:59:00 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief What one call to ss_collide_resolve found.
|
|
|
|
|
*
|
|
|
|
|
* `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this
|
|
|
|
|
* step; `grounded` says there is solid terrain one pixel under where the actor
|
|
|
|
|
* will be when the step finishes, which is the test a jump is gated on.
|
|
|
|
|
*/
|
|
|
|
|
typedef struct {
|
|
|
|
|
bool blocked_x;
|
|
|
|
|
bool blocked_y;
|
|
|
|
|
bool grounded;
|
|
|
|
|
} ss_Contact;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Per-actor game data, hung off akgl_Actor::actorData.
|
|
|
|
|
*
|
|
|
|
|
* The library never reads or frees `actorData`, so it is the place a game puts
|
|
|
|
|
* what the library has no field for. These live in a fixed table in actors.c
|
|
|
|
|
* rather than being allocated, which is the same discipline the library's own
|
|
|
|
|
* pools follow.
|
|
|
|
|
*/
|
|
|
|
|
typedef struct {
|
|
|
|
|
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
|
|
|
|
|
float32_t home_y;
|
|
|
|
|
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
|
|
|
|
|
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
|
|
|
|
|
} ss_ActorData;
|
|
|
|
|
|
|
|
|
|
/** @brief The whole game's state. One player, one level, no menus. */
|
|
|
|
|
typedef struct {
|
|
|
|
|
akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */
|
|
|
|
|
akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */
|
|
|
|
|
akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
|
|
|
|
|
int coins_taken;
|
|
|
|
|
int deaths;
|
|
|
|
|
bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
|
|
|
|
|
bool grounded; /**< Last step's verdict; what gates the next jump. */
|
|
|
|
|
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
|
|
|
|
|
int frame; /**< Frames drawn so far. */
|
|
|
|
|
} ss_Game;
|
|
|
|
|
|
|
|
|
|
extern ss_Game ss_game;
|
|
|
|
|
|
|
|
|
|
/* collision.c -- everything akgl_physics_arcade_collide would have done. */
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body);
|
|
|
|
|
|
|
|
|
|
/* player.c */
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
|
|
|
|
|
|
|
|
|
|
/* actors.c */
|
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
|
|
|
|
|
|
|
|
|
|
#endif // _SIDESCROLLER_H_
|