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>
437 lines
15 KiB
C
437 lines
15 KiB
C
/**
|
|
* @file player.c
|
|
* @brief The player's two hooks and its control bindings.
|
|
*
|
|
* Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the
|
|
* split between them is the frame's own order. `akgl_game_update` calls every
|
|
* actor's `updatefunc`, then steps the physics, then draws -- so per-frame game
|
|
* logic that is not movement (picking a coin up, falling in the pit) goes in
|
|
* `updatefunc`, and anything that has to happen inside the physics step goes in
|
|
* `movementlogicfunc`, which is the only hook the step calls.
|
|
*
|
|
* Two of the library's documented gaps are worked around here rather than
|
|
* papered over. Both are in `TODO.md` under "Arcade physics feel".
|
|
*/
|
|
|
|
#include <math.h>
|
|
|
|
#include <akstdlib.h>
|
|
|
|
#include <akgl/controller.h>
|
|
#include <akgl/heap.h>
|
|
#include <akgl/physics.h>
|
|
#include <akgl/registry.h>
|
|
#include <akgl/util.h>
|
|
|
|
#include "sidescroller.h"
|
|
|
|
/** @brief The player's collision box, as an offset into its 32x32 sprite frame. */
|
|
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
|
|
};
|
|
|
|
/** @brief Where the map put the player, so a death can put it back. */
|
|
static ss_ActorData ss_player_data;
|
|
|
|
/**
|
|
* @brief The rectangle an actor is tested against, given a 32x32 sprite frame.
|
|
*
|
|
* Smaller than the frame on purpose. Sprite art does not reach the edges, and a
|
|
* hazard box the full size of the frame kills a player who is visibly nowhere
|
|
* near it.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @brief Put the player back where the map placed it, at rest.
|
|
*
|
|
* Everything the simulation carries between steps has to be cleared, not just
|
|
* the position: `ey` is where gravity has been accumulating, and a player who
|
|
* respawns still holding a full-speed fall lands dead again immediately.
|
|
*/
|
|
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;
|
|
obj->ey = 0.0f;
|
|
obj->tx = 0.0f;
|
|
obj->ty = 0.0f;
|
|
obj->vx = 0.0f;
|
|
obj->vy = 0.0f;
|
|
ss_game.deaths += 1;
|
|
SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
|
|
*
|
|
* Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
|
|
* drag, the velocity recomputation and `move`.
|
|
*/
|
|
static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
ss_Contact contact;
|
|
float32_t friction = 0.0f;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
|
|
/* The default logic still has to run: it is what copies the character's
|
|
* speeds onto the actor and signs its acceleration by the movement bits. */
|
|
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
|
|
|
/*
|
|
* Workaround 1: there is no friction anywhere in the arcade backend.
|
|
*
|
|
* akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction
|
|
* stops the actor dead inside one frame -- correct for a top-down Zelda,
|
|
* wrong for a sidescroller. This game binds its own `_off` handlers that
|
|
* leave `tx` alone (see below) and decays it here instead, faster on the
|
|
* ground than in the air. Below a pixel per second it is snapped to zero,
|
|
* because an exponential decay never actually arrives.
|
|
*/
|
|
if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) &&
|
|
AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* A jump is an impulse straight into the environmental term, because `ey` is
|
|
* the axis gravity accumulates on and the two have to cancel for the arc to
|
|
* come back down. Thrust would not: `ty` is capped against the character's
|
|
* `speed_y`, which is 0 for this character, so a jump written as thrust is
|
|
* scaled to nothing by the ellipse cap in akgl_physics_simulate.
|
|
*
|
|
* `ss_game.grounded` is last step's verdict, one frame stale. That is the
|
|
* ordering again -- this hook runs before the step it is deciding about --
|
|
* and one frame of coyote time is not a thing a player can feel.
|
|
*/
|
|
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
|
obj->ey = -SS_JUMP_SPEED;
|
|
}
|
|
ss_game.jump_requested = false;
|
|
|
|
PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact));
|
|
ss_game.grounded = contact.grounded;
|
|
|
|
/*
|
|
* The character maps MOVING_UP to the jump sprites, so the state word says
|
|
* "in the air" whether the actor is rising or falling. Nothing else reads
|
|
* the bit here: `speed_y` and `acceleration_y` are both 0 in
|
|
* character_ss_player.json, so the thrust it would authorise is capped to
|
|
* nothing.
|
|
*/
|
|
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);
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The player's `updatefunc`: the animation, then what it is touching.
|
|
*
|
|
* Runs in `akgl_game_update`'s actor sweep, before the physics step and before
|
|
* anything is drawn.
|
|
*/
|
|
static akerr_ErrorContext *ss_player_update(akgl_Actor *obj)
|
|
{
|
|
SDL_FRect player;
|
|
SDL_FRect other;
|
|
bool hit = false;
|
|
int i = 0;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
|
|
/* Facing, then the animation frame. Replacing a hook does not mean
|
|
* reimplementing it. */
|
|
PASS(errctx, akgl_actor_update(obj));
|
|
|
|
/* Off the bottom of the world. Nothing in the library stops an actor
|
|
* leaving the map -- akgl_physics_arcade_move does not clamp -- so falling
|
|
* out of the level is a thing the game has to notice for itself. */
|
|
if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) {
|
|
PASS(errctx, respawn(obj));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
PASS(errctx, hitbox(obj, 8.0f, &player));
|
|
|
|
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
|
if ( ss_game.coins[i] == NULL ) {
|
|
continue;
|
|
}
|
|
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
|
|
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
|
if ( hit == true ) {
|
|
/*
|
|
* Giving the pool slot back is what unregisters the actor and stops
|
|
* it being drawn; there is no "despawn" call. Releasing another
|
|
* actor from inside this sweep is safe -- akgl_game_update re-reads
|
|
* `refcount` at the top of every iteration and skips a slot that has
|
|
* gone free.
|
|
*/
|
|
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
|
|
ss_game.coins[i] = NULL;
|
|
ss_game.coins_taken += 1;
|
|
SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT);
|
|
}
|
|
}
|
|
|
|
for ( i = 0; i < SS_HAZARD_COUNT; i++ ) {
|
|
if ( ss_game.hazards[i] == NULL ) {
|
|
continue;
|
|
}
|
|
PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other));
|
|
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
|
if ( hit == true ) {
|
|
PASS(errctx, respawn(obj));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/*
|
|
* Workaround 2, the release half.
|
|
*
|
|
* akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax`
|
|
* *and* zero `tx`. That last one is the "stops dead" behaviour; these two do
|
|
* everything else it does and leave `tx` for ss_player_movement to decay.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
static akerr_ErrorContext *ss_control_right_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_RIGHT);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @brief Cut a rising jump short when the button is let go.
|
|
*
|
|
* Variable jump height for four lines, and it works because `ey` is an ordinary
|
|
* field that nothing else owns between steps.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
akerr_ErrorContext *ss_player_bind(akgl_Actor *obj)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
|
|
ss_game.player = obj;
|
|
|
|
/* Lift the spawn point clear of the step it overlaps *before* recording it,
|
|
* so a respawn does not put the player back inside the geometry. */
|
|
PASS(errctx, ss_collide_settle(obj, &ss_player_body));
|
|
ss_player_data.home_x = obj->x;
|
|
ss_player_data.home_y = obj->y;
|
|
obj->actorData = (void *)&ss_player_data;
|
|
|
|
/*
|
|
* The default `facefunc` 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 character_ss_player.json has no sprite for
|
|
* that. The actor becomes invisible while standing still.
|
|
*
|
|
* Clearing this is the documented way out: akgl_actor_automatic_face leaves
|
|
* an actor alone entirely when it is clear, and the facing bits stay
|
|
* wherever the control handlers last put them.
|
|
*/
|
|
obj->movement_controls_face = false;
|
|
|
|
/* Replace the hooks after akgl_actor_initialize, never before -- it
|
|
* overwrites all six. The tilemap loader has already run it here. */
|
|
obj->movementlogicfunc = &ss_player_movement;
|
|
obj->updatefunc = &ss_player_update;
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
|
|
{
|
|
akgl_ControlMap *controlmap = NULL;
|
|
akgl_Control control;
|
|
SDL_KeyboardID *keyboards = NULL;
|
|
SDL_JoystickID *gamepads = NULL;
|
|
int count = 0;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname");
|
|
/* akgl_controller_pushmap checks the upper bound and not the lower one, so
|
|
* a negative id indexes before the start of akgl_controlmaps. TODO.md,
|
|
* "Known and still open" item 11. */
|
|
FAIL_NONZERO_RETURN(
|
|
errctx,
|
|
((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)),
|
|
AKERR_OUTOFBOUNDS,
|
|
"Control map id %d is outside 0..%d",
|
|
controlmapid,
|
|
(AKGL_MAX_CONTROL_MAPS - 1)
|
|
);
|
|
PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
|
|
|
|
controlmap = &akgl_controlmaps[controlmapid];
|
|
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
|
|
FAIL_ZERO_RETURN(
|
|
errctx,
|
|
controlmap->target,
|
|
AKGL_ERR_REGISTRY,
|
|
"Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded",
|
|
actorname
|
|
);
|
|
|
|
/*
|
|
* A control map listens to exactly one keyboard and one gamepad, matched by
|
|
* id, and a binding whose id does not match the event's is not consulted.
|
|
* That is what keeps two local players on two keyboards apart -- and it is
|
|
* also why "the arrow keys do nothing" is usually the wrong id rather than
|
|
* the wrong key.
|
|
*/
|
|
keyboards = SDL_GetKeyboards(&count);
|
|
if ( keyboards != NULL ) {
|
|
if ( count > 0 ) {
|
|
controlmap->kbid = keyboards[0];
|
|
}
|
|
SDL_free(keyboards);
|
|
}
|
|
gamepads = SDL_GetGamepads(&count);
|
|
if ( gamepads != NULL ) {
|
|
if ( count > 0 ) {
|
|
controlmap->jsid = gamepads[0];
|
|
}
|
|
SDL_free(gamepads);
|
|
}
|
|
|
|
/* ---- keyboard ---- */
|
|
control.event_on = SDL_EVENT_KEY_DOWN;
|
|
control.event_off = SDL_EVENT_KEY_UP;
|
|
|
|
control.key = SDLK_LEFT;
|
|
control.handler_on = &akgl_actor_cmhf_left_on;
|
|
control.handler_off = &ss_control_left_off;
|
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
|
|
|
control.key = SDLK_RIGHT;
|
|
control.handler_on = &akgl_actor_cmhf_right_on;
|
|
control.handler_off = &ss_control_right_off;
|
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
|
|
|
control.key = SDLK_SPACE;
|
|
control.handler_on = &ss_control_jump_on;
|
|
control.handler_off = &ss_control_jump_off;
|
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
|
|
|
/* ---- gamepad ---- */
|
|
/* Clear the keycode first: a keyboard event is matched on `key` whatever
|
|
* else the binding carries, and 0 is a keycode like any other. */
|
|
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));
|
|
|
|
control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT;
|
|
control.handler_on = &akgl_actor_cmhf_right_on;
|
|
control.handler_off = &ss_control_right_off;
|
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
|
|
|
control.button = SDL_GAMEPAD_BUTTON_SOUTH;
|
|
control.handler_on = &ss_control_jump_on;
|
|
control.handler_off = &ss_control_jump_off;
|
|
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
|
|
|
SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *ss_player_autoplay(int frame)
|
|
{
|
|
SDL_Event synthetic;
|
|
PREPARE_ERROR(errctx);
|
|
|
|
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
|
|
PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event)));
|
|
synthetic.type = SDL_EVENT_KEY_DOWN;
|
|
|
|
/*
|
|
* The headless smoke run has no keyboard, so it calls the handlers a
|
|
* keyboard would have called. They take the actor and an event, and the
|
|
* keyboard handlers do not read the event beyond requiring one -- which is
|
|
* what makes a scripted run possible at all.
|
|
*/
|
|
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;
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|