/** * @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 ); }