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>
This commit is contained in:
188
examples/jrpg/jrpg.h
Normal file
188
examples/jrpg/jrpg.h
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file jrpg.h
|
||||
* @brief Shared declarations for the JRPG tutorial game.
|
||||
*
|
||||
* Every function with external linkage in this program is declared here, the
|
||||
* way AGENTS.md requires of the library itself. It is a three-translation-unit
|
||||
* program and it would compile without the header; declaring them anyway is
|
||||
* what keeps a signature from drifting between the definition and the call.
|
||||
*
|
||||
* Everything this program exports carries the `jrpg_` prefix. `static` helpers
|
||||
* drop it, because the prefix exists only to avoid collisions with libakgl,
|
||||
* SDL and libc -- the same rule, for the same reason.
|
||||
*/
|
||||
|
||||
#ifndef _JRPG_JRPG_H_
|
||||
#define _JRPG_JRPG_H_
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
/*
|
||||
* Where the game's data lives. Both are absolute paths baked in by
|
||||
* examples/jrpg/CMakeLists.txt, because the tutorial has to run from the build
|
||||
* tree, from the source tree, and from wherever CTest happens to put its
|
||||
* working directory. The fallbacks are what a reader compiling this by hand
|
||||
* from the repository root would want.
|
||||
*/
|
||||
#ifndef JRPG_ASSET_DIR
|
||||
#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg"
|
||||
#endif
|
||||
#ifndef JRPG_FONT_FILE
|
||||
#define JRPG_FONT_FILE "tests/assets/akgl_test_mono.ttf"
|
||||
#endif
|
||||
|
||||
/*
|
||||
* The screen size is written as text because that is the only form
|
||||
* akgl_set_property takes, and akgl_render_2d_init copies it onto `akgl_camera`
|
||||
* on the way past. Everything below reads the camera rather than a second copy
|
||||
* of the same two numbers.
|
||||
*/
|
||||
#define JRPG_SCREEN_WIDTH "320"
|
||||
#define JRPG_SCREEN_HEIGHT "240"
|
||||
|
||||
/** @brief Registry name of the one font this game loads. */
|
||||
#define JRPG_FONT_NAME "jrpg"
|
||||
/** @brief Point size the font is rasterized at. A size is baked into the handle. */
|
||||
#define JRPG_FONT_SIZE 12
|
||||
|
||||
/**
|
||||
* @brief Index of the tile layer this game treats as solid.
|
||||
*
|
||||
* An index, not a name, because akgl_TilemapLayer has no `name` member: the
|
||||
* loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data,
|
||||
* and drops the name Tiled wrote. A map cannot say "the layer called
|
||||
* collision", so the game and the map agree on a number. See chapter 20.
|
||||
*/
|
||||
#define JRPG_LAYER_SOLID 1
|
||||
|
||||
/** @brief Longest line an NPC can say, terminator included. */
|
||||
#define JRPG_TEXTBOX_MAX_TEXT 256
|
||||
|
||||
/** @brief Registry name of the party member this game attaches to the player. */
|
||||
#define JRPG_FOLLOWER_NAME "companion"
|
||||
|
||||
/**
|
||||
* @brief One entry in the scripted demo the headless smoke run drives.
|
||||
*
|
||||
* The keystrokes go in through akgl_controller_handle_event like any other
|
||||
* event, so the smoke run exercises the same binding, state and physics path a
|
||||
* player does rather than a separate one written to be testable.
|
||||
*/
|
||||
typedef struct {
|
||||
long frame; /**< Frame number this step fires on. */
|
||||
SDL_Keycode key; /**< Key to synthesize. */
|
||||
bool down; /**< True for a press, false for a release. */
|
||||
} jrpg_ScriptStep;
|
||||
|
||||
/**
|
||||
* @brief Load every sprite, then every character, then the town map.
|
||||
*
|
||||
* In that order, and the order is not a preference: akgl_character_load_json
|
||||
* resolves each sprite name through #AKGL_REGISTRY_SPRITE, and
|
||||
* akgl_tilemap_load resolves each `character` property through
|
||||
* #AKGL_REGISTRY_CHARACTER while it spawns the map's actor objects.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
|
||||
/**
|
||||
* @brief Fix up the actors the map spawned, and attach the party member.
|
||||
*
|
||||
* Clears `movement_controls_face` on everything -- without which the default
|
||||
* `facefunc` erases the facing bit the map set and every NPC stops being drawn
|
||||
* on frame one -- installs the blocking movement logic on the player, and
|
||||
* builds the follower as a child actor.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_REGISTRY If the map spawned no actor named "player".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
|
||||
/**
|
||||
* @brief Centre the camera on the player, clamped to the edges of the map.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_REGISTRY If there is no actor named "player".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
|
||||
/**
|
||||
* @brief The player's `movementlogicfunc`: the library default, plus walls.
|
||||
*
|
||||
* libakgl implements no collision at all -- akgl_physics_arcade_collide raises
|
||||
* AKERR_API, akgl_physics_simulate never calls `collide`, and
|
||||
* akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall
|
||||
* writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open,
|
||||
* which is the documented way to tell the simulator to skip an actor's tick.
|
||||
*
|
||||
* @param obj The actor to compute acceleration for. Required, along with its
|
||||
* `basechar`.
|
||||
* @param dt Seconds this step covers.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||
* @throws AKGL_ERR_LOGICINTERRUPT While a conversation is open.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt);
|
||||
/**
|
||||
* @brief The follower's `renderfunc`: akgl_actor_render with the parent detached.
|
||||
*
|
||||
* The guard for a defect. akgl_physics_simulate writes a child's `x` as
|
||||
* `parent->x + vx` -- an absolute world position -- and akgl_actor_render then
|
||||
* draws it at `parent->x + obj->x`, adding the parent's position a second time.
|
||||
* Nulling `parent` for the duration of the draw takes the branch that does not
|
||||
* add it. See chapter 20.
|
||||
*
|
||||
* @param obj The child actor to draw. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj);
|
||||
/**
|
||||
* @brief Control handler: talk to whoever is standing nearby, or close the box.
|
||||
* @param obj The actor the control map drives -- the player. Required.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Control handler that does nothing, for the release half of a binding.
|
||||
*
|
||||
* akgl_controller_handle_event does not check a matched binding's handler
|
||||
* pointer before calling it, so a binding with a `NULL` `handler_off` is a
|
||||
* crash rather than an error. Every binding gets both halves.
|
||||
*
|
||||
* @param obj The actor the control map drives. Required.
|
||||
* @param event The event that triggered this. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event);
|
||||
|
||||
/**
|
||||
* @brief Put a line of dialogue on screen and freeze the world.
|
||||
* @param text The line to show. Required. Truncated at #JRPG_TEXTBOX_MAX_TEXT.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p text is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text);
|
||||
/** @brief Dismiss the text box. */
|
||||
void jrpg_textbox_close(void);
|
||||
/** @brief True while a line of dialogue is on screen. */
|
||||
bool jrpg_textbox_showing(void);
|
||||
/**
|
||||
* @brief Draw the panel and its text, if there is anything to draw.
|
||||
*
|
||||
* Called after akgl_game_update, so it lands on top of the world rather than
|
||||
* under it, and in screen coordinates rather than world ones --
|
||||
* akgl_text_rendertextat does not go through the camera, which is what a HUD
|
||||
* wants.
|
||||
*
|
||||
* @return `NULL` on success -- including when the box is closed -- otherwise an
|
||||
* error context owned by the caller.
|
||||
* @throws AKGL_ERR_REGISTRY If #JRPG_FONT_NAME is not a loaded font.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void);
|
||||
|
||||
#endif // _JRPG_JRPG_H_
|
||||
Reference in New Issue
Block a user