Add collision shapes, on the character and overridable per actor

A shape is a convex volume positioned relative to an actor's origin. It lives on
akgl_Character, so every goblin sharing a character shares one shape definition
the way they already share speeds and the state-to-sprite map, and an actor may
override it with akgl_Actor::shape_override.

The flag is not redundant with an empty shape. "This actor deliberately has no
collider" and "this actor has not been given one yet" are different states, and
without somewhere to record the difference akgl_actor_set_character cannot tell
whether it is allowed to overwrite what it finds.

include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete
struct pointers rather than including their headers, because both of those need
akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite
compiles it as the first include of a translation unit, so that property is
enforced rather than merely intended -- which is why the tilemap types got struct
tags two commits ago.

The masks are asymmetric and the defaults are the load-bearing part: layermask
ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with
map geometry and with no other actor. "Everything with a hitbox shoves everything
else" would be a surprising default for a town full of scenery and a painful one
to discover after the fact; opting in to actor-versus-actor is one bit, opting
out of it would have been a hunt through every NPC a game spawns.

Every shape gets a z depth, because the narrowphase behind this is three
dimensional and answers with the *minimum* separating translation. A thin
extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a
player can see while remaining inside the floor -- a collision system reporting
success while doing nothing. The ratio of 2 is the smallest for which the z
overlap of any two shapes the setters build provably exceeds any planar
penetration they can reach.

The test for that asserts the inequality across a spread of sizes rather than
asserting the constant is 2, so a change to the constant fails here rather than
in a game.

Two things about that test are worth recording, because the first version had
both:

- It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by
  `break`ing, and the assertion was inside a nested `for` -- so the break left
  the loop rather than the ATTEMPT block and the failure evaporated. This is the
  hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified
  by setting the ratio to 0.5 and watching the suite still pass, then fixing it
  and watching it report the 512x512 case by name.
- tests/collision_arena.c had the same bug in a loop added in the previous
  commit, and it is fixed here too.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:48:05 -04:00
parent 46232a5ad1
commit 9056ff06d6
8 changed files with 762 additions and 1 deletions

324
tests/collision.c Normal file
View File

@@ -0,0 +1,324 @@
/**
* @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;
}

View File

@@ -165,6 +165,7 @@ akerr_ErrorContext *test_arena_drives_a_real_query(void)
bool hit = false;
float32_t depth = 0.0f;
size_t highwater = 0;
akerr_ErrorContext *inner = NULL;
int i = 0;
PREPARE_ERROR(errctx);
@@ -214,10 +215,18 @@ akerr_ErrorContext *test_arena_drives_a_real_query(void)
* Each query resets the arena on entry, so a thousand of them in a row
* cost what one costs. Without that, a no-op free and a bump allocator
* would fill 64 KB in nine queries.
*
* The loop body cannot use CATCH: it reports by `break`ing, which would
* leave the loop rather than the ATTEMPT block and turn a failing query
* into a silently short run. Collect the failure and hand it over after.
*/
for ( i = 0; i < 64; i++ ) {
CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth));
inner = akgl_collision_arena_selftest(1.0f, &hit, &depth);
if ( inner != NULL ) {
break;
}
}
CATCH(errctx, inner);
TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= highwater),
"64 queries used %zu bytes where one used %zu; the arena is not being reset",
akgl_ccd_arena_used(), highwater);