Let a map say which layers are solid, and answer questions about them

A tile layer with a `collidable` boolean custom property is collision geometry.
Before this a game had to hard-code a layer index -- both examples do, because
akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes
the moment somebody reorders layers in the editor.

Solid tiles are **not** given proxies. At the maximum map size that is a quarter
of a million per layer, tens of megabytes of index to describe data that is
already a dense grid sitting in the tilemap. The world keeps a borrowed pointer
and reads layers[i].data[] over whatever cell range a query covers: nine array
reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load,
and nothing to maintain per frame. Static geometry that is not tile-aligned is
still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist
and tiles use the free one because there are a hundred thousand of them.

Four public queries come with it, all of which answer without resolving:
solid_at, box_blocked, query_box and settle. They are what a game reaches for
when it wants to know rather than to be pushed -- a ledge probe ahead of a
walking enemy, a check that a doorway is clear -- and the sidescroller cannot
drop its hand-rolled collision without them.

akgl_collision_settle is the one worth naming. Resolution stops a shape entering
geometry and has nothing to say about one that began inside it: what it does
instead is refuse every move, so an actor spawned in a wall is simply stuck.
Level authors produce that constantly, so settling walks a shape up a tile at a
time and refuses loudly rather than searching forever.

Two defects found by writing the tests:

- The fixture put an akgl_Tilemap on the stack and segfaulted before the first
  assertion. It is about 26 MB -- the layer and tileset arrays are sized for the
  worst case the format allows -- and tilemap.h says so. It is static now, with
  a comment saying why, because the next person to write a map fixture will
  reach for a local first as well.

- The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a
  sensible tolerance on a unit vector and a meaningless one on a map coordinate:
  `float` carries 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 did nothing, a box resting flush on the floor read as inside
  it, and every move it tried looked blocked -- an actor standing on the ground
  unable to walk. Two different quantities were sharing one constant; the tile
  one is now its own, at a thousandth of a pixel, which is what the sidescroller
  example independently arrived at.

Both breaks verified: removing the nudge and ignoring the `collidable` bit each
turn the suite red with the symptom named.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 06:38:12 -04:00
parent 68e042bd47
commit e632abf720
5 changed files with 570 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.
*

View File

@@ -17,6 +17,7 @@
#include <akstdlib.h>
#include <akgl/collision_arena.h>
#include <akgl/error.h>
#include <akgl/tilemap.h>
#include <akgl/types.h>
#include <ccd/ccd.h>
@@ -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
);
}

View File

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

View File

@@ -26,6 +26,7 @@
#include <akgl/collision.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/tilemap.h>
#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 {