Files
libakgl/src/collision_shape.c

220 lines
8.3 KiB
C
Raw Normal View History

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>
2026-08-01 23:48:05 -04:00
/**
* @file collision_shape.c
* @brief Building collision shapes, and the one invariant that keeps 2D flat.
*
* Nothing here touches the narrowphase. A shape is data, and this file is the
* only place that decides what a well-formed one looks like -- which is why the
* extrusion rule lives in the setters rather than at the point of use, where
* every caller would have to remember it.
*/
#include <string.h>
#include <akgl/collision.h>
#include <akgl/error.h>
/**
* @brief Fill in the depth, unless the caller asked for a specific one.
*
* Called last by every setter, once `hx` and `hy` are known. See collision.h for
* why the ratio is what it is; the short version is that a thin extrusion makes
* z the cheapest axis to separate on, and an actor pushed along z goes nowhere a
* player can see while falling through the floor.
*/
static akerr_ErrorContext *shape_extrude(akgl_CollisionShape *dest, float32_t depth)
{
float32_t largest = 0.0f;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_NONZERO_RETURN(errctx, (depth < 0.0f), AKERR_VALUE,
"Shape depth %f is negative", depth);
if ( depth > 0.0f ) {
dest->hz = depth;
SUCCEED_RETURN(errctx);
}
largest = dest->hx;
if ( dest->hy > largest ) {
largest = dest->hy;
}
dest->hz = AKGL_COLLISION_DEPTH_RATIO * largest;
SUCCEED_RETURN(errctx);
}
/**
* @brief Zero a shape and give it the defaults every setter shares.
*
* The masks are the interesting part. An actor that has been given a shape and
* nothing else collides with map geometry and with no other actor, because
* "everything with a hitbox shoves everything else" is 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 added bit; opting out of it would have been a
* hunt through every NPC a game spawns.
*/
static akerr_ErrorContext *shape_defaults(akgl_CollisionShape *dest, uint8_t kind)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
memset(dest, 0x00, sizeof(akgl_CollisionShape));
dest->kind = kind;
dest->layermask = AKGL_COLLISION_LAYER_ACTOR;
dest->collidemask = AKGL_COLLISION_LAYER_STATIC;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_box(akgl_CollisionShape *dest, SDL_FRect *body, float32_t depth)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "NULL rectangle reference");
FAIL_NONZERO_RETURN(errctx, (body->w <= 0.0f), AKERR_VALUE,
"Box width %f is not positive", body->w);
FAIL_NONZERO_RETURN(errctx, (body->h <= 0.0f), AKERR_VALUE,
"Box height %f is not positive", body->h);
PASS(errctx, shape_defaults(dest, AKGL_COLLISION_SHAPE_BOX));
// Top-left-and-size in, centre-and-half-extent out. Done once here so that
// nothing on the frame path has to do it per test.
dest->hx = body->w / 2.0f;
dest->hy = body->h / 2.0f;
dest->ox = body->x + dest->hx;
dest->oy = body->y + dest->hy;
PASS(errctx, shape_extrude(dest, depth));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_circle(akgl_CollisionShape *dest, float32_t ox, float32_t oy, float32_t radius, float32_t depth)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_VALUE,
"Circle radius %f is not positive", radius);
PASS(errctx, shape_defaults(dest, AKGL_COLLISION_SHAPE_CIRCLE));
dest->ox = ox;
dest->oy = oy;
dest->hx = radius;
dest->hy = radius;
PASS(errctx, shape_extrude(dest, depth));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_capsule(akgl_CollisionShape *dest, SDL_FRect *body, uint8_t axis, float32_t depth)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "NULL rectangle reference");
FAIL_NONZERO_RETURN(
errctx,
((axis != AKGL_COLLISION_SHAPE_CAPSULE_X) && (axis != AKGL_COLLISION_SHAPE_CAPSULE_Y)),
AKERR_VALUE,
"Capsule axis %u is neither AKGL_COLLISION_SHAPE_CAPSULE_X nor _Y",
(unsigned int)axis
);
FAIL_NONZERO_RETURN(errctx, (body->w <= 0.0f), AKERR_VALUE,
"Capsule width %f is not positive", body->w);
FAIL_NONZERO_RETURN(errctx, (body->h <= 0.0f), AKERR_VALUE,
"Capsule height %f is not positive", body->h);
/*
* The capped axis has to be the longer one. A capsule whose caps are wider
* than the body is long is a circle with extra steps, and accepting it would
* mean the shape a caller described and the shape the narrowphase built were
* different -- silently, and only for some sizes.
*/
if ( axis == AKGL_COLLISION_SHAPE_CAPSULE_X ) {
FAIL_NONZERO_RETURN(errctx, (body->w <= body->h), AKERR_VALUE,
"An x-axis capsule is %fx%f; the capped axis must be the longer one",
body->w, body->h);
} else {
FAIL_NONZERO_RETURN(errctx, (body->h <= body->w), AKERR_VALUE,
"A y-axis capsule is %fx%f; the capped axis must be the longer one",
body->w, body->h);
}
PASS(errctx, shape_defaults(dest, axis));
dest->hx = body->w / 2.0f;
dest->hy = body->h / 2.0f;
dest->ox = body->x + dest->hx;
dest->oy = body->y + dest->hy;
PASS(errctx, shape_extrude(dest, depth));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_bounds(akgl_CollisionShape *shape, float32_t x, float32_t y, SDL_FRect *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL rectangle reference");
dest->x = x + shape->ox - shape->hx;
dest->y = y + shape->oy - shape->hy;
dest->w = shape->hx * 2.0f;
dest->h = shape->hy * 2.0f;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_shape_interacts(akgl_CollisionShape *self, akgl_CollisionShape *other, bool *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL shape reference");
FAIL_ZERO_RETURN(errctx, other, AKERR_NULLPOINTER, "NULL other shape reference");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference");
*dest = false;
if ( (self->kind == AKGL_COLLISION_SHAPE_NONE) ||
(other->kind == AKGL_COLLISION_SHAPE_NONE) ) {
SUCCEED_RETURN(errctx);
}
if ( ((self->flags & AKGL_COLLISION_FLAG_DISABLED) != 0) ||
((other->flags & AKGL_COLLISION_FLAG_DISABLED) != 0) ) {
SUCCEED_RETURN(errctx);
}
// One direction only. The mirrored question is asked separately, and may
// legitimately have a different answer.
*dest = ((other->layermask & self->collidemask) != 0);
SUCCEED_RETURN(errctx);
}
Pool collision proxies, and tie one to the life of its actor 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>
2026-08-01 23:56:21 -04:00
akerr_ErrorContext *akgl_collision_proxy_initialize(akgl_CollisionProxy *obj, struct akgl_Actor *owner, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL proxy reference");
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
memset(obj, 0x00, sizeof(akgl_CollisionProxy));
obj->owner = owner;
/*
* The reference is taken here and not in akgl_heap_next_collision_proxy,
* matching four of the five pools that came before. Until this runs the slot
* still reads as free -- which is also the property that keeps an acquire
* abandoned before this point from leaking the slot.
*/
obj->refcount = 1;
PASS(errctx, akgl_collision_proxy_sync(obj, shape, x, y, z));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL proxy reference");
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
// A copy, not a pointer. See the note on akgl_CollisionProxy.
obj->shape = *shape;
obj->x = x;
obj->y = y;
obj->z = z;
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, x, y, &obj->bounds));
SUCCEED_RETURN(errctx);
}