diff --git a/include/akgl/collision.h b/include/akgl/collision.h index 0d3da56..a0beaac 100644 --- a/include/akgl/collision.h +++ b/include/akgl/collision.h @@ -302,6 +302,10 @@ typedef struct akgl_CollisionWorld { uint32_t flags; /**< `AKGL_COLLISION_TEST_*` bits applied to every test this world runs. */ uint32_t sweep; /**< Serial of the current pass. Stamped onto proxies so one spanning several cells is reported once. */ uint32_t tests; /**< Narrowphase calls during the last pass. Diagnostic. */ + + struct akgl_Tilemap *tilesource;/**< The map whose solid tile layers are static geometry, or `NULL` for none. Borrowed. */ + uint32_t tilelayers; /**< Which of that map's layers are solid, copied from its `collidablelayers`. */ + uint32_t tilelayermask; /**< The collision layer solid tiles are treated as being on. #AKGL_COLLISION_LAYER_STATIC by default. */ } akgl_CollisionWorld; /** @@ -347,6 +351,109 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_partitioner_factory(akgl_Partitioner *se */ akerr_ErrorContext AKERR_NOIGNORE *akgl_partitioner_init_grid(akgl_Partitioner *self); +/** + * @brief Make a map's solid layers count as collision geometry. + * + * @section collision_tiles Tiles are read, not registered + * + * A map's solid tiles are **not** given proxies. At the maximum map size that + * would be a quarter of a million of them per layer -- tens of megabytes of + * index, to describe data that is already a dense grid sitting in the tilemap. + * Instead the world keeps a borrowed pointer to the map and reads + * `layers[i].data[]` directly over whatever cell range a query covers, which is + * nine array reads for a 32-pixel actor on 16-pixel tiles and costs nothing to + * maintain when a level loads. + * + * Static geometry that is *not* tile-aligned -- a slope, a Tiled object + * rectangle, a platform that only moves between levels -- is still an ordinary + * proxy carrying #AKGL_COLLISION_FLAG_STATIC. Both mechanisms exist; tiles use + * the free one because there are a hundred thousand of them. + * + * @param self The world. Required. + * @param map The map, or `NULL` to detach the one already bound. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self is `NULL`. + * @throws AKERR_VALUE If the map's tile dimensions are not positive. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_bind_tilemap(akgl_CollisionWorld *self, struct akgl_Tilemap *map); + +/** + * @brief Is the tile under this point solid? + * + * A point query against the bound map's collidable layers, and nothing else -- + * it does not consult proxies. Off the map is **not** solid: a game that wants + * an edge of the world puts one there, and a pit that kills the player has to be + * a pit rather than a wall. + * + * @param self The world. Required. + * @param x World x in map pixels. + * @param y World y in map pixels. + * @param dest Receives whether a solid tile covers that point. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or @p dest is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_solid_at(akgl_CollisionWorld *self, float32_t x, float32_t y, bool *dest); + +/** + * @brief Would this rectangle overlap anything solid? + * + * Tiles and proxies both. This is the query a game reaches for when it wants an + * answer without a response: a ledge probe ahead of a walking enemy, a check + * that a spawn point is clear, a door that only opens when nothing is standing + * in it. + * + * @param self The world. Required. + * @param box The rectangle, in map pixels. Required. + * @param mask Which collision layers count. #AKGL_COLLISION_LAYER_ALL for + * everything, #AKGL_COLLISION_LAYER_STATIC for map geometry only. + * @param dest Receives the answer. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If any pointer argument is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_box_blocked(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, bool *dest); + +/** + * @brief Visit every proxy that may overlap a rectangle. + * + * The broad phase, exposed. Tiles are not proxies and so are not visited; use + * akgl_collision_box_blocked when the question includes map geometry. + * + * @param self The world. Required. + * @param box The rectangle, in map pixels. Required. + * @param mask Which collision layers to report. + * @param visit Called once per candidate. Required. + * @param data Passed through to @p visit. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If any pointer argument is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_query_box(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, akgl_CollisionVisitFunc visit, void *data); + +/** + * @brief Lift a shape out of the geometry it was placed inside. + * + * Resolution stops a shape *entering* geometry and has nothing to say about one + * that started inside it -- what it does instead is refuse every move, so an + * actor spawned in a wall is simply stuck. That is not a hypothetical: level + * authors place a character on a step and Tiled rounds the object to a position + * that overlaps the tile below it. + * + * This walks the shape up a tile at a time until it is clear, and refuses rather + * than searching forever if it cannot be. Call it once, after a map load, for + * anything the map placed. + * + * @param self The world. Required. + * @param shape The shape being placed. Required. + * @param x Position in map pixels. Read and written. + * @param y Position in map pixels. Read and written; this is what moves. + * @param maxsteps How many tiles to try before giving up. 0 uses a sensible default. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If any pointer argument is `NULL`. + * @throws AKERR_VALUE If the shape is still inside geometry after @p maxsteps. + * The message carries the position, because the answer is almost always + * to move the object in the level rather than to raise the limit. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_settle(akgl_CollisionWorld *self, akgl_CollisionShape *shape, float32_t *x, float32_t *y, int maxsteps); + /* * The following is part of the internal API. Proxies are created and destroyed * by the library, not by a game. diff --git a/include/akgl/tilemap.h b/include/akgl/tilemap.h index 79f6535..c12c105 100644 --- a/include/akgl/tilemap.h +++ b/include/akgl/tilemap.h @@ -151,6 +151,7 @@ typedef struct akgl_Tilemap { akgl_TilemapLayer layers[AKGL_TILEMAP_MAX_LAYERS]; /**< The layers, in draw order: index 0 is furthest back. */ // Different levels may have different physics. + uint32_t collidablelayers; /**< Bit `i` set means `layers[i]` is solid geometry for collision, from that layer's `collidable` custom property in Tiled. 0 means nothing on this map collides, which is what a map authored before collision existed gets. */ bool use_own_physics; /**< Set when the map declared a `physics.model` property. A caller that honours it simulates through `akgl_physics` instead of the global backend. */ akgl_PhysicsBackend physics; /**< This map's own backend, valid only when `use_own_physics` is set. */ } akgl_Tilemap; @@ -301,6 +302,25 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_string(json_t *obj, * @throws AKGL_ERR_HEAP If the string pool is exhausted. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_integer(json_t *obj, char *key, int *dest); + +/** + * @brief Read a boolean Tiled custom property. + * + * Tiled writes these with a declared type of `"bool"`, and the accessor matches + * on that type -- a property written as an `int` named the same thing raises + * `AKERR_TYPE` rather than being coerced, because a map that says `1` where it + * means `true` is a map that will say something else next time. + * + * @param obj The object carrying a `properties` array. Required. + * @param key Property name. Required. + * @param dest Receives the value. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_KEY If no property of that name exists. **Absent is not an + * error to the loader** -- a layer with no `collidable` property is not + * collidable, which is the common case. + * @throws AKERR_TYPE If the property exists but was not written as a bool. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_boolean(json_t *obj, char *key, bool *dest); /** * @brief Build a tileset's local-id-to-pixel-offset table. * diff --git a/src/collision.c b/src/collision.c index ecbe052..654085a 100644 --- a/src/collision.c +++ b/src/collision.c @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,24 @@ /** @brief Below this, a normal is treated as having no length at all. */ #define AKGL_COLLISION_EPSILON 1e-6f +/** + * @brief Nudge applied to a box's far edge before it is turned into tile indices. + * + * A thousandth of a pixel, and **not** #AKGL_COLLISION_EPSILON. That one is a + * tolerance on a unit vector, where 1e-6 is meaningful; this one is subtracted + * from a map coordinate, where it is not. `float` has about seven significant + * digits, so at a coordinate of 144 the smallest representable step is around + * 1.5e-5 and `144.0f - 1e-6f` is exactly 144.0f. The nudge does nothing, the far + * edge lands on the next tile, and an actor standing flush on the ground reads + * as inside it -- which makes every move it tries look blocked. + * + * The sidescroller example arrived at the same number for the same reason. + */ +#define AKGL_COLLISION_TILE_EPSILON 0.001f + +/** @brief Tiles akgl_collision_settle will lift a shape before giving up. */ +#define AKGL_COLLISION_SETTLE_STEPS 4 + /** @brief One positioned shape, in the form libccd's callbacks read. */ typedef struct { uint8_t kind; /**< AKGL_COLLISION_SHAPE_*. */ @@ -443,8 +462,216 @@ akerr_ErrorContext *akgl_collision_world_init(akgl_CollisionWorld *self, char *t // Every game today is 2D, so the guard is on by default. A caller with a // real third axis to resolve on clears it. self->flags = AKGL_COLLISION_TEST_PLANAR; + self->tilelayermask = AKGL_COLLISION_LAYER_STATIC; PASS(errctx, akgl_partitioner_factory(&self->partitioner, type)); PASS(errctx, self->partitioner.reset(&self->partitioner, self)); SUCCEED_RETURN(errctx); } + +/** + * @brief Is a tile coordinate solid on any of the world's collidable layers? + * + * Reads the tilemap's own array. A gid of 0 is an empty cell; anything else is + * a tile, and whether a tile is solid is decided by which layer it is drawn on, + * not by the tile itself. Off the map is not solid -- see akgl_collision_solid_at. + */ +static bool collision_tile_solid(akgl_CollisionWorld *self, int32_t tx, int32_t ty) +{ + akgl_Tilemap *map = self->tilesource; + int layer = 0; + + if ( (map == NULL) || (self->tilelayers == 0) ) { + return false; + } + if ( (tx < 0) || (ty < 0) || (tx >= map->width) || (ty >= map->height) ) { + return false; + } + + for ( layer = 0; layer < map->numlayers; layer++ ) { + if ( (self->tilelayers & (1u << layer)) == 0 ) { + continue; + } + // Row-major, strided by the map's width rather than the array's, which + // is how akgl_tilemap_draw indexes it too. + if ( map->layers[layer].data[(ty * map->width) + tx] != 0 ) { + return true; + } + } + return false; +} + +akerr_ErrorContext *akgl_collision_bind_tilemap(akgl_CollisionWorld *self, akgl_Tilemap *map) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + + if ( map == NULL ) { + self->tilesource = NULL; + self->tilelayers = 0; + SUCCEED_RETURN(errctx); + } + + FAIL_NONZERO_RETURN(errctx, (map->tilewidth <= 0), AKERR_VALUE, + "Map tile width %d is not positive", map->tilewidth); + FAIL_NONZERO_RETURN(errctx, (map->tileheight <= 0), AKERR_VALUE, + "Map tile height %d is not positive", map->tileheight); + + self->tilesource = map; + self->tilelayers = map->collidablelayers; + + /* + * Cells are keyed on the map's tiles. That is the sizing the performance + * record argues for, and it makes the tile scan below exactly one cell's + * worth of array reads per cell a query touches. + */ + self->cellwidth = (float32_t)map->tilewidth; + self->cellheight = (float32_t)map->tileheight; + PASS(errctx, self->partitioner.reset(&self->partitioner, self)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_solid_at(akgl_CollisionWorld *self, float32_t x, float32_t y, bool *dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference"); + + *dest = false; + if ( self->tilesource == NULL ) { + SUCCEED_RETURN(errctx); + } + // floorf, not a cast: truncation rounds toward zero, so -0.5 and +0.5 would + // land in the same tile and a coordinate just off the left edge of the map + // would read as being on it. + *dest = collision_tile_solid(self, + (int32_t)floorf(x / (float32_t)self->tilesource->tilewidth), + (int32_t)floorf(y / (float32_t)self->tilesource->tileheight)); + SUCCEED_RETURN(errctx); +} + +/** @brief Carries the answer out of a query visitor. */ +typedef struct { + SDL_FRect box; + bool blocked; +} collision_blocked_probe; + +static akerr_ErrorContext *collision_blocked_visit(akgl_CollisionProxy *proxy, void *data) +{ + collision_blocked_probe *probe = (collision_blocked_probe *)data; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy in a blocked probe"); + FAIL_ZERO_RETURN(errctx, probe, AKERR_NULLPOINTER, "NULL probe reference"); + + if ( (proxy->shape.flags & AKGL_COLLISION_FLAG_SENSOR) != 0 ) { + // A sensor reports, it does not block. Walking through a coin is not + // being blocked by it. + SUCCEED_RETURN(errctx); + } + if ( SDL_HasRectIntersectionFloat(&proxy->bounds, &probe->box) ) { + probe->blocked = true; + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_box_blocked(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, bool *dest) +{ + collision_blocked_probe probe; + int32_t tx = 0; + int32_t ty = 0; + int32_t x0 = 0; + int32_t y0 = 0; + int32_t x1 = 0; + int32_t y1 = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "NULL box reference"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference"); + + *dest = false; + + if ( ((mask & self->tilelayermask) != 0) && (self->tilesource != NULL) ) { + /* + * The far edge is nudged inward. Without it a box whose right edge sits + * exactly on a tile boundary reads as overlapping the tile beyond it, + * and an actor standing flush against a wall is reported as inside it -- + * which makes every move it tries look blocked. + */ + x0 = (int32_t)floorf(box->x / (float32_t)self->tilesource->tilewidth); + y0 = (int32_t)floorf(box->y / (float32_t)self->tilesource->tileheight); + x1 = (int32_t)floorf(((box->x + box->w) - AKGL_COLLISION_TILE_EPSILON) / (float32_t)self->tilesource->tilewidth); + y1 = (int32_t)floorf(((box->y + box->h) - AKGL_COLLISION_TILE_EPSILON) / (float32_t)self->tilesource->tileheight); + + for ( ty = y0; ty <= y1; ty++ ) { + for ( tx = x0; tx <= x1; tx++ ) { + if ( collision_tile_solid(self, tx, ty) ) { + *dest = true; + SUCCEED_RETURN(errctx); + } + } + } + } + + memset(&probe, 0x00, sizeof(probe)); + probe.box = *box; + PASS(errctx, self->partitioner.query(&self->partitioner, box, mask, &collision_blocked_visit, &probe)); + *dest = probe.blocked; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_query_box(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, akgl_CollisionVisitFunc visit, void *data) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "NULL box reference"); + FAIL_ZERO_RETURN(errctx, visit, AKERR_NULLPOINTER, "NULL visitor reference"); + + PASS(errctx, self->partitioner.query(&self->partitioner, box, mask, visit, data)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_settle(akgl_CollisionWorld *self, akgl_CollisionShape *shape, float32_t *x, float32_t *y, int maxsteps) +{ + SDL_FRect box; + bool blocked = false; + float32_t step = 0.0f; + int i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference"); + FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_ZERO_RETURN(errctx, x, AKERR_NULLPOINTER, "NULL x reference"); + FAIL_ZERO_RETURN(errctx, y, AKERR_NULLPOINTER, "NULL y reference"); + + if ( maxsteps <= 0 ) { + maxsteps = AKGL_COLLISION_SETTLE_STEPS; + } + step = self->cellheight; + if ( step <= 0.0f ) { + step = 1.0f; + } + + for ( i = 0; i <= maxsteps; i++ ) { + PASS(errctx, akgl_collision_shape_bounds(shape, *x, *y, &box)); + PASS(errctx, akgl_collision_box_blocked(self, &box, AKGL_COLLISION_LAYER_STATIC, &blocked)); + if ( blocked == false ) { + SUCCEED_RETURN(errctx); + } + // Up, because in a game with gravity the free space is above and the + // floor is below. Lifting is the move that does not drop a character + // through the world. + *y -= step; + } + + FAIL_RETURN( + errctx, + AKERR_VALUE, + "A shape at %f, %f is still inside solid geometry after being lifted %d tiles; " + "the object is probably placed wrong in the level rather than the limit being low", + *x, + *y, + maxsteps + ); +} diff --git a/src/tilemap.c b/src/tilemap.c index f9ba7d9..62975c8 100644 --- a/src/tilemap.c +++ b/src/tilemap.c @@ -90,6 +90,16 @@ akerr_ErrorContext *akgl_get_json_properties_string(json_t *obj, char *key, akgl SUCCEED_RETURN(errctx); } +akerr_ErrorContext *akgl_get_json_properties_boolean(json_t *obj, char *key, bool *dest) +{ + PREPARE_ERROR(errctx); + json_t *property = NULL; + PASS(errctx, akgl_get_json_tilemap_property(obj, key, "bool", &property)); + PASS(errctx, akgl_get_json_boolean_value(property, "value", dest)); + + SUCCEED_RETURN(errctx); +} + akerr_ErrorContext *akgl_get_json_properties_integer(json_t *obj, char *key, int *dest) { PREPARE_ERROR(errctx); @@ -394,6 +404,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_tile(akgl_Tilemap *dest, json_t *roo { PREPARE_ERROR(errctx); json_t *layerdata = NULL; + bool collidable = false; int j; int layerdatalen; @@ -416,6 +427,27 @@ akerr_ErrorContext *akgl_tilemap_load_layer_tile(akgl_Tilemap *dest, json_t *roo } PROCESS(errctx) { } FINISH(errctx, true); + /* + * Which layers are solid is the map's business, not the game's. Before this + * a game had to hard-code a layer index -- both example games do, because + * akgl_TilemapLayer does not retain the name Tiled wrote -- and that index + * changes the moment somebody reorders layers in the editor. + * + * A layer with no `collidable` property is not solid. That is the common + * case, it is not an error, and it is why this is its own ATTEMPT block with + * a HANDLE rather than a CATCH in the one above. + */ + ATTEMPT { + CATCH(errctx, akgl_get_json_properties_boolean(root, "collidable", &collidable)); + if ( collidable == true ) { + dest->collidablelayers |= (1u << layerid); + } + } CLEANUP { + } PROCESS(errctx) { + } HANDLE(errctx, AKERR_KEY) { + // No such property. Not solid, and not a complaint. + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); } diff --git a/tests/partition.c b/tests/partition.c index 0e837a9..67f7158 100644 --- a/tests/partition.c +++ b/tests/partition.c @@ -26,6 +26,7 @@ #include #include #include +#include #include "testutil.h" @@ -434,6 +435,185 @@ akerr_ErrorContext *test_reset_and_empty(char *name) SUCCEED_RETURN(errctx); } +/** + * @brief A small map with a floor, a pillar, a gap and a one-tile corridor. + * + * Synthesized rather than loaded: collision reads `data[]`, `tilewidth`, + * `tileheight`, `width` and `height` and nothing else, so a fixture needs no + * image, no tileset and no loader. `tests/perf_render.c` builds its map the same + * way for the same reason. + * + * Every shape here is one the two example games actually hit -- a floor to rest + * on, a pillar to walk into, a gap to fall through, a corridor that blocks both + * axes at once. + */ +/* + * Static, not on the stack. sizeof(akgl_Tilemap) is about 26 MB -- the layer and + * tileset arrays are sized for the worst case the format allows -- so a local + * one overflows the stack before the first assertion runs. tilemap.h says so and + * this fixture was written wrong once anyway. + */ +static akgl_Tilemap fixture_map; + +static void build_map(akgl_Tilemap *map) +{ + int x = 0; + + memset(map, 0x00, sizeof(akgl_Tilemap)); + map->tilewidth = 16; + map->tileheight = 16; + map->width = 20; + map->height = 10; + map->numlayers = 2; + map->layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES; + map->layers[1].type = AKGL_TILEMAP_LAYER_TYPE_TILES; + + // Layer 0 is scenery and is not collidable. Layer 1 is terrain and is. If + // the two were confused, an actor would collide with the background. + for ( x = 0; x < map->width; x++ ) { + map->layers[0].data[(2 * map->width) + x] = 99; + } + + // A floor along row 9, with a two-tile gap at columns 5 and 6. + for ( x = 0; x < map->width; x++ ) { + if ( (x == 5) || (x == 6) ) { + continue; + } + map->layers[1].data[(9 * map->width) + x] = 1; + } + // A one-tile pillar at column 12, rows 7 and 8. + map->layers[1].data[(7 * map->width) + 12] = 1; + map->layers[1].data[(8 * map->width) + 12] = 1; + + map->collidablelayers = (1u << 1); +} + +/** + * @brief Solid tiles block, non-collidable layers do not, and off-map is open. + */ +akerr_ErrorContext *test_tiles_are_geometry(char *name) +{ + akgl_CollisionWorld world; + akgl_Tilemap *map = &fixture_map; + SDL_FRect box; + bool solid = false; + bool blocked = false; + + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f)); + build_map(map); + CATCH(errctx, akgl_collision_bind_tilemap(&world, map)); + + TEST_ASSERT(errctx, (world.cellwidth == 16.0f), + "binding a map left the cell width at %f rather than the tile width", + world.cellwidth); + + // On the floor. + CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 152.0f, &solid)); + TEST_ASSERT(errctx, (solid == true), "%s does not see the floor tile at row 9", name); + + // In the gap in the floor. + CATCH(errctx, akgl_collision_solid_at(&world, 88.0f, 152.0f, &solid)); + TEST_ASSERT(errctx, (solid == false), "%s reports the gap in the floor as solid", name); + + // On the scenery layer, which is not collidable. Getting this wrong + // means an actor colliding with the background. + CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 40.0f, &solid)); + TEST_ASSERT(errctx, (solid == false), + "%s treats a non-collidable layer as solid; `collidable` is being ignored", name); + + // Off the map is open, not solid. A pit has to be a pit. + CATCH(errctx, akgl_collision_solid_at(&world, -100.0f, -100.0f, &solid)); + TEST_ASSERT(errctx, (solid == false), "%s reports off-map as solid", name); + CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 10000.0f, &solid)); + TEST_ASSERT(errctx, (solid == false), "%s reports below the map as solid", name); + + // A box resting exactly on the floor must not read as inside it. Without + // the epsilon on the far edge, an actor standing flush against geometry + // reads as overlapping it and every move it tries looks blocked. + box.x = 0.0f; + box.y = 128.0f; + box.w = 16.0f; + box.h = 16.0f; + CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_ALL, &blocked)); + TEST_ASSERT(errctx, (blocked == false), + "%s reports a box resting exactly on the floor as inside it; an actor " + "standing on the ground would be unable to move", name); + + // One pixel lower and it is inside. + box.y = 129.0f; + CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_ALL, &blocked)); + TEST_ASSERT(errctx, (blocked == true), "%s does not see a box overlapping the floor", name); + + // The pillar blocks horizontally. + box.x = 190.0f; + box.y = 112.0f; + CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_ALL, &blocked)); + TEST_ASSERT(errctx, (blocked == true), "%s does not see the pillar", name); + + // Detaching the map leaves nothing solid. + CATCH(errctx, akgl_collision_bind_tilemap(&world, NULL)); + CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 152.0f, &solid)); + TEST_ASSERT(errctx, (solid == false), "%s still sees tiles after the map was detached", name); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_solid_at(NULL, 0.0f, 0.0f, &solid), + "a point query with no world"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_box_blocked(&world, &box, 0, NULL), + "a box query with no destination"); + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Settling lifts a spawn out of the ground, and refuses when it cannot. + */ +akerr_ErrorContext *test_settle_lifts_a_spawn(char *name) +{ + akgl_CollisionWorld world; + akgl_Tilemap *map = &fixture_map; + akgl_CollisionShape shape; + SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f }; + float32_t x = 0.0f; + float32_t y = 0.0f; + bool blocked = false; + SDL_FRect box; + + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f)); + build_map(map); + CATCH(errctx, akgl_collision_bind_tilemap(&world, map)); + CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f)); + + // Placed straddling the floor, which is what an editor does when an + // object is dropped near a step. + x = 32.0f; + y = 140.0f; + CATCH(errctx, akgl_collision_settle(&world, &shape, &x, &y, 0)); + TEST_ASSERT(errctx, (y < 140.0f), "%s did not lift a shape spawned inside the floor", name); + + CATCH(errctx, akgl_collision_shape_bounds(&shape, x, y, &box)); + CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_STATIC, &blocked)); + TEST_ASSERT(errctx, (blocked == false), "%s settled a shape that is still inside geometry", name); + + // Already clear: settling must not move it at all. + x = 32.0f; + y = 40.0f; + CATCH(errctx, akgl_collision_settle(&world, &shape, &x, &y, 0)); + TEST_ASSERT_FEQ(errctx, y, 40.0f, "%s moved a shape that was already clear, to %f", name, y); + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + akerr_ErrorContext *test_world_and_factory(void) { akgl_CollisionWorld world; @@ -497,6 +677,10 @@ int main(void) if ( inner != NULL ) { break; } inner = test_reset_and_empty(partitioners[i]); if ( inner != NULL ) { break; } + inner = test_tiles_are_geometry(partitioners[i]); + if ( inner != NULL ) { break; } + inner = test_settle_lifts_a_spawn(partitioners[i]); + if ( inner != NULL ) { break; } } CATCH(errctx, inner); } CLEANUP {