collision.c was 383 lines that re-implemented akgl_physics_simulate's own arithmetic, because movementlogicfunc runs before gravity, drag and the move and so had to predict where the actor would end up. The whole file goes. What replaces it is three lines in main.c, a shape on the player and the blob, and a `collidable` property on the map's terrain layer -- which also retires SS_TERRAIN_LAYER_ID, a compiled-in layer id that broke whenever somebody reordered layers in Tiled. grounded stays a probe rather than becoming a contact test. A contact says something pushed back this step; grounded asks whether there is a floor to push off, and that stays true through the frame after landing. The blob keeps its own wall and ledge probes for the same reason: the resolver does not say which side of the blob it was pushed from, and says nothing at all about a floor that is missing. The summary line now carries the player's resting position, because exiting 0 was not evidence that collision ran. It reports 136.0,160.0 grounded after 240 autoplay frames -- feet at y=192, flush on the surface of terrain row 12. The tutorial's collision section is rewritten around the library's, keeping the quarter-of-a-pixel story as what a predicting resolver costs. The docs harness learns `json excerpt=`, so the map property the chapter quotes is checked against the map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
451 lines
16 KiB
C
451 lines
16 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 and the jump.
|
|
*
|
|
* Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
|
|
* drag, the velocity recomputation and `move`. Terrain is no longer this
|
|
* function's problem -- the library resolves it after the move, which is the
|
|
* only place it can be resolved without predicting the arithmetic above.
|
|
*/
|
|
static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
|
{
|
|
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.
|
|
*/
|
|
/* Where the last step left the player, one pixel down. This runs before the
|
|
* step it is deciding about, so it is one frame stale by construction --
|
|
* which is one frame of coyote time, and not a thing a player can feel. */
|
|
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &ss_game.grounded));
|
|
|
|
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 the probe above.
|
|
*/
|
|
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
|
obj->ey = -SS_JUMP_SPEED;
|
|
}
|
|
ss_game.jump_requested = false;
|
|
|
|
/*
|
|
* 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 ( ss_game.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;
|
|
|
|
/* The library's default response only stops an actor *entering* geometry;
|
|
* one that starts inside it is simply stuck, so lift the spawn point clear
|
|
* of the step it overlaps *before* recording it. */
|
|
PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f));
|
|
obj->shape_override = true;
|
|
PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0));
|
|
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;
|
|
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 one keyboard and one gamepad, matched by id, and
|
|
* 0 means "any". That is what a one-player game wants, and it is the only
|
|
* thing that works: SDL_GetKeyboards() reports what is attached, but the id
|
|
* a key event carries is chosen by the video backend, and on X11 it is
|
|
* SDL_GLOBAL_KEYBOARD_ID (0) without XInput2 and the physical device's
|
|
* sourceid with it -- never the SDL_DEFAULT_KEYBOARD_ID of 1 that SDL
|
|
* registers when XInput2 is missing. This function used to bind
|
|
* SDL_GetKeyboards()[0] and the arrow keys did nothing at all.
|
|
*
|
|
* Bind a specific id when two players share a machine on two keyboards.
|
|
*/
|
|
controlmap->kbid = 0;
|
|
controlmap->jsid = 0;
|
|
|
|
/* ---- 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)));
|
|
/*
|
|
* Dispatch through akgl_controller_handle_event rather than calling the
|
|
* handlers directly, so a scripted run exercises the binding table the way a
|
|
* player does. The first version of this called the handlers, which meant
|
|
* the smoke test passed while the control map matched nothing at all and the
|
|
* arrow keys did nothing -- a test that cannot fail is not a test.
|
|
*
|
|
* SS_AUTOPLAY_KBID is deliberately not 0: the map binds keyboard 0 meaning
|
|
* "any", and driving it from a non-zero device id is what proves that.
|
|
*/
|
|
if ( frame == 1 ) {
|
|
synthetic.type = SDL_EVENT_KEY_DOWN;
|
|
synthetic.key.which = SS_AUTOPLAY_KBID;
|
|
synthetic.key.key = SDLK_RIGHT;
|
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
|
|
FAIL_ZERO_RETURN(
|
|
errctx,
|
|
AKGL_BITMASK_HAS(ss_game.player->state, AKGL_ACTOR_STATE_MOVING_RIGHT),
|
|
AKGL_ERR_BEHAVIOR,
|
|
"the right arrow did not reach the player; the control map matched nothing"
|
|
);
|
|
}
|
|
|
|
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));
|
|
} else if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == SS_AUTOPLAY_JUMP_HOLD ) {
|
|
synthetic.type = SDL_EVENT_KEY_UP;
|
|
synthetic.key.which = SS_AUTOPLAY_KBID;
|
|
synthetic.key.key = SDLK_SPACE;
|
|
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|