diff --git a/docs/20-tutorial-sidescroller.md b/docs/20-tutorial-sidescroller.md index 520e019..48cc803 100644 --- a/docs/20-tutorial-sidescroller.md +++ b/docs/20-tutorial-sidescroller.md @@ -240,189 +240,135 @@ files, four characters, a map and a tileset image — happened between the backe created and the first step. The `max_timestep` bound would have caught it, at the cost of one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md). -## The collision libakgl does not have +## Collision, in three lines -Here is the whole problem, stated as three facts about `src/physics.c`: +The game does not implement collision. It attaches a world and the library resolves through +it: -- **`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. +```c excerpt=examples/sidescroller/main.c + 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; +``` -So an actor walks through a wall and off the edge of the world, and none of that announces -itself at compile time. [Chapter 14](14-physics.md) says so plainly and this game is what -the consequence looks like. +Plus a shape on anything that should collide, which is two lines per actor: -The only hook the physics step calls is the actor's own `movementlogicfunc`, so that is -where the collision goes. And that placement is awkward in exactly one way, which shapes -everything in `collision.c`: +```c excerpt=examples/sidescroller/player.c + PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f)); + obj->shape_override = true; +``` + +That is the whole of it. [Chapter 15](15-collision.md) is the reference; what follows is +what this game had to know. + +### Why it resolves after the move and not before + +The only per-actor hook the physics step used to offer was `movementlogicfunc`, and it runs +in the wrong place: ```text akgl_physics_simulate, per actor: tx += ax * dt <- thrust, from the previous step's ax cap (tx,ty,tz) to the speed ellipse - movementlogicfunc(actor, dt) <- YOU ARE HERE + movementlogicfunc(actor, dt) <- the only hook there used to be gravity(self, actor, dt) ey += gravity_y * dt ex -= ex * drag_x * dt (and y, z) vx = ex + tx (and y, z) move(self, actor, dt) x += vx * dt + collide(self, actor, dt) <- where resolution actually happens ``` -**Your hook runs before the step it has to resolve.** It cannot look at where the actor -ended up, because the actor has not moved yet. So the game predicts the step instead: +A resolver in `movementlogicfunc` runs before gravity, drag and the move, so it cannot look +at where the actor ended up — the actor has not moved yet. It has to *predict* the step, +which means copying the six lines above into the game, `!= 0` guards included, in a file +that will not be recompiled when they change. -```c excerpt=examples/sidescroller/collision.c - ex = obj->ex; - ey = obj->ey; - if ( akgl_physics->gravity_x != 0 ) { - ex -= (float32_t)akgl_physics->gravity_x * dt; +This tutorial used to carry that copy, and 383 lines around it. All of it is gone. `collide` +runs after `move` on a position that already happened, so there is nothing to predict. + +**One symptom is worth keeping, because it is what a predicting resolver costs.** The +obvious thing for such a resolver to do on a blocked vertical axis is zero `ey`. It is +wrong: the step is about to add `gravity_y * dt` back and `move` commits `gravity_y * dt²` +of fall — a quarter of a pixel at 900 px/s² and 60 Hz. Invisible, and fatal. That quarter +pixel of overlap means the box intersects the floor tile, so on the *next* step the +horizontal sweep finds itself blocked wherever it goes and snaps back to a tile boundary. +The symptom is a character who cannot walk, jerking backwards by up to a tile every time it +tries, and nothing about it looks like a vertical problem. The fix was to pre-load the +cancellation — `ey = -gravity_y * dt` — which is arithmetic no game should ever have had to +write. Resolving after the move deletes the whole class. + +### Solid is data now, not a layer id compiled into the game + +The old version matched Tiled's numeric layer id, because **`akgl_TilemapLayer` does not +record the layer's name** — `akgl_tilemap_load_layers` reads `id`, `opacity`, `visible`, +`x`, `y` and `type` and nothing else, so there is no way to ask for "the layer called +terrain". A `#define SS_TERRAIN_LAYER_ID 2` broke the moment somebody reordered layers in +the editor. + +The map says it instead. `level1.tmj`'s terrain layer carries a custom property: + +```json excerpt=docs/tutorials/assets/sidescroller/level1.tmj + "properties": [ + { + "name": "collidable", + "type": "bool", + "value": true } - 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; + ] ``` -That is `akgl_physics_simulate`'s own arithmetic in its own order, `!= 0` guards included — -dropping them would let a zero-gravity axis pick up drag, which the library does not do. Get -it wrong and the actor is resolved against a step it never takes. +`akgl_collision_bind_tilemap` reads it off every layer and the game names no layer at all. -Be clear about what this is: a copy of somebody else's implementation, in a file that will -not be recompiled when that implementation changes. It is a liability, and it is not a -design — it is what the missing `collide` call costs. When `akgl_physics_simulate` grows a -collision hook, this function is the first thing to delete. +### Standing on something is still measured -### Solid is a layer, not a flag +Nothing in libakgl records whether an actor is on the ground, and a contact does not answer +it either — a contact says something pushed back this step, which is a different question +from "is there a floor to push off". So it stays a probe: -There is no per-tile "solid" property. The level says what is solid by which layer a tile is -drawn on: - -```c excerpt=examples/sidescroller/collision.c - *dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0); +```c excerpt=examples/sidescroller/main.c + 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)); ``` -Finding that layer takes a small piece of knowledge that is easy to lose an afternoon to: -**`akgl_TilemapLayer` does not record the layer's name.** `akgl_tilemap_load_layers` reads -`id`, `opacity`, `visible`, `x`, `y` and `type` and nothing else, so there is no way to ask -for "the layer called terrain". The game matches on Tiled's numeric layer id instead: +One pixel, 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. -```c excerpt=examples/sidescroller/collision.c - if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) && - (map->layers[i].id == SS_TERRAIN_LAYER_ID) ) { - ss_terrain = &map->layers[i]; - } -``` - -Off the left or right edge of the map counts as solid, so the level has walls at its ends. -Off the top or the bottom does not: the sky is open, and falling into the pit is the whole -point of the pit. - -### Sweeping, not testing - -A fall at 600 px/s with the step bounded to 0.05 s covers 30 pixels, which is nearly two -tiles. A single test at the destination walks straight through a floor. So the motion is -walked in sub-steps of at most half a tile, each axis separately — testing the axes -separately is what lets an actor slide along a wall instead of sticking to it — and a -blocked axis is snapped to the boundary it was about to cross rather than simply not moved, -so an actor lands flush at whatever speed it arrives: - -```c excerpt=examples/sidescroller/collision.c - 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; - } - } -``` - -Only a blocked axis is written back to the actor. The free axis is left for -`akgl_physics_arcade_move` to advance by exactly the amount that was predicted — writing it -here as well would move the actor twice. - -### The quarter of a pixel that breaks everything - -This one cost an afternoon and is the most useful thing in the chapter. - -The obvious thing to do on a blocked axis is to zero the environmental term. It is wrong, -and the reason is the ordering again: the step is about to add `gravity_y * dt` back, and -`move` commits `gravity_y * dt²` of fall. At 900 px/s² and 60 Hz that is a quarter of a -pixel. Invisible — and fatal. - -A quarter of a pixel of overlap means the actor's box intersects the floor tile. On the next -step the **horizontal** sweep therefore finds itself blocked wherever it tries to go, and -snaps the actor back to a tile boundary. The symptom is a character who cannot walk, jerking -backwards by up to a tile every time it tries. Nothing about it looks like a vertical -problem. - -The fix is to pre-load the cancellation instead of zeroing: - -```c excerpt=examples/sidescroller/collision.c - if ( dest->blocked_y == true ) { - obj->y = box.y - body->y; - obj->ey = -(float32_t)akgl_physics->gravity_y * dt; - obj->ty = 0.0f; - } -``` - -After the step's gravity, `ey` is exactly zero; after drag, still zero; `move` commits -nothing. A standing actor rests exactly on the surface, forever, at a `y` that is a whole -number of pixels. - -### Standing on something is measured, not recorded - -Nothing in libakgl knows whether an actor is on the ground. It is one probe, a pixel below -where the box will be when the step finishes: - -```c excerpt=examples/sidescroller/collision.c - probe = box; - probe.y += 1.0f; - PASS(errctx, ss_collide_box_blocked(&probe, &solid)); - dest->grounded = solid; -``` - -The verdict is one frame stale by the time the jump reads it, because this hook runs before -the step it is deciding about. One frame of coyote time is not something a player can feel. +The verdict is one frame stale by the time the jump reads it, because `movementlogicfunc` +runs before the step it is deciding about. One frame of coyote time is not something a +player can feel. ### Spawning inside the scenery A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the -player at x=32 with a step at tile (3,11) — under the right half of the player's frame. The -swept resolution cannot help: 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 blocked wherever it goes. +player at x=32 with a step at tile (3,11) — under the right half of the player's frame. +Resolution cannot help: it stops an actor *entering* geometry and has nothing to say about +one that began inside it. What it does instead is refuse every move, so the actor is simply +stuck. So spawn points get lifted clear once, before the first step: -```c excerpt=examples/sidescroller/collision.c - PASS(errctx, ss_collide_box_blocked(&box, &solid)); - if ( solid == false ) { - SUCCEED_RETURN(errctx); - } - obj->y -= (float32_t)SS_TILE_SIZE; +```c excerpt=examples/sidescroller/player.c + PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0)); ``` The player and the blob both need it, and both end up standing on the block they were -overlapping. Failing after four tiles is deliberate: a spawn point buried that deep is a -level bug, and a silent nudge would hide it. +overlapping. `akgl_collision_settle` refuses loudly after four tiles rather than searching +forever: a spawn point buried that deep is a level bug, and a silent nudge would hide it. + +### What the blob still asks for itself + +The blob turns around at a wall or a ledge, and 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*. Both are queries: + +```c excerpt=examples/sidescroller/actors.c + 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)); +``` + +This is what the query API is for: asking without being pushed. ## Making it feel like a platformer @@ -508,7 +454,7 @@ The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever t not on the ground: ```c excerpt=examples/sidescroller/player.c - 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); @@ -579,12 +525,13 @@ Two rules go with it, both from [Chapter 4](04-errors.md): - The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN` inside one returns past `CLEANUP`. -The blob is the counter-example: it stays inside the physics step, walks under the level's -gravity like the player does, and uses the same swept resolution. It turns around when the -sweep blocks it or when there is no floor one pixel past its leading edge: +The blob is the counter-example: it stays inside the physics step and walks under the +level's gravity like the player does, and the library resolves it the same way. All its hook +decides is which way to face next — a wall ahead, or no floor one pixel past its leading +edge: ```c excerpt=examples/sidescroller/actors.c - if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) { + if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) { data->facing = -data->facing; ``` diff --git a/docs/tutorials/assets/sidescroller/level1.tmj b/docs/tutorials/assets/sidescroller/level1.tmj index 6093bc1..3f73df2 100644 --- a/docs/tutorials/assets/sidescroller/level1.tmj +++ b/docs/tutorials/assets/sidescroller/level1.tmj @@ -1227,7 +1227,14 @@ "visible": true, "width": 40, "x": 0, - "y": 0 + "y": 0, + "properties": [ + { + "name": "collidable", + "type": "bool", + "value": true + } + ] }, { "draworder": "topdown", diff --git a/examples/sidescroller/CMakeLists.txt b/examples/sidescroller/CMakeLists.txt index 572ea6d..e397d0a 100644 --- a/examples/sidescroller/CMakeLists.txt +++ b/examples/sidescroller/CMakeLists.txt @@ -9,7 +9,6 @@ get_filename_component(SS_ASSET_DIR add_executable(sidescroller main.c - collision.c player.c actors.c ) diff --git a/examples/sidescroller/actors.c b/examples/sidescroller/actors.c index e0a19b1..3256f15 100644 --- a/examples/sidescroller/actors.c +++ b/examples/sidescroller/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; diff --git a/examples/sidescroller/collision.c b/examples/sidescroller/collision.c deleted file mode 100644 index 08dafe1..0000000 --- a/examples/sidescroller/collision.c +++ /dev/null @@ -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 - -#include - -#include -#include - -#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 - ); -} diff --git a/examples/sidescroller/main.c b/examples/sidescroller/main.c index 13edd0b..809f265 100644 --- a/examples/sidescroller/main.c +++ b/examples/sidescroller/main.c @@ -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; } diff --git a/examples/sidescroller/player.c b/examples/sidescroller/player.c index 86f5e8a..b9caa98 100644 --- a/examples/sidescroller/player.c +++ b/examples/sidescroller/player.c @@ -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; diff --git a/examples/sidescroller/sidescroller.h b/examples/sidescroller/sidescroller.h index a8d62f8..a77ba8a 100644 --- a/examples/sidescroller/sidescroller.h +++ b/examples/sidescroller/sidescroller.h @@ -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 #include +#include #include #include +#include #include #include @@ -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); diff --git a/tests/docs_examples.sh b/tests/docs_examples.sh index 47dcac1..d8e3307 100755 --- a/tests/docs_examples.sh +++ b/tests/docs_examples.sh @@ -33,6 +33,7 @@ # ```c excerpt=PATH must appear verbatim in PATH; not compiled # ```c screenshot=NAME also the source of docs/images/NAME.png # ```json kind=KIND loaded through the real akgl_*_load_json +# ```json excerpt=PATH must appear verbatim in PATH; not loaded # ```output the exact stdout of the runnable block above it # ```sh run in a sandbox; must exit 0 # ```sh setup=NAME the same, after tests/docs_setups/NAME.sh @@ -434,9 +435,10 @@ function run_c_run() fi } -# A declaration echoed from a header. Compiling it would redefine the type, so -# the check is that it still says what the header says -- which is the drift -# that actually happens, and the one a compile could not catch. +# A declaration echoed from a header, or a fragment quoted out of an asset file +# too large to show whole. Compiling or loading it is not the point -- the check +# is that it still says what the file says, which is the drift that actually +# happens and the one a compile could not catch. function run_excerpt() { local where="$1" body="$2" path="$3" @@ -640,7 +642,12 @@ for DOC in "${DOCS[@]}"; do fi ;; json) - run_json "${WHERE}" "${BODY}" "${INFO}" + EXCERPT="$(attr excerpt "${INFO}")" + if [ -n "${EXCERPT}" ]; then + run_excerpt "${WHERE}" "${BODY}" "${EXCERPT}" + else + run_json "${WHERE}" "${BODY}" "${INFO}" + fi ;; sh) run_sh "${WHERE}" "${BODY}" "${INFO}" "${EXPECTED}"