test_reset_and_empty called spawn() with an uninitialized stack akgl_CollisionShape and built it on the next line. akgl_collision_proxy_initialize copies the shape, so grid_insert read uninitialized half-extents to work out which cells to file the proxy under -- eighteen findings inside grid_cellrect, and the test still passed because the proxy_sync two lines later overwrote all of it. Found by scripts/memcheck.sh, which is the only thing that could have found it: the assertion it makes is about the query afterwards, and the query answers correctly either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
704 lines
26 KiB
C
704 lines
26 KiB
C
/**
|
|
* @file partition.c
|
|
* @brief The broad phase, checked against brute force.
|
|
*
|
|
* A broad phase has one contract and it is asymmetric: it may report a pair that
|
|
* does not overlap, and it may never miss one that does. Over-reporting costs a
|
|
* narrowphase call; under-reporting is a wall an actor walks through, and it
|
|
* fails silently and intermittently, only for the positions where the structure
|
|
* happens to be wrong.
|
|
*
|
|
* So every query here is compared against a linear scan over the same proxies,
|
|
* and the assertion is containment in one direction and not equality. That is
|
|
* the only check that has any teeth: a broad phase can be tested against itself
|
|
* all day and agree with itself all day.
|
|
*
|
|
* The suite is written as a table over partitioners so the same assertions run
|
|
* against every implementation. A second implementation that is not held to the
|
|
* first one's contract is not pluggable, it is just present.
|
|
*/
|
|
|
|
#include <math.h>
|
|
#include <string.h>
|
|
|
|
#include <akerror.h>
|
|
|
|
#include <akgl/collision.h>
|
|
#include <akgl/error.h>
|
|
#include <akgl/heap.h>
|
|
#include <akgl/tilemap.h>
|
|
|
|
#include "testutil.h"
|
|
|
|
/** @brief Every partitioner the factory knows, so the same tests run over each. */
|
|
static char *partitioners[] = { "grid", "bsp" };
|
|
|
|
/** @brief What a visit callback accumulates. */
|
|
typedef struct {
|
|
int16_t seen[AKGL_MAX_HEAP_COLLISION_PROXY];
|
|
int count;
|
|
} visit_record;
|
|
|
|
static akerr_ErrorContext *record_visit(akgl_CollisionProxy *proxy, void *data)
|
|
{
|
|
visit_record *rec = (visit_record *)data;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy in a visit");
|
|
FAIL_ZERO_RETURN(errctx, rec, AKERR_NULLPOINTER, "NULL record in a visit");
|
|
|
|
if ( rec->count < AKGL_MAX_HEAP_COLLISION_PROXY ) {
|
|
rec->seen[rec->count] = (int16_t)(proxy - &akgl_heap_collision_proxies[0]);
|
|
rec->count += 1;
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/** @brief How many cell entries are currently claimed from the pool. */
|
|
static int live_cells(void)
|
|
{
|
|
int live = 0;
|
|
int i = 0;
|
|
|
|
for ( i = 0; i < AKGL_MAX_HEAP_COLLISION_CELL; i++ ) {
|
|
if ( akgl_heap_collision_cells[i].refcount != 0 ) {
|
|
live += 1;
|
|
}
|
|
}
|
|
return live;
|
|
}
|
|
|
|
/** @brief Was this proxy index reported? */
|
|
static bool sawit(visit_record *rec, int16_t idx)
|
|
{
|
|
int i = 0;
|
|
|
|
for ( i = 0; i < rec->count; i++ ) {
|
|
if ( rec->seen[i] == idx ) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** @brief Claim a proxy at a position and put it in the partitioner. */
|
|
static akerr_ErrorContext *spawn(akgl_CollisionWorld *world, akgl_CollisionShape *shape,
|
|
float32_t x, float32_t y, akgl_CollisionProxy **dest)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
PASS(errctx, akgl_heap_next_collision_proxy(dest));
|
|
PASS(errctx, akgl_collision_proxy_initialize(*dest, NULL, shape, x, y, 0.0f));
|
|
PASS(errctx, world->partitioner.insert(&world->partitioner, *dest));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A query must report every proxy a linear scan would, and may report more.
|
|
*
|
|
* Positions are chosen to land on cell boundaries, span several cells, and sit
|
|
* outside the grid entirely, because those are the three places an index is
|
|
* wrong and a linear scan is not.
|
|
*/
|
|
akerr_ErrorContext *test_query_never_misses(char *name)
|
|
{
|
|
akgl_CollisionWorld world;
|
|
akgl_CollisionShape small;
|
|
akgl_CollisionShape big;
|
|
akgl_CollisionProxy *proxies[16];
|
|
visit_record rec;
|
|
SDL_FRect area;
|
|
SDL_FRect small_body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
SDL_FRect big_body = { .x = 0.0f, .y = 0.0f, .w = 96.0f, .h = 96.0f };
|
|
float32_t spots[][2] = {
|
|
{ 0.0f, 0.0f }, { 16.0f, 0.0f }, { 32.0f, 32.0f }, { 8.0f, 8.0f },
|
|
{ 100.0f, 100.0f }, { 1000.0f, 40.0f }, { -40.0f, -40.0f }, { 15.9f, 15.9f }
|
|
};
|
|
int spotcount = (int)(sizeof(spots) / sizeof(spots[0]));
|
|
bool missed = false;
|
|
float32_t missx = 0.0f;
|
|
float32_t missy = 0.0f;
|
|
int i = 0;
|
|
int q = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
|
|
CATCH(errctx, akgl_collision_shape_box(&small, &small_body, 0.0f));
|
|
CATCH(errctx, akgl_collision_shape_box(&big, &big_body, 0.0f));
|
|
|
|
for ( i = 0; i < spotcount; i++ ) {
|
|
CATCH(errctx, spawn(&world, &small, spots[i][0], spots[i][1], &proxies[i]));
|
|
}
|
|
// One deliberately large enough to spill onto the oversized chain, which
|
|
// is a separate code path a query must not forget to walk.
|
|
CATCH(errctx, spawn(&world, &big, 200.0f, 200.0f, &proxies[spotcount]));
|
|
|
|
/*
|
|
* Sweep a query window across the world and compare against a linear
|
|
* scan every time. Recorded into a flag and asserted after the loops: a
|
|
* TEST_ASSERT in here reports by breaking, which would leave the loop
|
|
* rather than the ATTEMPT block and could not fail the test.
|
|
*/
|
|
for ( q = 0; q < 40; q++ ) {
|
|
area.x = (float32_t)((q * 13) % 300) - 50.0f;
|
|
area.y = (float32_t)((q * 7) % 300) - 50.0f;
|
|
area.w = 24.0f;
|
|
area.h = 24.0f;
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
if ( world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec) != NULL ) {
|
|
missed = true;
|
|
break;
|
|
}
|
|
|
|
for ( i = 0; i <= spotcount; i++ ) {
|
|
if ( !SDL_HasRectIntersectionFloat(&proxies[i]->bounds, &area) ) {
|
|
continue;
|
|
}
|
|
if ( !sawit(&rec, (int16_t)(proxies[i] - &akgl_heap_collision_proxies[0])) ) {
|
|
missed = true;
|
|
missx = area.x;
|
|
missy = area.y;
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_ASSERT(errctx, (missed == false),
|
|
"%s missed a proxy that overlaps the query at (%f, %f); a broad phase may "
|
|
"over-report and may never under-report", name, missx, missy);
|
|
|
|
// A query is allowed to report a proxy more than the once, but must not:
|
|
// a proxy spanning four cells appears on four chains.
|
|
area.x = 0.0f;
|
|
area.y = 0.0f;
|
|
area.w = 300.0f;
|
|
area.h = 300.0f;
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count <= (spotcount + 1)),
|
|
"%s reported %d proxies where at most %d exist; a proxy spanning several "
|
|
"cells is being reported once per cell", name, rec.count, spotcount + 1);
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The layer mask filters, and filters in the right direction.
|
|
*/
|
|
akerr_ErrorContext *test_query_respects_mask(char *name)
|
|
{
|
|
akgl_CollisionWorld world;
|
|
akgl_CollisionShape actor;
|
|
akgl_CollisionShape scenery;
|
|
akgl_CollisionProxy *a = NULL;
|
|
akgl_CollisionProxy *b = NULL;
|
|
visit_record rec;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
SDL_FRect area = { .x = -50.0f, .y = -50.0f, .w = 200.0f, .h = 200.0f };
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
|
|
CATCH(errctx, akgl_collision_shape_box(&actor, &body, 0.0f));
|
|
CATCH(errctx, akgl_collision_shape_box(&scenery, &body, 0.0f));
|
|
scenery.layermask = AKGL_COLLISION_LAYER_STATIC;
|
|
|
|
CATCH(errctx, spawn(&world, &actor, 0.0f, 0.0f, &a));
|
|
CATCH(errctx, spawn(&world, &scenery, 32.0f, 0.0f, &b));
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_STATIC,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 1), "%s reported %d proxies on the static layer, expected 1",
|
|
name, rec.count);
|
|
TEST_ASSERT(errctx, sawit(&rec, (int16_t)(b - &akgl_heap_collision_proxies[0])),
|
|
"%s filtered out the static proxy and kept the actor", name);
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_NONE,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 0), "%s reported %d proxies for an empty mask", name, rec.count);
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Moving a proxy has to update the index, and moving it nowhere has not to.
|
|
*
|
|
* The first half is the correctness claim: a stale entry in the old cell is the
|
|
* classic incremental-structure bug, and it shows up as a collision with where
|
|
* something used to be. The second half is the *performance* claim the whole
|
|
* structure was chosen for, and it is asserted rather than assumed.
|
|
*/
|
|
akerr_ErrorContext *test_move_is_incremental(char *name)
|
|
{
|
|
akgl_CollisionWorld world;
|
|
akgl_CollisionShape shape;
|
|
akgl_CollisionProxy *proxy = NULL;
|
|
visit_record rec;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 8.0f, .h = 8.0f };
|
|
SDL_FRect here = { .x = 0.0f, .y = 0.0f, .w = 8.0f, .h = 8.0f };
|
|
SDL_FRect there = { .x = 200.0f, .y = 200.0f, .w = 8.0f, .h = 8.0f };
|
|
int16_t firstbefore = 0;
|
|
int before = 0;
|
|
int i = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
|
|
|
|
// Move it a long way and re-query both ends.
|
|
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 200.0f, 200.0f, 0.0f));
|
|
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &here, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 0),
|
|
"%s still reports a proxy where it used to be; the old cell entry was not "
|
|
"removed, and a game would collide with a ghost", name);
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &there, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 1), "%s does not report the proxy where it now is", name);
|
|
|
|
/*
|
|
* A move within the cells already occupied must touch nothing. That is
|
|
* the property the uniform grid was chosen for over a tree, so it is
|
|
* checked rather than trusted: the proxy's chain head is the cheapest
|
|
* observable proof that no entry was released and re-claimed.
|
|
*/
|
|
/*
|
|
* Only the grid claims to be incremental, and this is where that claim
|
|
* is checked rather than believed. The BSP is exempt by construction: it
|
|
* marks itself stale on any move and rebuilds, which is exactly the cost
|
|
* the grid was chosen to avoid and exactly why it is not the default.
|
|
*/
|
|
if ( strcmp(name, "grid") == 0 ) {
|
|
firstbefore = proxy->first;
|
|
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 201.0f, 201.0f, 0.0f));
|
|
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
|
|
TEST_ASSERT(errctx, (proxy->first == firstbefore),
|
|
"%s re-celled a proxy that did not leave its cells; the structure is "
|
|
"not incremental and the argument for choosing it does not hold", name);
|
|
}
|
|
|
|
/*
|
|
* Moving repeatedly must not accumulate index entries. A move that links
|
|
* into the new cells without unlinking the old ones answers *queries*
|
|
* correctly -- the bounds re-check filters the stale entries out -- so
|
|
* nothing above catches it. What it does instead is leak the cell pool
|
|
* until the grid stops working, on a timescale no unit test reaches by
|
|
* accident. Counting the pool is the only thing that sees it.
|
|
*/
|
|
before = live_cells();
|
|
for ( i = 0; i < 200; i++ ) {
|
|
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape,
|
|
(float32_t)((i * 37) % 400),
|
|
(float32_t)((i * 53) % 400), 0.0f));
|
|
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
|
|
}
|
|
TEST_ASSERT(errctx, (live_cells() <= before),
|
|
"%s holds %d cell entries for one proxy after 200 moves, up from %d; the "
|
|
"old entries are not being unlinked and the pool is leaking",
|
|
name, live_cells(), before);
|
|
|
|
// Removing is idempotent, because a caller tearing down should not have
|
|
// to remember what it already dropped.
|
|
CATCH(errctx, world.partitioner.remove(&world.partitioner, proxy));
|
|
CATCH(errctx, world.partitioner.remove(&world.partitioner, proxy));
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &there, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 0), "%s still reports a removed proxy", name);
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/** @brief What each_pair accumulates. */
|
|
typedef struct {
|
|
int count;
|
|
int selfpairs;
|
|
} pair_record;
|
|
|
|
static akerr_ErrorContext *record_pair(akgl_CollisionProxy *a, akgl_CollisionProxy *b, void *data)
|
|
{
|
|
pair_record *rec = (pair_record *)data;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, a, AKERR_NULLPOINTER, "NULL first proxy in a pair");
|
|
FAIL_ZERO_RETURN(errctx, b, AKERR_NULLPOINTER, "NULL second proxy in a pair");
|
|
FAIL_ZERO_RETURN(errctx, rec, AKERR_NULLPOINTER, "NULL record in a pair");
|
|
|
|
if ( a == b ) {
|
|
rec->selfpairs += 1;
|
|
}
|
|
rec->count += 1;
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Every overlapping pair is reported, and none is reported twice.
|
|
*
|
|
* Two proxies sharing four cells is the case that produces four reports from a
|
|
* naive implementation, and four reports means four impulses for one contact.
|
|
*/
|
|
akerr_ErrorContext *test_each_pair_is_once(char *name)
|
|
{
|
|
akgl_CollisionWorld world;
|
|
akgl_CollisionShape shape;
|
|
akgl_CollisionProxy *proxies[4];
|
|
pair_record rec;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 40.0f };
|
|
int i = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
|
|
// 40 pixels on a 16 pixel grid: each of these covers nine cells, so every
|
|
// pair among them shares several.
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
|
|
for ( i = 0; i < 4; i++ ) {
|
|
CATCH(errctx, spawn(&world, &shape, (float32_t)(i * 4), 0.0f, &proxies[i]));
|
|
}
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.each_pair(&world.partitioner, &record_pair, &rec));
|
|
|
|
// Four mutually overlapping proxies are six distinct pairs.
|
|
TEST_ASSERT(errctx, (rec.selfpairs == 0), "%s paired a proxy with itself %d times",
|
|
name, rec.selfpairs);
|
|
TEST_ASSERT(errctx, (rec.count == 6),
|
|
"%s reported %d pairs among four mutually overlapping proxies, expected 6; "
|
|
"duplicates mean one contact resolved several times", name, rec.count);
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/** @brief An empty world answers nothing, and reset really empties it. */
|
|
akerr_ErrorContext *test_reset_and_empty(char *name)
|
|
{
|
|
akgl_CollisionWorld world;
|
|
akgl_CollisionShape shape;
|
|
akgl_CollisionProxy *proxy = NULL;
|
|
visit_record rec;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
SDL_FRect area = { .x = -100.0f, .y = -100.0f, .w = 400.0f, .h = 400.0f };
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
|
|
|
|
// The null hypothesis. A broken structure that reports nothing passes
|
|
// every other test in this file, so this one is not a formality.
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 0), "%s reported %d proxies in an empty world",
|
|
name, rec.count);
|
|
|
|
// Build the shape before spawning from it, not after. Spawning first
|
|
// initializes the proxy from an uninitialized stack struct -- the sync
|
|
// below overwrites it, so the test still passed, and valgrind still
|
|
// reported eighteen uninitialised reads inside grid_cellrect.
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
|
|
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 0.0f, 0.0f, 0.0f));
|
|
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
|
|
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 1), "%s lost the only proxy in the world", name);
|
|
|
|
CATCH(errctx, world.partitioner.reset(&world.partitioner, &world));
|
|
memset(&rec, 0x00, sizeof(rec));
|
|
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
|
|
&record_visit, &rec));
|
|
TEST_ASSERT(errctx, (rec.count == 0), "%s still reports %d proxies after a reset",
|
|
name, rec.count);
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
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;
|
|
akgl_Partitioner part;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_collision_world_init(&world, NULL, 16.0f, 16.0f));
|
|
TEST_ASSERT(errctx, (strcmp(world.partitioner.name, "grid") == 0),
|
|
"a NULL partitioner name installed \"%s\" rather than the default",
|
|
world.partitioner.name);
|
|
TEST_ASSERT(errctx, ((world.flags & AKGL_COLLISION_TEST_PLANAR) != 0),
|
|
"a new world does not force planar normals; every game today is 2D");
|
|
|
|
CATCH(errctx, akgl_partitioner_factory(&part, "grid"));
|
|
TEST_ASSERT(errctx, (part.query != NULL), "the grid installed no query");
|
|
TEST_ASSERT(errctx, (part.move != NULL), "the grid installed no move");
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_partitioner_factory(&part, "quadtree"),
|
|
"a partitioner nobody wrote");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_partitioner_factory(NULL, "grid"),
|
|
"the factory with no destination");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_world_init(&world, NULL, 0.0f, 16.0f),
|
|
"a world with no cell width");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_world_init(&world, NULL, 16.0f, -1.0f),
|
|
"a world with negative cell height");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_world_init(NULL, NULL, 16.0f, 16.0f),
|
|
"a world with no destination");
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
akerr_ErrorContext *inner = NULL;
|
|
int i = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_error_init());
|
|
CATCH(errctx, test_world_and_factory());
|
|
|
|
/*
|
|
* Every partitioner is held to the same assertions. Collected rather
|
|
* than CATCHed inside the loop, because CATCH reports by breaking and a
|
|
* break here would leave the loop rather than the ATTEMPT block, so a
|
|
* failing partitioner would be silently skipped.
|
|
*/
|
|
for ( i = 0; i < (int)(sizeof(partitioners) / sizeof(partitioners[0])); i++ ) {
|
|
inner = test_query_never_misses(partitioners[i]);
|
|
if ( inner != NULL ) { break; }
|
|
inner = test_query_respects_mask(partitioners[i]);
|
|
if ( inner != NULL ) { break; }
|
|
inner = test_move_is_incremental(partitioners[i]);
|
|
if ( inner != NULL ) { break; }
|
|
inner = test_each_pair_is_once(partitioners[i]);
|
|
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 {
|
|
} PROCESS(errctx) {
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
return 0;
|
|
}
|