A proxy is what the broad phase will index: an actor's shape, where it is, and the bounds that follow from those. It is a sixth heap layer, two per actor -- one for the actor itself and headroom for static geometry a game registers that is not tile-aligned. Solid *tiles* deliberately get none: the broad phase will read those out of the tilemap's own array, because one proxy per tile is tens of megabytes to index data that is already a grid. The pool follows the existing convention rather than improving on it. akgl_heap_next_collision_proxy finds a free slot and does not claim it; akgl_collision_proxy_initialize claims it. That is TODO.md item 8's asymmetry, and it is kept on purpose: an acquire abandoned before initialization leaks nothing, because the slot still reads as free, and a sixth pool with its own rule would be worse than one defect with five instances. Fixing item 8 means fixing all five together with every call site audited, and that is its own change. The proxy holds a *copy* of the shape rather than a pointer. An actor's own logic may rewrite its shape mid-step -- a character crouching, a projectile arming -- and a broad phase whose bounds came from a shape that has since changed is a bug that only appears when two things happen in the same frame. akgl_heap_release_actor now releases the actor's proxy. Without it the proxy holds a borrowed `owner` into a slot that has just been zeroed, so the broad phase keeps a registration whose owner reads as a free actor, and the next contact against it is a collision with nothing. Verified by removing the release and watching test_proxy_dies_with_its_actor go red. The tests pin the convention as well as the behaviour: that two acquires with no initialize between them return the same slot is asserted, not merely tolerated, so that changing the convention has to change a test that says why it exists. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
469 lines
18 KiB
C
469 lines
18 KiB
C
/**
|
|
* @file collision.c
|
|
* @brief Collision shapes: construction, defaults, and the extrusion invariant.
|
|
*
|
|
* The invariant is the one worth staring at. A 2D shape handed to a 3D
|
|
* narrowphase has to be thick enough that separating two of them along z is
|
|
* never cheaper than separating them in the plane -- otherwise the narrowphase
|
|
* answers "push it into the screen", the resolver does, and the actor sinks
|
|
* through the floor while every test that only asks "did they collide" passes.
|
|
* test_shape_extrusion_beats_planar_penetration checks the inequality directly
|
|
* rather than checking that the number is 2.
|
|
*/
|
|
|
|
#include <string.h>
|
|
|
|
#include <akerror.h>
|
|
|
|
#include <akgl/actor.h>
|
|
#include <akgl/collision.h>
|
|
#include <akgl/error.h>
|
|
#include <akgl/heap.h>
|
|
#include <akgl/registry.h>
|
|
|
|
#include "testutil.h"
|
|
|
|
akerr_ErrorContext *test_shape_box(void)
|
|
{
|
|
akgl_CollisionShape shape;
|
|
SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
|
|
TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_BOX), "a box is not a box");
|
|
// Top-left-and-size in, centre-and-half-extent out.
|
|
TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "half width is %f, expected 8", shape.hx);
|
|
TEST_ASSERT_FEQ(errctx, shape.hy, 16.0f, "half height is %f, expected 16", shape.hy);
|
|
TEST_ASSERT_FEQ(errctx, shape.ox, 16.0f, "centre x is %f, expected 16", shape.ox);
|
|
TEST_ASSERT_FEQ(errctx, shape.oy, 16.0f, "centre y is %f, expected 16", shape.oy);
|
|
|
|
// The defaults exist so that giving an actor a hitbox does not silently
|
|
// make it shove every other actor on the map.
|
|
TEST_ASSERT(errctx, (shape.layermask == AKGL_COLLISION_LAYER_ACTOR),
|
|
"a new shape is not on the actor layer");
|
|
TEST_ASSERT(errctx, (shape.collidemask == AKGL_COLLISION_LAYER_STATIC),
|
|
"a new shape responds to something other than map geometry");
|
|
TEST_ASSERT(errctx, (shape.flags == AKGL_COLLISION_FLAG_NONE),
|
|
"a new shape carries flags nobody set");
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(NULL, &body, 0.0f),
|
|
"a box with no destination");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_shape_box(&shape, NULL, 0.0f),
|
|
"a box with no rectangle");
|
|
|
|
body.w = 0.0f;
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
|
|
"a box with zero width");
|
|
body.w = -4.0f;
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
|
|
"a box with negative width");
|
|
body.w = 16.0f;
|
|
body.h = 0.0f;
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, 0.0f),
|
|
"a box with zero height");
|
|
body.h = 32.0f;
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_shape_box(&shape, &body, -1.0f),
|
|
"a box with negative depth");
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *test_shape_circle_and_capsule(void)
|
|
{
|
|
akgl_CollisionShape shape;
|
|
SDL_FRect wide = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 10.0f };
|
|
SDL_FRect tall = { .x = 0.0f, .y = 0.0f, .w = 10.0f, .h = 40.0f };
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_collision_shape_circle(&shape, 16.0f, 16.0f, 8.0f, 0.0f));
|
|
TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CIRCLE), "a circle is not a circle");
|
|
TEST_ASSERT_FEQ(errctx, shape.hx, 8.0f, "circle radius is %f, expected 8", shape.hx);
|
|
TEST_ASSERT_FEQ(errctx, shape.hy, 8.0f, "a circle's hy should mirror its radius, got %f", shape.hy);
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
|
|
akgl_collision_shape_circle(&shape, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
"a circle of zero radius");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_shape_circle(NULL, 0.0f, 0.0f, 8.0f, 0.0f),
|
|
"a circle with no destination");
|
|
|
|
CATCH(errctx, akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f));
|
|
TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_X), "an x capsule is not one");
|
|
CATCH(errctx, akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f));
|
|
TEST_ASSERT(errctx, (shape.kind == AKGL_COLLISION_SHAPE_CAPSULE_Y), "a y capsule is not one");
|
|
|
|
/*
|
|
* A capsule capped on its *short* axis is a circle described
|
|
* confusingly. Refusing it is the difference between a caller getting an
|
|
* error and a caller getting a shape that is not the one they drew.
|
|
*/
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
|
|
akgl_collision_shape_capsule(&shape, &tall, AKGL_COLLISION_SHAPE_CAPSULE_X, 0.0f),
|
|
"a tall rectangle capped on x");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
|
|
akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_CAPSULE_Y, 0.0f),
|
|
"a wide rectangle capped on y");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_VALUE,
|
|
akgl_collision_shape_capsule(&shape, &wide, AKGL_COLLISION_SHAPE_BOX, 0.0f),
|
|
"a capsule on an axis that is not an axis");
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The inequality that keeps a 2D game flat, checked rather than assumed.
|
|
*
|
|
* For any two shapes the setters produce, the overlap along z when they are
|
|
* concentric must exceed the largest penetration they can reach in the plane.
|
|
* If it does not, the narrowphase is entitled to answer "separate along z", and
|
|
* a resolver acting on that pushes the actor into the screen -- invisible, and
|
|
* fatal, because the actor is still inside the floor.
|
|
*
|
|
* This tests the property over a spread of sizes rather than testing that the
|
|
* ratio constant equals 2, so that changing the constant to something that does
|
|
* not work fails here instead of failing in a game.
|
|
*/
|
|
akerr_ErrorContext *test_shape_extrusion_beats_planar_penetration(void)
|
|
{
|
|
akgl_CollisionShape a;
|
|
akgl_CollisionShape b;
|
|
SDL_FRect body;
|
|
float32_t sizes[] = { 1.0f, 4.0f, 16.0f, 32.0f, 512.0f };
|
|
float32_t zoverlap = 0.0f;
|
|
float32_t planarmax = 0.0f;
|
|
float32_t worstz = 0.0f;
|
|
float32_t worstplanar = 0.0f;
|
|
float32_t worsta = 0.0f;
|
|
float32_t worstb = 0.0f;
|
|
bool violated = false;
|
|
int i = 0;
|
|
int j = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
/*
|
|
* The check is recorded into a flag and asserted after the loops rather
|
|
* than asserted inside them. TEST_ASSERT expands to FAIL_BREAK, which
|
|
* reports by `break`ing, and a `break` inside a `for` leaves the loop
|
|
* rather than the ATTEMPT block -- so an assertion written in here
|
|
* cannot fail the test. AGENTS.md documents that hazard for CATCH; it
|
|
* applies to every macro in the family, and this test was written with
|
|
* the bug before it was written without it.
|
|
*/
|
|
for ( i = 0; i < (int)(sizeof(sizes) / sizeof(sizes[0])); i++ ) {
|
|
for ( j = 0; j < (int)(sizeof(sizes) / sizeof(sizes[0])); j++ ) {
|
|
body.x = 0.0f;
|
|
body.y = 0.0f;
|
|
body.w = sizes[i];
|
|
body.h = sizes[i];
|
|
if ( akgl_collision_shape_box(&a, &body, 0.0f) != NULL ) {
|
|
violated = true;
|
|
break;
|
|
}
|
|
body.w = sizes[j];
|
|
body.h = sizes[j];
|
|
if ( akgl_collision_shape_box(&b, &body, 0.0f) != NULL ) {
|
|
violated = true;
|
|
break;
|
|
}
|
|
|
|
// Concentric is the worst case: every axis is fully overlapped.
|
|
zoverlap = 2.0f * ((a.hz < b.hz) ? a.hz : b.hz);
|
|
planarmax = 2.0f * ((a.hx < b.hx) ? a.hx : b.hx);
|
|
if ( zoverlap <= planarmax ) {
|
|
violated = true;
|
|
worstz = zoverlap;
|
|
worstplanar = planarmax;
|
|
worsta = sizes[i];
|
|
worstb = sizes[j];
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_ASSERT(errctx, (violated == false),
|
|
"a %fx%f box against a %fx%f one overlaps %f on z and up to %f in the "
|
|
"plane; z is the cheaper separation, so the narrowphase will answer "
|
|
"\"push it into the screen\" and the actor will sink through the floor",
|
|
worsta, worsta, worstb, worstb, worstz, worstplanar);
|
|
|
|
// A depth given by hand is taken at face value. A caller who does that
|
|
// has opted out of the guarantee above, which is why the setters choose
|
|
// for you unless you say otherwise.
|
|
body.w = 16.0f;
|
|
body.h = 16.0f;
|
|
CATCH(errctx, akgl_collision_shape_box(&a, &body, 3.0f));
|
|
TEST_ASSERT_FEQ(errctx, a.hz, 3.0f, "an explicit depth of 3 became %f", a.hz);
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext *test_shape_bounds(void)
|
|
{
|
|
akgl_CollisionShape shape;
|
|
SDL_FRect body = { .x = 8.0f, .y = 4.0f, .w = 16.0f, .h = 32.0f };
|
|
SDL_FRect bounds;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
|
|
// At the origin the bounds are the rectangle it was built from. That
|
|
// round trip is the whole contract, and it is easy to get wrong by half
|
|
// an extent in either direction.
|
|
CATCH(errctx, akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, &bounds));
|
|
TEST_ASSERT_FEQ(errctx, bounds.x, 8.0f, "bounds x is %f, expected 8", bounds.x);
|
|
TEST_ASSERT_FEQ(errctx, bounds.y, 4.0f, "bounds y is %f, expected 4", bounds.y);
|
|
TEST_ASSERT_FEQ(errctx, bounds.w, 16.0f, "bounds w is %f, expected 16", bounds.w);
|
|
TEST_ASSERT_FEQ(errctx, bounds.h, 32.0f, "bounds h is %f, expected 32", bounds.h);
|
|
|
|
// And they translate with the owner rather than staying put.
|
|
CATCH(errctx, akgl_collision_shape_bounds(&shape, 100.0f, -50.0f, &bounds));
|
|
TEST_ASSERT_FEQ(errctx, bounds.x, 108.0f, "bounds x is %f, expected 108", bounds.x);
|
|
TEST_ASSERT_FEQ(errctx, bounds.y, -46.0f, "bounds y is %f, expected -46", bounds.y);
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_shape_bounds(NULL, 0.0f, 0.0f, &bounds),
|
|
"bounds of no shape");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_shape_bounds(&shape, 0.0f, 0.0f, NULL),
|
|
"bounds with no destination");
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The mask test, and the asymmetry that is the reason it exists.
|
|
*/
|
|
akerr_ErrorContext *test_shape_interacts(void)
|
|
{
|
|
akgl_CollisionShape player;
|
|
akgl_CollisionShape npc;
|
|
akgl_CollisionShape none;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
bool interacts = false;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_collision_shape_box(&player, &body, 0.0f));
|
|
CATCH(errctx, akgl_collision_shape_box(&npc, &body, 0.0f));
|
|
memset(&none, 0x00, sizeof(akgl_CollisionShape));
|
|
|
|
// Out of the box, two actors ignore each other. This is the default that
|
|
// keeps a town full of NPCs from shoving itself apart.
|
|
CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
|
|
TEST_ASSERT(errctx, (interacts == false),
|
|
"two default shapes collide with each other; the default masks are wrong");
|
|
|
|
// The player opts in to blocking against actors. The NPC does not opt in
|
|
// to blocking against the player, and must not be dragged along by it --
|
|
// that asymmetry is the entire point of two masks instead of one.
|
|
player.collidemask |= AKGL_COLLISION_LAYER_ACTOR;
|
|
CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
|
|
TEST_ASSERT(errctx, (interacts == true), "the player does not respond to an actor it opted in to");
|
|
CATCH(errctx, akgl_collision_shape_interacts(&npc, &player, &interacts));
|
|
TEST_ASSERT(errctx, (interacts == false),
|
|
"the NPC responded to the player because the player responded to it");
|
|
|
|
// A shape with no kind takes no part, in either direction.
|
|
CATCH(errctx, akgl_collision_shape_interacts(&player, &none, &interacts));
|
|
TEST_ASSERT(errctx, (interacts == false), "a shape of no kind was collided with");
|
|
CATCH(errctx, akgl_collision_shape_interacts(&none, &player, &interacts));
|
|
TEST_ASSERT(errctx, (interacts == false), "a shape of no kind collided with something");
|
|
|
|
// And neither does a disabled one, which is the cheap way to switch an
|
|
// actor out without losing its shape.
|
|
npc.layermask = AKGL_COLLISION_LAYER_ACTOR;
|
|
npc.flags |= AKGL_COLLISION_FLAG_DISABLED;
|
|
CATCH(errctx, akgl_collision_shape_interacts(&player, &npc, &interacts));
|
|
TEST_ASSERT(errctx, (interacts == false), "a disabled shape was collided with");
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_shape_interacts(NULL, &npc, &interacts),
|
|
"interacts with no self");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_shape_interacts(&player, NULL, &interacts),
|
|
"interacts with no other");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_shape_interacts(&player, &npc, NULL),
|
|
"interacts with no destination");
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief The pool convention, and the window it deliberately leaves open.
|
|
*/
|
|
akerr_ErrorContext *test_proxy_pool(void)
|
|
{
|
|
akgl_CollisionProxy *first = NULL;
|
|
akgl_CollisionProxy *second = NULL;
|
|
akgl_CollisionShape shape;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
akerr_ErrorContext *inner = NULL;
|
|
int claimed = 0;
|
|
int i = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
|
|
/*
|
|
* Acquiring does not claim the slot, and that is the convention rather
|
|
* than an oversight: an acquire abandoned before initialization leaks
|
|
* nothing, because the slot still reads as free. The cost is this window,
|
|
* where two acquires in a row return the same pointer -- which is why the
|
|
* library writes the acquire and the initialize adjacent.
|
|
*/
|
|
CATCH(errctx, akgl_heap_next_collision_proxy(&first));
|
|
TEST_ASSERT(errctx, (first->refcount == 0),
|
|
"acquiring a proxy claimed the slot; that is not this pool's convention");
|
|
CATCH(errctx, akgl_heap_next_collision_proxy(&second));
|
|
TEST_ASSERT(errctx, (first == second),
|
|
"two acquires with no initialize between them returned different slots");
|
|
|
|
// Initializing is what closes it.
|
|
CATCH(errctx, akgl_collision_proxy_initialize(first, NULL, &shape, 10.0f, 20.0f, 0.0f));
|
|
TEST_ASSERT(errctx, (first->refcount == 1), "initializing did not take the reference");
|
|
CATCH(errctx, akgl_heap_next_collision_proxy(&second));
|
|
TEST_ASSERT(errctx, (first != second), "an initialized slot was handed out again");
|
|
|
|
// The proxy carries a copy of the shape, not a pointer to it, so a later
|
|
// edit to the caller's shape cannot change what the broad phase indexed.
|
|
shape.hx = 999.0f;
|
|
TEST_ASSERT_FEQ(errctx, first->shape.hx, 8.0f,
|
|
"the proxy tracked an edit to the caller's shape; it holds %f", first->shape.hx);
|
|
// Body {0,0,16,16} centres at (8,8) with half-extents 8, so at owner x=10
|
|
// the bounds start at 10 and are 16 wide.
|
|
TEST_ASSERT_FEQ(errctx, first->bounds.x, 10.0f,
|
|
"proxy bounds x is %f, expected 10", first->bounds.x);
|
|
TEST_ASSERT_FEQ(errctx, first->bounds.y, 20.0f,
|
|
"proxy bounds y is %f, expected 20", first->bounds.y);
|
|
TEST_ASSERT_FEQ(errctx, first->bounds.w, 16.0f,
|
|
"proxy bounds w is %f, expected 16", first->bounds.w);
|
|
|
|
CATCH(errctx, akgl_heap_release_collision_proxy(first));
|
|
TEST_ASSERT(errctx, (first->refcount == 0), "releasing did not free the slot");
|
|
|
|
// Exhaustion is an ordinary reported error, not a crash and not a silent
|
|
// hand-out of a slot somebody else is using.
|
|
CATCH(errctx, akgl_heap_init());
|
|
for ( i = 0; i < (AKGL_MAX_HEAP_COLLISION_PROXY + 4); i++ ) {
|
|
inner = akgl_heap_next_collision_proxy(&first);
|
|
if ( inner != NULL ) {
|
|
break;
|
|
}
|
|
inner = akgl_collision_proxy_initialize(first, NULL, &shape, 0.0f, 0.0f, 0.0f);
|
|
if ( inner != NULL ) {
|
|
break;
|
|
}
|
|
claimed += 1;
|
|
}
|
|
TEST_ASSERT(errctx, (inner != NULL), "the proxy pool never ran out");
|
|
TEST_EXPECT_STATUS(errctx, AKGL_ERR_HEAP, inner, "exhausting the proxy pool");
|
|
inner = NULL;
|
|
TEST_ASSERT(errctx, (claimed == AKGL_MAX_HEAP_COLLISION_PROXY),
|
|
"the pool handed out %d of %d slots before refusing",
|
|
claimed, AKGL_MAX_HEAP_COLLISION_PROXY);
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_heap_next_collision_proxy(NULL),
|
|
"acquiring into no destination");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_heap_release_collision_proxy(NULL),
|
|
"releasing no proxy");
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
akgl_collision_proxy_initialize(NULL, NULL, &shape, 0.0f, 0.0f, 0.0f),
|
|
"initializing no proxy");
|
|
} CLEANUP {
|
|
if ( inner != NULL ) {
|
|
inner->handled = true;
|
|
inner = akerr_release_error(inner);
|
|
}
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A proxy must not outlive the actor it points at.
|
|
*
|
|
* akgl_heap_release_actor zeroes the actor slot. A proxy left registered would
|
|
* hold a borrowed `owner` into a slot that now reads as free, and the next
|
|
* contact against it would be a collision with nothing -- so the release has to
|
|
* reach through and take the proxy with it.
|
|
*/
|
|
akerr_ErrorContext *test_proxy_dies_with_its_actor(void)
|
|
{
|
|
akgl_Actor *actor = NULL;
|
|
akgl_CollisionProxy *proxy = NULL;
|
|
akgl_CollisionShape shape;
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_heap_init());
|
|
CATCH(errctx, akgl_registry_init_actor());
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
|
|
CATCH(errctx, akgl_heap_next_actor(&actor));
|
|
CATCH(errctx, akgl_actor_initialize(actor, "collider"));
|
|
CATCH(errctx, akgl_heap_next_collision_proxy(&proxy));
|
|
CATCH(errctx, akgl_collision_proxy_initialize(proxy, actor, &shape, 0.0f, 0.0f, 0.0f));
|
|
actor->proxy = proxy;
|
|
|
|
TEST_ASSERT(errctx, (proxy->refcount == 1), "the proxy was not claimed");
|
|
CATCH(errctx, akgl_heap_release_actor(actor));
|
|
TEST_ASSERT(errctx, (proxy->refcount == 0),
|
|
"releasing the actor left its proxy registered, pointing at a freed slot");
|
|
TEST_ASSERT(errctx, (proxy->owner == NULL), "the released proxy still names an owner");
|
|
|
|
// An actor with no proxy releases without complaint, which is every
|
|
// actor a game written before this existed.
|
|
CATCH(errctx, akgl_heap_next_actor(&actor));
|
|
CATCH(errctx, akgl_actor_initialize(actor, "plain"));
|
|
CATCH(errctx, akgl_heap_release_actor(actor));
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
|
|
ATTEMPT {
|
|
CATCH(errctx, akgl_error_init());
|
|
CATCH(errctx, test_shape_box());
|
|
CATCH(errctx, test_shape_circle_and_capsule());
|
|
CATCH(errctx, test_shape_extrusion_beats_planar_penetration());
|
|
CATCH(errctx, test_shape_bounds());
|
|
CATCH(errctx, test_shape_interacts());
|
|
CATCH(errctx, test_proxy_pool());
|
|
CATCH(errctx, test_proxy_dies_with_its_actor());
|
|
} CLEANUP {
|
|
} PROCESS(errctx) {
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
return 0;
|
|
}
|