A binary space partition on libakstdlib's tree links and lists, registered in the factory as "bsp". It runs the same assertions the grid does, because tests/partition.c is a table over partitioners and adding a row is all it takes to be held to the contract. **Use the grid.** This is here to prove the vtable works and to have something to measure the grid against, and the file says so at the top. It rebuilds whenever the proxy set changes, which is the shape PERFORMANCE.md records Phaser using and capping out around five thousand bodies. It would earn its place in a world with wildly non-uniform object sizes, or one larger than the grid's fixed cell array covers. The difference is visible in the code rather than buried in a benchmark: the grid's `move` compares four integers and returns when a proxy has not left its cells, and this one marks the whole partition stale. The incremental-move assertion in the suite is therefore grid-only, and says why. aksl_tree_iterate is not used, and the file carries the three reasons so the next reader does not rediscover them: it is a complete traversal whose only control signal stops the entire walk, so there is no way to prune a subtree -- which is the only operation a spatial query is made of; it carries no per-node context, and a pruning descent needs each node's bounds and plane; and its breadth-first modes allocate, while only the depth-first ones are malloc-free and those are the ones without pruning. aksl_tree_insert is unusable for a different reason again: it is a comparator-ordered BST, and a spatial insert has to compare a leaf against a plane, which aksl_TreeCompareFunc cannot express. What is used is the link structure and the lists, neither of which allocates, which is why they fit here at all. The descent is an explicit stack rather than recursion, so the depth bound is an array bound the compiler can see -- this library already has one documented way to blow the C stack and does not need a second. Split planes are the median of the item centres on the longer axis, not the spatial midpoint: actors in a tile game cluster on the floor, and a midpoint split gives one empty child and one full one for several levels running. A split that separates nothing degrades the node to a leaf rather than recursing forever on the same set. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
700 lines
26 KiB
C
700 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);
|
|
|
|
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
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;
|
|
}
|