/** * @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 #include #include #include #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; }