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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user