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>
184 lines
6.9 KiB
C
184 lines
6.9 KiB
C
/**
|
|
* @file actors.c
|
|
* @brief Everything level1.tmj places that is not the player.
|
|
*
|
|
* None of these are created here. The map's object layer creates all seven
|
|
* actors, registers them under the names Tiled gave them, and binds each one to
|
|
* the character its `character` property names -- so this file's whole job is to
|
|
* find them again by name and give them behaviour.
|
|
*
|
|
* Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is the one
|
|
* status in libakgl that is not a failure. Raised from a `movementlogicfunc` it
|
|
* means "skip the rest of this tick for me": no gravity, no drag, no move for
|
|
* that actor this step, and akgl_physics_simulate carries on to the next one.
|
|
* That is exactly what an actor that has just placed itself wants.
|
|
*/
|
|
|
|
#include <math.h>
|
|
|
|
#include <akstdlib.h>
|
|
|
|
#include <akgl/heap.h>
|
|
#include <akgl/registry.h>
|
|
|
|
#include "sidescroller.h"
|
|
|
|
/** @brief The blob's collision box, offset into its 32x32 frame. */
|
|
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
|
|
|
/**
|
|
* @brief Per-actor data for everything in this file, one slot per actor.
|
|
*
|
|
* A fixed table rather than an allocation, for the same reason the library has
|
|
* pools rather than a `malloc`: the level places a known number of things, and a
|
|
* game that cannot run out of memory at runtime is one fewer failure mode.
|
|
*/
|
|
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
|
|
|
|
/**
|
|
* @brief Look an actor up by the name its Tiled object carried.
|
|
*
|
|
* A miss here means the map and the code disagree about what the level
|
|
* contains, which is worth failing on rather than working around.
|
|
*/
|
|
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
|
|
|
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
|
|
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A `movementlogicfunc` for something that does not move at all.
|
|
*
|
|
* The coins need this. Their character declares no speed and no acceleration, so
|
|
* they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity`
|
|
* accumulates into `ey` for every actor it is handed, so a coin left to the
|
|
* default logic falls out of the level along with everything else.
|
|
*
|
|
* Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its
|
|
* step. It is not an error and nothing logs it.
|
|
*/
|
|
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
(void)dt;
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
|
|
}
|
|
|
|
/**
|
|
* @brief The blob: walk, and turn around at a wall or a ledge.
|
|
*
|
|
* This one stays inside the physics step -- it walks on the ground under the
|
|
* map's gravity like the player does, and uses the same swept resolution.
|
|
*/
|
|
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
ss_ActorData *data = NULL;
|
|
ss_Contact contact;
|
|
float32_t probe_x = 0.0f;
|
|
float32_t probe_y = 0.0f;
|
|
bool floor_ahead = false;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
|
data = (ss_ActorData *)obj->actorData;
|
|
|
|
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
|
if ( data->facing < 0.0f ) {
|
|
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
|
|
} else {
|
|
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
|
|
}
|
|
/* Signs the acceleration from the movement bits just set. */
|
|
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
|
|
|
PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
|
|
|
|
/* One pixel past the leading edge of the box and one pixel below its feet:
|
|
* no floor there means the next step walks off. */
|
|
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
|
|
if ( data->facing < 0.0f ) {
|
|
probe_x = obj->x + ss_blob_body.x - 1.0f;
|
|
} else {
|
|
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
|
|
}
|
|
PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
|
|
|
|
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
|
data->facing = -data->facing;
|
|
/* The resolution already zeroed `tx` on a blocked axis; zero it on the
|
|
* ledge path too, or the blob keeps its old momentum through the turn. */
|
|
obj->tx = 0.0f;
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The moth: a figure-eight around where the map put it.
|
|
*
|
|
* It flies, so it wants no gravity -- and the map's physics has gravity, because
|
|
* the player needs it. Rather than fighting the backend the moth writes its own
|
|
* position and then opts out of the rest of the step, which is the second thing
|
|
* #AKGL_ERR_LOGICINTERRUPT is for.
|
|
*/
|
|
static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
ss_ActorData *data = NULL;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
|
data = (ss_ActorData *)obj->actorData;
|
|
|
|
data->phase += dt;
|
|
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
|
|
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
|
|
|
|
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL);
|
|
if ( cosf(data->phase) < 0.0f ) {
|
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT);
|
|
} else {
|
|
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT);
|
|
}
|
|
|
|
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
|
|
}
|
|
|
|
akerr_ErrorContext *ss_actors_bind(void)
|
|
{
|
|
char name[32];
|
|
int count = 0;
|
|
int i = 0;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
|
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
|
|
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
|
|
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
|
|
}
|
|
|
|
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
|
|
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
|
|
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
|
|
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
|
|
ss_hazard_data[0].facing = -1.0f;
|
|
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
|
|
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
|
|
|
|
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
|
|
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
|
|
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
|
|
ss_hazard_data[1].facing = 1.0f;
|
|
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
|
|
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|