Delete the sidescroller's collision, and let the library do it
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
This commit is contained in:
@@ -9,7 +9,6 @@ get_filename_component(SS_ASSET_DIR
|
||||
|
||||
add_executable(sidescroller
|
||||
main.c
|
||||
collision.c
|
||||
player.c
|
||||
actors.c
|
||||
)
|
||||
|
||||
@@ -75,15 +75,19 @@ static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
||||
* @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.
|
||||
* map's gravity like the player does, and the library resolves it the same way.
|
||||
* All this hook decides is which way to face next.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
ss_ActorData *data = NULL;
|
||||
ss_Contact contact;
|
||||
SDL_FRect ahead;
|
||||
float32_t probe_x = 0.0f;
|
||||
float32_t probe_y = 0.0f;
|
||||
float32_t step = 0.0f;
|
||||
bool floor_ahead = false;
|
||||
bool wall_ahead = false;
|
||||
bool grounded = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
@@ -99,7 +103,18 @@ static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
/* 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));
|
||||
step = 1.0f;
|
||||
if ( data->facing < 0.0f ) {
|
||||
step = -1.0f;
|
||||
}
|
||||
|
||||
/* Three probes, all taken from where the last step left the blob. The blob
|
||||
* turns on a wall or a ledge, and a contact from the resolver answers
|
||||
* neither question: it says something pushed back, not which side of the
|
||||
* blob it was on, and it says nothing at all about a floor that is missing. */
|
||||
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead));
|
||||
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &ahead, AKGL_COLLISION_LAYER_STATIC, &wall_ahead));
|
||||
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded));
|
||||
|
||||
/* One pixel past the leading edge of the box and one pixel below its feet:
|
||||
* no floor there means the next step walks off. */
|
||||
@@ -109,12 +124,13 @@ static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
} 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));
|
||||
PASS(errctx, akgl_collision_solid_at(&ss_collision, probe_x, probe_y, &floor_ahead));
|
||||
|
||||
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
||||
if ( (wall_ahead == true) || ((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. */
|
||||
/* The library's response removes the component of `tx` going into a
|
||||
* wall, but nothing removes it on the ledge path -- and without this the
|
||||
* blob keeps its old momentum through the turn. */
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -165,7 +181,10 @@ akerr_ErrorContext *ss_actors_bind(void)
|
||||
}
|
||||
|
||||
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
|
||||
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
|
||||
PASS(errctx, akgl_collision_shape_box(&ss_game.hazards[0]->shape, &ss_blob_body, 0.0f));
|
||||
ss_game.hazards[0]->shape_override = true;
|
||||
PASS(errctx, akgl_collision_settle(&ss_collision, &ss_game.hazards[0]->shape,
|
||||
&ss_game.hazards[0]->x, &ss_game.hazards[0]->y, 0));
|
||||
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;
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* @file collision.c
|
||||
* @brief The collision libakgl does not have.
|
||||
*
|
||||
* This file exists because of a gap, and the gap is worth stating exactly:
|
||||
*
|
||||
* - `akgl_physics_arcade_collide` raises `AKERR_API` with the message "Not
|
||||
* implemented".
|
||||
* - `akgl_physics_simulate` never calls `collide` at all -- not for the arcade
|
||||
* backend, not for the null one. The vtable slot exists and the simulation
|
||||
* does not use it.
|
||||
* - `akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.
|
||||
* It does not clamp to the map, consult the tilemap, or test anything.
|
||||
*
|
||||
* So an actor walks through a wall and off the edge of the world, and the only
|
||||
* hook that runs inside the physics step is the actor's own `movementlogicfunc`.
|
||||
* That is where this game's collision lives, and everything here is called from
|
||||
* there.
|
||||
*
|
||||
* The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
|
||||
* drag, the velocity recomputation and `move`, so it cannot look at where the
|
||||
* actor ended up -- the step has not happened yet. ss_collide_predict therefore
|
||||
* repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
|
||||
* resolves against that predicted position. Get the prediction wrong and the
|
||||
* actor is resolved against a step it never takes.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
#include "sidescroller.h"
|
||||
|
||||
/**
|
||||
* @brief Half a pixel-thousandth, taken off the far edge of a box before it is
|
||||
* turned into tile indices.
|
||||
*
|
||||
* A box whose right edge sits exactly on a tile boundary does not overlap the
|
||||
* tile on the far side of it. Without this, an actor standing flush against a
|
||||
* wall reads as inside it, and the sweep snaps it back a tile every frame.
|
||||
*/
|
||||
#define SS_COLLIDE_EPSILON 0.001f
|
||||
|
||||
/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
|
||||
#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
|
||||
|
||||
/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
|
||||
#define SS_COLLIDE_SETTLE_TILES 4
|
||||
|
||||
static akgl_TilemapLayer *ss_terrain = NULL;
|
||||
|
||||
/**
|
||||
* @brief Is this map cell solid?
|
||||
*
|
||||
* Off the left or right edge of the map is solid, so the level has walls at its
|
||||
* ends. Off the top or the bottom is not: the sky is open and the pit in the
|
||||
* middle of level1.tmj has to be fallable-into, which is the whole point of it.
|
||||
*/
|
||||
static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
|
||||
|
||||
if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
|
||||
*dest = true;
|
||||
} else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
|
||||
*dest = false;
|
||||
} else {
|
||||
/* A tile layer's data is global tile ids in row-major order, and 0 is
|
||||
* the empty cell. Any tile at all on the terrain layer is solid: the
|
||||
* level says what is solid by which layer the tile is drawn on. */
|
||||
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
|
||||
{
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
|
||||
|
||||
ss_terrain = NULL;
|
||||
for ( i = 0; i < map->numlayers; i++ ) {
|
||||
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
||||
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
||||
ss_terrain = &map->layers[i];
|
||||
}
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
ss_terrain,
|
||||
AKERR_KEY,
|
||||
"Map has no tile layer with id %d to collide against",
|
||||
SS_TERRAIN_LAYER_ID
|
||||
);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
|
||||
{
|
||||
int x0 = 0;
|
||||
int x1 = 0;
|
||||
int y0 = 0;
|
||||
int y1 = 0;
|
||||
int tilex = 0;
|
||||
int tiley = 0;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((box->w <= 0.0f) || (box->h <= 0.0f)),
|
||||
AKERR_VALUE,
|
||||
"Collision box is %fx%f; both sides must be positive",
|
||||
box->w,
|
||||
box->h
|
||||
);
|
||||
|
||||
*dest = false;
|
||||
x0 = (int)floorf(box->x / SS_TILE_SIZE);
|
||||
x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||
y0 = (int)floorf(box->y / SS_TILE_SIZE);
|
||||
y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||
|
||||
for ( tiley = y0; tiley <= y1; tiley++ ) {
|
||||
for ( tilex = x0; tilex <= x1; tilex++ ) {
|
||||
PASS(errctx, solid_tile(tilex, tiley, &solid));
|
||||
if ( solid == true ) {
|
||||
*dest = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
|
||||
{
|
||||
float32_t ex = 0.0f;
|
||||
float32_t ey = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
|
||||
FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
|
||||
FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
|
||||
|
||||
/*
|
||||
* Everything below is akgl_physics_simulate's own arithmetic, in its own
|
||||
* order: gravity onto the environmental term, then drag off it, then
|
||||
* velocity as environmental plus thrust, then position plus velocity times
|
||||
* dt. The `!= 0` guards are the library's too -- it skips each axis whose
|
||||
* constant is zero rather than multiplying by it -- and are repeated here
|
||||
* because dropping them would make a zero-gravity axis pick up drag.
|
||||
*
|
||||
* This mirrors the *arcade* backend. Against the null backend gravity does
|
||||
* nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
|
||||
* right.
|
||||
*/
|
||||
ex = obj->ex;
|
||||
ey = obj->ey;
|
||||
if ( akgl_physics->gravity_x != 0 ) {
|
||||
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
||||
}
|
||||
if ( akgl_physics->gravity_y != 0 ) {
|
||||
ey += (float32_t)akgl_physics->gravity_y * dt;
|
||||
}
|
||||
if ( akgl_physics->drag_x != 0 ) {
|
||||
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
|
||||
}
|
||||
if ( akgl_physics->drag_y != 0 ) {
|
||||
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
|
||||
}
|
||||
*dx = (ex + obj->tx) * dt;
|
||||
*dy = (ey + obj->ty) * dt;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
|
||||
*
|
||||
* Each axis is tested on its own, which is what lets an actor slide along a wall
|
||||
* instead of sticking to it, and the sub-step is capped at half a tile so a
|
||||
* fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
|
||||
* the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
|
||||
* step, which is nearly two tiles -- so this is not a theoretical concern.
|
||||
*
|
||||
* On a blocked axis the box is snapped to the tile boundary it was about to
|
||||
* cross rather than simply not moved, so an actor lands flush on a floor at
|
||||
* whatever speed it arrives.
|
||||
*/
|
||||
static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
|
||||
{
|
||||
SDL_FRect trial;
|
||||
float32_t span = 0.0f;
|
||||
float32_t stepx = 0.0f;
|
||||
float32_t stepy = 0.0f;
|
||||
int steps = 0;
|
||||
int i = 0;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
span = fabsf(dx);
|
||||
if ( fabsf(dy) > span ) {
|
||||
span = fabsf(dy);
|
||||
}
|
||||
steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
|
||||
stepx = dx / (float32_t)steps;
|
||||
stepy = dy / (float32_t)steps;
|
||||
|
||||
for ( i = 0; i < steps; i++ ) {
|
||||
if ( stepx != 0.0f ) {
|
||||
trial = *box;
|
||||
trial.x += stepx;
|
||||
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||
if ( solid == true ) {
|
||||
if ( stepx > 0.0f ) {
|
||||
box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
|
||||
} else {
|
||||
box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||
}
|
||||
stepx = 0.0f;
|
||||
dest->blocked_x = true;
|
||||
} else {
|
||||
box->x = trial.x;
|
||||
}
|
||||
}
|
||||
if ( stepy != 0.0f ) {
|
||||
trial = *box;
|
||||
trial.y += stepy;
|
||||
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||
if ( solid == true ) {
|
||||
if ( stepy > 0.0f ) {
|
||||
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
|
||||
} else {
|
||||
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||
}
|
||||
stepy = 0.0f;
|
||||
dest->blocked_y = true;
|
||||
} else {
|
||||
box->y = trial.y;
|
||||
}
|
||||
}
|
||||
if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
|
||||
{
|
||||
SDL_FRect box;
|
||||
SDL_FRect probe;
|
||||
float32_t dx = 0.0f;
|
||||
float32_t dy = 0.0f;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
dest->blocked_x = false;
|
||||
dest->blocked_y = false;
|
||||
dest->grounded = false;
|
||||
|
||||
PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
|
||||
|
||||
box.x = obj->x + body->x;
|
||||
box.y = obj->y + body->y;
|
||||
box.w = body->w;
|
||||
box.h = body->h;
|
||||
PASS(errctx, sweep(&box, dx, dy, dest));
|
||||
|
||||
/*
|
||||
* Only a blocked axis is written back. The free axis is left for
|
||||
* akgl_physics_arcade_move to advance by exactly the amount this function
|
||||
* predicted -- writing it here as well would move the actor twice.
|
||||
*
|
||||
* `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
|
||||
* effort; a blocked axis has to give up both, or the actor keeps pressing
|
||||
* into the wall and the next step's prediction is wrong by everything it
|
||||
* accumulated while stuck.
|
||||
*
|
||||
* The environmental term is not set to zero, it is set to *minus one step of
|
||||
* gravity*, and that is not a nicety. Zeroing it leaves the step about to add
|
||||
* `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
|
||||
* quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
|
||||
* still fatal: the box now overlaps the floor tile, so the *horizontal*
|
||||
* sweep on the next step finds itself blocked wherever it is, and the actor
|
||||
* is snapped back a whole tile every time it tries to walk. Pre-loading the
|
||||
* cancellation leaves the actor resting exactly on the surface instead.
|
||||
*/
|
||||
if ( dest->blocked_x == true ) {
|
||||
obj->x = box.x - body->x;
|
||||
obj->ex = (float32_t)akgl_physics->gravity_x * dt;
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
if ( dest->blocked_y == true ) {
|
||||
obj->y = box.y - body->y;
|
||||
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
||||
obj->ty = 0.0f;
|
||||
}
|
||||
|
||||
/*
|
||||
* Standing on a floor is not a state the library records, so it is measured:
|
||||
* one pixel below where the box will be at the end of this step. Note that
|
||||
* zeroing `ey` above does not stop gravity -- the step still adds
|
||||
* `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
|
||||
* fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
|
||||
* it straight back off, so a standing actor rests within a pixel of the
|
||||
* surface forever rather than sinking through it.
|
||||
*/
|
||||
probe = box;
|
||||
probe.y += 1.0f;
|
||||
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
||||
dest->grounded = solid;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
|
||||
{
|
||||
SDL_FRect box;
|
||||
bool solid = false;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||
|
||||
/*
|
||||
* A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
|
||||
* actor can start out with its box partly inside a step -- level1.tmj puts
|
||||
* the player at x=32 and a block at tiles (3,11), which is under the right
|
||||
* half of the player's frame.
|
||||
*
|
||||
* The swept resolution cannot fix that. It stops an actor *entering*
|
||||
* terrain and has nothing to say about one that began inside it; what it
|
||||
* does instead is refuse every horizontal move, because the box is already
|
||||
* blocked wherever it goes. So a spawn point gets lifted clear once, before
|
||||
* the first step, a tile at a time.
|
||||
*/
|
||||
for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
|
||||
box.x = obj->x + body->x;
|
||||
box.y = obj->y + body->y;
|
||||
box.w = body->w;
|
||||
box.h = body->h;
|
||||
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
||||
if ( solid == false ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
obj->y -= (float32_t)SS_TILE_SIZE;
|
||||
SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
|
||||
}
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"%s spawned inside more than %d tiles of terrain at %f, %f",
|
||||
(char *)obj->name,
|
||||
SS_COLLIDE_SETTLE_TILES,
|
||||
obj->x,
|
||||
obj->y
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,23 @@
|
||||
#define SS_PATH_MAX 1024
|
||||
|
||||
ss_Game ss_game;
|
||||
akgl_CollisionWorld ss_collision;
|
||||
|
||||
akerr_ErrorContext *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest)
|
||||
{
|
||||
SDL_FRect feet;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "shape");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
/* One pixel down, and only one: a taller probe reports a floor the actor is
|
||||
* still falling towards, and a jump that fires off it looks like the player
|
||||
* jumped out of thin air. */
|
||||
PASS(errctx, akgl_collision_shape_bounds(shape, x, y + 1.0f, &feet));
|
||||
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &feet, AKGL_COLLISION_LAYER_STATIC, dest));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The sprites, loaded in this order.
|
||||
@@ -240,7 +257,17 @@ static akerr_ErrorContext *load_level(char *assetdir)
|
||||
(akgl_physics->gravity_y / akgl_physics->drag_y));
|
||||
}
|
||||
|
||||
PASS(errctx, ss_collide_bind(akgl_gamemap));
|
||||
/*
|
||||
* Collision is opt-in: a backend with no world attached moves an actor and
|
||||
* consults nothing, which is what this game used to rely on and then spend
|
||||
* 383 lines of its own working around. The cell arguments are placeholders
|
||||
* -- akgl_collision_bind_tilemap overwrites them from the map's own tile
|
||||
* size, and reads which layers are solid off each layer's `collidable`
|
||||
* property rather than a layer id compiled into the game.
|
||||
*/
|
||||
PASS(errctx, akgl_collision_world_init(&ss_collision, NULL, (float32_t)SS_TILE_SIZE, (float32_t)SS_TILE_SIZE));
|
||||
PASS(errctx, akgl_collision_bind_tilemap(&ss_collision, akgl_gamemap));
|
||||
akgl_physics->collision = &ss_collision;
|
||||
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
|
||||
@@ -346,6 +373,14 @@ static akerr_ErrorContext *run(int frames)
|
||||
* driver, where there is nothing to sync to. */
|
||||
SDL_Delay(16);
|
||||
}
|
||||
|
||||
/* Recorded here and not in main(): shutdown_game() runs in main()'s CLEANUP
|
||||
* block and releases the actor pool, so ss_game.player is stale by the time
|
||||
* the summary is printed. */
|
||||
if ( ss_game.player != NULL ) {
|
||||
ss_game.final_x = ss_game.player->x;
|
||||
ss_game.final_y = ss_game.player->y;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -446,11 +481,20 @@ int main(int argc, char *argv[])
|
||||
*/
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
/*
|
||||
* The position is in the summary because exiting 0 is not evidence that
|
||||
* collision ran. The map is 240 pixels tall and gravity is 900 px/s^2: an
|
||||
* unresolved player is hundreds of pixels below the floor within a second,
|
||||
* and this line is what makes that visible in a CI log instead of passing.
|
||||
*/
|
||||
SDL_Log(
|
||||
"sidescroller: %d frames, %d of %d coins, %d deaths",
|
||||
"sidescroller: %d frames, %d of %d coins, %d deaths, player at %.1f,%.1f%s",
|
||||
ss_game.frame,
|
||||
ss_game.coins_taken,
|
||||
SS_COIN_COUNT,
|
||||
ss_game.deaths);
|
||||
ss_game.deaths,
|
||||
ss_game.final_x,
|
||||
ss_game.final_y,
|
||||
((ss_game.grounded == true) ? " grounded" : " airborne"));
|
||||
return ss_failed;
|
||||
}
|
||||
|
||||
@@ -86,14 +86,15 @@ static akerr_ErrorContext *respawn(akgl_Actor *obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
|
||||
* @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`.
|
||||
* 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)
|
||||
{
|
||||
ss_Contact contact;
|
||||
float32_t friction = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
@@ -113,6 +114,11 @@ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
* 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;
|
||||
@@ -132,18 +138,13 @@ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
* `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.
|
||||
* `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;
|
||||
|
||||
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
|
||||
@@ -151,7 +152,7 @@ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
* character_ss_player.json, so the thrust it would authorise is capped to
|
||||
* nothing.
|
||||
*/
|
||||
if ( contact.grounded == true ) {
|
||||
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);
|
||||
@@ -285,9 +286,12 @@ akerr_ErrorContext *ss_player_bind(akgl_Actor *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));
|
||||
/* 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;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* 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
|
||||
*
|
||||
@@ -23,8 +22,10 @@
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
@@ -38,16 +39,6 @@
|
||||
#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. */
|
||||
@@ -81,19 +72,6 @@
|
||||
*/
|
||||
#define SS_AUTOPLAY_KBID 11
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
@@ -120,17 +98,31 @@ typedef struct {
|
||||
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. */
|
||||
float32_t final_x; /**< Where the player finished, stamped before teardown for the summary line. */
|
||||
float32_t final_y;
|
||||
} 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);
|
||||
/**
|
||||
* @brief The collision world, owned by main.c.
|
||||
*
|
||||
* The physics backend resolves through it after every sub-move; the game only
|
||||
* touches it to ask questions -- a ledge probe, a wall probe, a spawn point that
|
||||
* needs lifting clear. Everything that used to be in collision.c is behind it.
|
||||
*/
|
||||
extern akgl_CollisionWorld ss_collision;
|
||||
|
||||
/**
|
||||
* @brief Is there solid geometry one pixel under this shape?
|
||||
*
|
||||
* The predicate a jump is gated on, and it is deliberately *not* the same
|
||||
* question as "did the last step's resolution stop me falling". A contact says
|
||||
* something pushed back this step; this says there is a floor to push off, which
|
||||
* stays true through the frame after landing and is what makes a jump feel like
|
||||
* it registered.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest);
|
||||
|
||||
/* player.c */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
|
||||
|
||||
Reference in New Issue
Block a user