325 lines
13 KiB
C
325 lines
13 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/collision.h>
|
||
|
|
#include <akgl/error.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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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());
|
||
|
|
} CLEANUP {
|
||
|
|
} PROCESS(errctx) {
|
||
|
|
} FINISH_NORETURN(errctx);
|
||
|
|
|
||
|
|
return 0;
|
||
|
|
}
|