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.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.
|
|
|
|
|
*/
|
|
|
|
|
|
Answer whether two shapes overlap, and how to undo it
akgl_collision_test takes two proxies and fills in a contact: a unit normal, a
depth, and a point. Three paths, cheapest first.
The proxies' bounds reject most pairs in four comparisons and no arithmetic,
which is free because the bounds were computed when the proxy was synced.
A box against a box is answered in closed form. That is not only cheaper than
the general solver, it is *exact*, and the difference shows up where it matters
most: an actor resting on a floor wants a normal of precisely (0, -1, 0), and an
iterative solver converges to something like (0.0001, -0.99999, 0), which
accumulates into a slow sideways creep. A tile game is almost entirely boxes, so
this is the path that runs.
Everything else goes to ccdMPRPenetration. MPR rather than GJK+EPA because MPR
allocates nothing at all, converges in fewer iterations, and its one weakness --
a coarser contact *point* -- is on a field the blocking resolver never reads.
EPA stays compiled and available behind the arena for a caller who one day wants
an accurate manifold. The header says the point is approximate so nobody builds
a damage falloff on it.
The normal points out of the second shape and toward the first, so a caller
moving `a` along it by `depth` separates the pair. libccd hands back the
opposite convention; converting once here saves every resolver from remembering
the sign, and getting it backwards would compile, would pass any "do these
collide" test, and would drag actors into walls. There is a test that asserts
the direction, and inverting the conversion turns it red.
AKGL_COLLISION_TEST_PLANAR flattens the normal into the xy plane. The extrusion
the setters apply already makes z the most expensive axis, so this is a net for
a caller who set a depth by hand -- and the failure it catches is silent: the
narrowphase reports a contact, the resolver pushes the actor into the screen,
and the actor does not move on screen while remaining inside the floor. It also
rescues the degenerate case, two shapes at exactly the same centre, where there
is no planar direction at all and a zero-length normal would be a wall that
stops nothing. Level authors stack things constantly.
Three things the tests found rather than assumed:
- The guard has two copies, one per path, and the box one is where it earns its
place. Removing both is caught; removing only the iterative one is not,
because the iterative solver is seeded from the line between the two centres
and so converges to a planar answer on its own for a planar offset. That is
measured and written down in the test rather than papered over with a fixture
contrived to force it.
- A concentric pair has no preferred sign on the degenerate axis, so the test
asserts the magnitude of the normal rather than its direction. The first
version asserted -1 and failed against an equally correct +1.
- The box fast path and the iterative solver are two implementations of one
answer, so they are driven over the same arrangements and required to agree on
the decision and the axis. A shortcut nothing cross-checks is a shortcut
waiting to diverge.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:18:09 -04:00
|
|
|
#include <math.h>
|
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
|
|
|
#include <string.h>
|
|
|
|
|
|
|
|
|
|
#include <akerror.h>
|
|
|
|
|
|
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
|
|
|
#include <akgl/actor.h>
|
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
|
|
|
#include <akgl/collision.h>
|
|
|
|
|
#include <akgl/error.h>
|
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
|
|
|
#include <akgl/heap.h>
|
|
|
|
|
#include <akgl/registry.h>
|
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
|
|
|
|
|
|
|
|
#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);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* @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);
|
|
|
|
|
}
|
|
|
|
|
|
Answer whether two shapes overlap, and how to undo it
akgl_collision_test takes two proxies and fills in a contact: a unit normal, a
depth, and a point. Three paths, cheapest first.
The proxies' bounds reject most pairs in four comparisons and no arithmetic,
which is free because the bounds were computed when the proxy was synced.
A box against a box is answered in closed form. That is not only cheaper than
the general solver, it is *exact*, and the difference shows up where it matters
most: an actor resting on a floor wants a normal of precisely (0, -1, 0), and an
iterative solver converges to something like (0.0001, -0.99999, 0), which
accumulates into a slow sideways creep. A tile game is almost entirely boxes, so
this is the path that runs.
Everything else goes to ccdMPRPenetration. MPR rather than GJK+EPA because MPR
allocates nothing at all, converges in fewer iterations, and its one weakness --
a coarser contact *point* -- is on a field the blocking resolver never reads.
EPA stays compiled and available behind the arena for a caller who one day wants
an accurate manifold. The header says the point is approximate so nobody builds
a damage falloff on it.
The normal points out of the second shape and toward the first, so a caller
moving `a` along it by `depth` separates the pair. libccd hands back the
opposite convention; converting once here saves every resolver from remembering
the sign, and getting it backwards would compile, would pass any "do these
collide" test, and would drag actors into walls. There is a test that asserts
the direction, and inverting the conversion turns it red.
AKGL_COLLISION_TEST_PLANAR flattens the normal into the xy plane. The extrusion
the setters apply already makes z the most expensive axis, so this is a net for
a caller who set a depth by hand -- and the failure it catches is silent: the
narrowphase reports a contact, the resolver pushes the actor into the screen,
and the actor does not move on screen while remaining inside the floor. It also
rescues the degenerate case, two shapes at exactly the same centre, where there
is no planar direction at all and a zero-length normal would be a wall that
stops nothing. Level authors stack things constantly.
Three things the tests found rather than assumed:
- The guard has two copies, one per path, and the box one is where it earns its
place. Removing both is caught; removing only the iterative one is not,
because the iterative solver is seeded from the line between the two centres
and so converges to a planar answer on its own for a planar offset. That is
measured and written down in the test rather than papered over with a fixture
contrived to force it.
- A concentric pair has no preferred sign on the degenerate axis, so the test
asserts the magnitude of the normal rather than its direction. The first
version asserted -1 and failed against an equally correct +1.
- The box fast path and the iterative solver are two implementations of one
answer, so they are driven over the same arrangements and required to agree on
the decision and the axis. A shortcut nothing cross-checks is a shortcut
waiting to diverge.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:18:09 -04:00
|
|
|
/** @brief Build a stack proxy around a shape at a position, for a test. */
|
|
|
|
|
static akerr_ErrorContext *at(akgl_CollisionProxy *dest, akgl_CollisionShape *shape, float32_t x, float32_t y)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
PASS(errctx, akgl_collision_proxy_initialize(dest, NULL, shape, x, y, 0.0f));
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief The normal points the way a resolver needs it to, and the depth undoes the overlap.
|
|
|
|
|
*
|
|
|
|
|
* The sign convention is the thing every caller would otherwise have to
|
|
|
|
|
* rediscover: the normal points **out of the second shape and toward the
|
|
|
|
|
* first**, so `a` moving along it by `depth` separates them. Getting it
|
|
|
|
|
* backwards does not fail to compile and does not fail a "do these collide"
|
|
|
|
|
* test -- it drags actors *into* walls, which is why it is asserted directly.
|
|
|
|
|
*/
|
|
|
|
|
akerr_ErrorContext *test_narrowphase_box_normal_and_depth(void)
|
|
|
|
|
{
|
|
|
|
|
akgl_CollisionShape shape;
|
|
|
|
|
akgl_CollisionProxy a;
|
|
|
|
|
akgl_CollisionProxy b;
|
|
|
|
|
akgl_Contact contact;
|
|
|
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
|
|
|
bool hit = false;
|
|
|
|
|
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
|
|
|
|
|
|
|
|
|
|
// b is 4 to the right of a and overlapping by 12.
|
|
|
|
|
CATCH(errctx, at(&a, &shape, 0.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, at(&b, &shape, 4.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "two overlapping boxes did not collide");
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.nx, -1.0f,
|
|
|
|
|
"the normal is %f on x; a is left of b so it must leave to the left",
|
|
|
|
|
contact.nx);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.ny, 0.0f, "a purely horizontal overlap produced ny %f", contact.ny);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.depth, 12.0f, "depth is %f, expected 12", contact.depth);
|
|
|
|
|
|
|
|
|
|
// Mirrored: the answer must mirror with it.
|
|
|
|
|
CATCH(errctx, at(&b, &shape, -4.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "the mirrored pair did not collide");
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.nx, 1.0f, "the mirrored normal is %f, expected 1", contact.nx);
|
|
|
|
|
|
|
|
|
|
// Standing on a floor: the smallest overlap is vertical, so the normal is
|
|
|
|
|
// vertical, and it is exactly vertical rather than nearly so.
|
|
|
|
|
CATCH(errctx, at(&b, &shape, 0.0f, 14.0f));
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "an actor resting on a box did not collide");
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.ny, -1.0f, "resting normal is %f on y, expected -1", contact.ny);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.nx, 0.0f,
|
|
|
|
|
"resting normal has %f on x; a converged normal creeps along a floor",
|
|
|
|
|
contact.nx);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.depth, 2.0f, "resting depth is %f, expected 2", contact.depth);
|
|
|
|
|
|
|
|
|
|
// Separated, and touching exactly, are both "no push".
|
|
|
|
|
CATCH(errctx, at(&b, &shape, 100.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == false), "boxes 100 apart collided");
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
|
|
|
akgl_collision_test(NULL, &b, 0, &contact, &hit), "a test with no first proxy");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
|
|
|
akgl_collision_test(&a, &b, 0, NULL, &hit), "a test with no contact");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
|
|
|
|
akgl_collision_test(&a, &b, 0, &contact, NULL), "a test with no hit flag");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief A shape whose depth was set badly must still not resolve along z.
|
|
|
|
|
*
|
|
|
|
|
* This is the failure the planar flag exists for, and it is invisible without
|
|
|
|
|
* it: the narrowphase reports a contact, the resolver pushes the actor into the
|
|
|
|
|
* screen, the actor does not move on screen, and it stays inside the floor. The
|
|
|
|
|
* shape here is deliberately built with a depth that defeats the extrusion
|
|
|
|
|
* invariant, which is the only way a caller can reach the case.
|
|
|
|
|
*/
|
|
|
|
|
akerr_ErrorContext *test_narrowphase_planar_guard(void)
|
|
|
|
|
{
|
|
|
|
|
akgl_CollisionShape thin;
|
|
|
|
|
akgl_CollisionShape thincircle;
|
|
|
|
|
akgl_CollisionProxy a;
|
|
|
|
|
akgl_CollisionProxy b;
|
|
|
|
|
akgl_Contact contact;
|
|
|
|
|
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
|
|
|
|
|
bool hit = false;
|
|
|
|
|
float32_t len = 0.0f;
|
|
|
|
|
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
// Depth 0.5 against half-extents of 8: z is now by far the cheapest axis.
|
|
|
|
|
CATCH(errctx, akgl_collision_shape_box(&thin, &body, 0.5f));
|
|
|
|
|
CATCH(errctx, at(&a, &thin, 0.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, at(&b, &thin, 2.0f, 0.0f));
|
|
|
|
|
|
|
|
|
|
// Without the flag the narrowphase is free to answer along z, and does.
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, 0, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "two thin overlapping boxes did not collide");
|
|
|
|
|
// Either sign is correct -- the pair is concentric on z, so both
|
|
|
|
|
// directions separate it equally. That z was chosen at all is the point.
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, fabsf(contact.nz), 1.0f,
|
|
|
|
|
"a badly extruded pair resolved along %f on z; if this is not +/-1 the "
|
|
|
|
|
"fixture no longer reproduces the case the guard exists for", contact.nz);
|
|
|
|
|
|
|
|
|
|
// With it, the answer is planar and still a unit vector.
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "the guarded test lost the collision");
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.nz, 0.0f, "the guard left %f on z", contact.nz);
|
|
|
|
|
len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, len, 1.0f, "the flattened normal has length %f, expected 1", len);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.nx, -1.0f, "the flattened normal is %f on x, expected -1", contact.nx);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Exactly concentric. There is no planar direction to separate along, and
|
|
|
|
|
* a zero-length normal would be a wall that moves nothing -- so the guard
|
|
|
|
|
* has to invent one rather than pass the degenerate answer through. Level
|
|
|
|
|
* authors put things on top of each other constantly.
|
|
|
|
|
*/
|
|
|
|
|
CATCH(errctx, at(&b, &thin, 0.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "two concentric shapes did not collide");
|
|
|
|
|
len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, len, 1.0f,
|
|
|
|
|
"concentric shapes produced a normal of length %f; a zero normal is a "
|
|
|
|
|
"wall that does not stop anything", len);
|
|
|
|
|
TEST_ASSERT(errctx, (contact.depth > 0.0f), "concentric shapes produced depth %f", contact.depth);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* And again through the iterative solver, which is a different branch
|
|
|
|
|
* with its own copy of the guard. Circles cannot take the box fast path,
|
|
|
|
|
* so this is the only way to reach it -- the box version above proves
|
|
|
|
|
* nothing about this code.
|
|
|
|
|
*/
|
|
|
|
|
CATCH(errctx, akgl_collision_shape_circle(&thincircle, 0.0f, 0.0f, 8.0f, 0.5f));
|
|
|
|
|
CATCH(errctx, at(&a, &thincircle, 0.0f, 0.0f));
|
|
|
|
|
CATCH(errctx, at(&b, &thincircle, 2.0f, 0.0f));
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Unlike the closed-form box path, the iterative solver is seeded from
|
|
|
|
|
* the line between the two centres, so for a planar offset it converges
|
|
|
|
|
* to a planar answer and picks z only rarely -- measured, not assumed:
|
|
|
|
|
* this pair comes back with nz of about 0 even unguarded. The guard on
|
|
|
|
|
* this branch is therefore a net rather than a routine correction, and
|
|
|
|
|
* what is asserted below is that it produces a well-formed planar normal,
|
|
|
|
|
* not that it rescues one. The box path above is where the guard earns
|
|
|
|
|
* its place, and that is the assertion that fails if it is removed.
|
|
|
|
|
*/
|
|
|
|
|
CATCH(errctx, akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
|
|
|
|
|
TEST_ASSERT(errctx, (hit == true), "the guarded circle test lost the collision");
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, contact.nz, 0.0f,
|
|
|
|
|
"the iterative solver's guard left %f on z", contact.nz);
|
|
|
|
|
len = sqrtf((contact.nx * contact.nx) + (contact.ny * contact.ny));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, len, 1.0f,
|
|
|
|
|
"the flattened circle normal has length %f, expected 1", len);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief The box fast path and the general solver must agree.
|
|
|
|
|
*
|
|
|
|
|
* Two implementations of one answer are two chances to be wrong, and the fast
|
|
|
|
|
* path exists only because it is cheaper and exact. Driving the same
|
|
|
|
|
* arrangements through both -- by asking a box pair, then asking the same
|
|
|
|
|
* geometry as capsules, which cannot take the fast path -- is what keeps the
|
|
|
|
|
* shortcut honest. The agreement is on the *decision* and the *axis*; the
|
|
|
|
|
* iterative solver's depth converges rather than being exact, so it is compared
|
|
|
|
|
* with tolerance.
|
|
|
|
|
*/
|
|
|
|
|
akerr_ErrorContext *test_narrowphase_fast_path_agrees(void)
|
|
|
|
|
{
|
|
|
|
|
akgl_CollisionShape box;
|
|
|
|
|
akgl_CollisionShape circle;
|
|
|
|
|
akgl_CollisionProxy a;
|
|
|
|
|
akgl_CollisionProxy b;
|
|
|
|
|
akgl_Contact boxcontact;
|
|
|
|
|
akgl_Contact mprcontact;
|
|
|
|
|
SDL_FRect body = { .x = -8.0f, .y = -8.0f, .w = 16.0f, .h = 16.0f };
|
|
|
|
|
float32_t offsets[] = { 0.0f, 4.0f, 12.0f, 15.9f, 16.0f, 24.0f };
|
|
|
|
|
bool boxhit = false;
|
|
|
|
|
bool mprhit = false;
|
|
|
|
|
bool disagreed = false;
|
|
|
|
|
float32_t worst = 0.0f;
|
|
|
|
|
int i = 0;
|
|
|
|
|
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(errctx, akgl_collision_shape_box(&box, &body, 0.0f));
|
|
|
|
|
// A circle inscribed in the same box: it cannot take the fast path.
|
|
|
|
|
CATCH(errctx, akgl_collision_shape_circle(&circle, 0.0f, 0.0f, 8.0f, 0.0f));
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Recorded and asserted after the loop, not inside it. TEST_ASSERT
|
|
|
|
|
* reports by breaking, and a break inside a `for` leaves the loop rather
|
|
|
|
|
* than the ATTEMPT block -- an assertion written in here cannot fail.
|
|
|
|
|
*/
|
|
|
|
|
for ( i = 0; i < (int)(sizeof(offsets) / sizeof(offsets[0])); i++ ) {
|
|
|
|
|
if ( at(&a, &box, 0.0f, 0.0f) != NULL ) { disagreed = true; break; }
|
|
|
|
|
if ( at(&b, &box, offsets[i], 0.0f) != NULL ) { disagreed = true; break; }
|
|
|
|
|
if ( akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &boxcontact, &boxhit) != NULL ) {
|
|
|
|
|
disagreed = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( at(&a, &circle, 0.0f, 0.0f) != NULL ) { disagreed = true; break; }
|
|
|
|
|
if ( at(&b, &circle, offsets[i], 0.0f) != NULL ) { disagreed = true; break; }
|
|
|
|
|
if ( akgl_collision_test(&a, &b, AKGL_COLLISION_TEST_PLANAR, &mprcontact, &mprhit) != NULL ) {
|
|
|
|
|
disagreed = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Two circles of radius 8 and two boxes of half-extent 8 overlap on
|
|
|
|
|
// this axis over exactly the same range, so the decision must match.
|
|
|
|
|
if ( boxhit != mprhit ) {
|
|
|
|
|
disagreed = true;
|
|
|
|
|
worst = offsets[i];
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if ( (boxhit == true) && (mprhit == true) ) {
|
|
|
|
|
if ( (boxcontact.nx * mprcontact.nx) < 0.0f ) {
|
|
|
|
|
disagreed = true;
|
|
|
|
|
worst = offsets[i];
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT(errctx, (disagreed == false),
|
|
|
|
|
"the box fast path and the iterative solver disagree at an offset of %f; "
|
|
|
|
|
"two implementations of one answer are two chances to be wrong", worst);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
Give an actor a say in how it answers a collision
A seventh behaviour hook, collidefunc, defaulting to akgl_actor_collide_block.
Contacts are delivered one actor at a time with the normal already pointing the
way that actor has to move, which removes the "which one is a1" ambiguity that
made the old physics `collide` slot unimplementable -- and means a game
overriding one actor's response never has to reason about the other's.
The default moves the actor out along the normal by the penetration depth and
removes the component of its motion that was going into the surface. Three
things about that are easy to write wrongly, and each has a test that names the
symptom rather than the assertion:
- **It writes `e` and `t`, never `v`.** akgl_physics_simulate recomputes velocity
as `e + t` at the top of every step, so a write to `v` is discarded before
anything reads it. Worse than useless: an actor holding a direction into a
wall would keep accumulating thrust while standing still and leave at speed
the instant the wall ended. Breaking this leaves the fall running at 300 px/s
through the floor.
- **It removes the component, not the axis.** Zeroing the whole thrust vector
costs the actor its motion *along* the surface as well, which is a character
scraping a ceiling stopping dead and a character landing on a floor losing its
run. Sliding along a wall is the difference between a wall and glue.
- **A separating contact is left alone.** An actor already moving out of a
surface that gets its velocity zeroed is an actor stuck to a wall it is
walking away from, and it reads to a player as the collision grabbing them.
`e` and `t` are treated separately rather than as their sum, so an actor
pressing into a floor loses its downward gravity without losing the sideways run
it is also doing.
A sensor is reported and never pushed -- walking through a coin is not being
blocked by it.
The default deliberately does not pre-load the environmental term with a step of
gravity. A resolver running *before* the step has to, or the step it is
correcting for re-adds gravity and commits a quarter pixel of penetration, which
is invisible and still fatal because every horizontal move then reads as
blocked. This library resolves after the move, so the case does not arise; the
sidescroller example carries that workaround because it had no choice.
Also asserted: the other six hooks survive. A seventh that displaced one of them
would be a silent regression somewhere unrelated.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:47:21 -04:00
|
|
|
/** @brief Build a contact by hand, so a response can be tested without a step. */
|
|
|
|
|
static void contact_at(akgl_Contact *dest, float32_t nx, float32_t ny, float32_t depth, bool sensor)
|
|
|
|
|
{
|
|
|
|
|
memset(dest, 0x00, sizeof(akgl_Contact));
|
|
|
|
|
dest->tilex = -1;
|
|
|
|
|
dest->tiley = -1;
|
|
|
|
|
dest->tilelayer = -1;
|
|
|
|
|
dest->nx = nx;
|
|
|
|
|
dest->ny = ny;
|
|
|
|
|
dest->depth = depth;
|
|
|
|
|
dest->sensor = sensor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief The blocking response, and the four ways it is easy to write wrongly.
|
|
|
|
|
*/
|
|
|
|
|
akerr_ErrorContext *test_collide_block(void)
|
|
|
|
|
{
|
|
|
|
|
akgl_Actor actor;
|
|
|
|
|
akgl_Contact contact;
|
|
|
|
|
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
// Landing on a floor: the normal points up, out of the surface.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
actor.y = 100.0f;
|
|
|
|
|
actor.ey = 300.0f; // falling
|
|
|
|
|
actor.tx = 90.0f; // and running right
|
|
|
|
|
contact_at(&contact, 0.0f, -1.0f, 4.0f, false);
|
|
|
|
|
|
|
|
|
|
CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.y, 96.0f,
|
|
|
|
|
"the actor was not pushed out of the floor; y is %f, expected 96", actor.y);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.ey, 0.0f,
|
|
|
|
|
"the fall was not stopped; ey is %f", actor.ey);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* The run survives. Removing the whole thrust vector rather than its
|
|
|
|
|
* component into the surface is what makes a character scraping a
|
|
|
|
|
* ceiling stop dead, and it is the bug the sidescroller example has to
|
|
|
|
|
* work around by hand.
|
|
|
|
|
*/
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.tx, 90.0f,
|
|
|
|
|
"landing on a floor cost the actor its horizontal run; tx is %f", actor.tx);
|
|
|
|
|
|
|
|
|
|
// v is recomputed from e + t, because that is what simulate reads.
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.vx, 90.0f, "vx is %f, expected e + t", actor.vx);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.vy, 0.0f, "vy is %f, expected e + t", actor.vy);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* A separating contact changes nothing but the position. An actor
|
|
|
|
|
* already moving out of a surface that has its velocity zeroed is an
|
|
|
|
|
* actor stuck to a wall it is walking away from.
|
|
|
|
|
*/
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
actor.ey = -200.0f; // rising
|
|
|
|
|
contact_at(&contact, 0.0f, -1.0f, 2.0f, false);
|
|
|
|
|
CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.ey, -200.0f,
|
|
|
|
|
"a separating velocity was removed; ey is %f, expected -200. An actor "
|
|
|
|
|
"moving away from a surface must not be stopped by it", actor.ey);
|
|
|
|
|
|
|
|
|
|
// The thrust into a wall goes, and the motion along it stays.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
actor.tx = 120.0f; // running right
|
|
|
|
|
actor.ty = 40.0f; // and drifting down
|
|
|
|
|
contact_at(&contact, -1.0f, 0.0f, 3.0f, false); // wall on the right
|
|
|
|
|
CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.tx, 0.0f, "the run into the wall survived; tx is %f", actor.tx);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.ty, 40.0f,
|
|
|
|
|
"hitting a wall cost the actor its vertical motion; ty is %f. Sliding "
|
|
|
|
|
"along a wall is the difference between a wall and glue", actor.ty);
|
|
|
|
|
|
|
|
|
|
// A sensor reports and never pushes.
|
|
|
|
|
memset(&actor, 0x00, sizeof(akgl_Actor));
|
|
|
|
|
actor.x = 50.0f;
|
|
|
|
|
actor.ey = 300.0f;
|
|
|
|
|
contact_at(&contact, 0.0f, -1.0f, 8.0f, true);
|
|
|
|
|
CATCH(errctx, akgl_actor_collide_block(&actor, &contact));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.x, 50.0f, "a sensor moved the actor; x is %f", actor.x);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, actor.ey, 300.0f, "a sensor stopped the actor; ey is %f", actor.ey);
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_actor_collide_block(NULL, &contact),
|
|
|
|
|
"a response with no actor");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_actor_collide_block(&actor, NULL),
|
|
|
|
|
"a response with no contact");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @brief Every actor gets the default, and a game can replace it. */
|
|
|
|
|
akerr_ErrorContext *test_collidefunc_is_installed(void)
|
|
|
|
|
{
|
|
|
|
|
akgl_Actor *actor = NULL;
|
|
|
|
|
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(errctx, akgl_heap_init());
|
|
|
|
|
CATCH(errctx, akgl_registry_init_actor());
|
|
|
|
|
CATCH(errctx, akgl_heap_next_actor(&actor));
|
|
|
|
|
CATCH(errctx, akgl_actor_initialize(actor, "hooked"));
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT(errctx, (actor->collidefunc == &akgl_actor_collide_block),
|
|
|
|
|
"akgl_actor_initialize did not install the default collision response");
|
|
|
|
|
|
|
|
|
|
// The other six are still there. A seventh hook that displaced one of
|
|
|
|
|
// them would be a silent regression in something unrelated.
|
|
|
|
|
TEST_ASSERT(errctx, (actor->updatefunc != NULL), "updatefunc was lost");
|
|
|
|
|
TEST_ASSERT(errctx, (actor->renderfunc != NULL), "renderfunc was lost");
|
|
|
|
|
TEST_ASSERT(errctx, (actor->facefunc != NULL), "facefunc was lost");
|
|
|
|
|
TEST_ASSERT(errctx, (actor->movementlogicfunc != NULL), "movementlogicfunc was lost");
|
|
|
|
|
TEST_ASSERT(errctx, (actor->changeframefunc != NULL), "changeframefunc was lost");
|
|
|
|
|
TEST_ASSERT(errctx, (actor->addchild != NULL), "addchild was lost");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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());
|
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
|
|
|
CATCH(errctx, test_proxy_pool());
|
|
|
|
|
CATCH(errctx, test_proxy_dies_with_its_actor());
|
Answer whether two shapes overlap, and how to undo it
akgl_collision_test takes two proxies and fills in a contact: a unit normal, a
depth, and a point. Three paths, cheapest first.
The proxies' bounds reject most pairs in four comparisons and no arithmetic,
which is free because the bounds were computed when the proxy was synced.
A box against a box is answered in closed form. That is not only cheaper than
the general solver, it is *exact*, and the difference shows up where it matters
most: an actor resting on a floor wants a normal of precisely (0, -1, 0), and an
iterative solver converges to something like (0.0001, -0.99999, 0), which
accumulates into a slow sideways creep. A tile game is almost entirely boxes, so
this is the path that runs.
Everything else goes to ccdMPRPenetration. MPR rather than GJK+EPA because MPR
allocates nothing at all, converges in fewer iterations, and its one weakness --
a coarser contact *point* -- is on a field the blocking resolver never reads.
EPA stays compiled and available behind the arena for a caller who one day wants
an accurate manifold. The header says the point is approximate so nobody builds
a damage falloff on it.
The normal points out of the second shape and toward the first, so a caller
moving `a` along it by `depth` separates the pair. libccd hands back the
opposite convention; converting once here saves every resolver from remembering
the sign, and getting it backwards would compile, would pass any "do these
collide" test, and would drag actors into walls. There is a test that asserts
the direction, and inverting the conversion turns it red.
AKGL_COLLISION_TEST_PLANAR flattens the normal into the xy plane. The extrusion
the setters apply already makes z the most expensive axis, so this is a net for
a caller who set a depth by hand -- and the failure it catches is silent: the
narrowphase reports a contact, the resolver pushes the actor into the screen,
and the actor does not move on screen while remaining inside the floor. It also
rescues the degenerate case, two shapes at exactly the same centre, where there
is no planar direction at all and a zero-length normal would be a wall that
stops nothing. Level authors stack things constantly.
Three things the tests found rather than assumed:
- The guard has two copies, one per path, and the box one is where it earns its
place. Removing both is caught; removing only the iterative one is not,
because the iterative solver is seeded from the line between the two centres
and so converges to a planar answer on its own for a planar offset. That is
measured and written down in the test rather than papered over with a fixture
contrived to force it.
- A concentric pair has no preferred sign on the degenerate axis, so the test
asserts the magnitude of the normal rather than its direction. The first
version asserted -1 and failed against an equally correct +1.
- The box fast path and the iterative solver are two implementations of one
answer, so they are driven over the same arrangements and required to agree on
the decision and the axis. A shortcut nothing cross-checks is a shortcut
waiting to diverge.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:18:09 -04:00
|
|
|
CATCH(errctx, test_narrowphase_box_normal_and_depth());
|
|
|
|
|
CATCH(errctx, test_narrowphase_planar_guard());
|
|
|
|
|
CATCH(errctx, test_narrowphase_fast_path_agrees());
|
Give an actor a say in how it answers a collision
A seventh behaviour hook, collidefunc, defaulting to akgl_actor_collide_block.
Contacts are delivered one actor at a time with the normal already pointing the
way that actor has to move, which removes the "which one is a1" ambiguity that
made the old physics `collide` slot unimplementable -- and means a game
overriding one actor's response never has to reason about the other's.
The default moves the actor out along the normal by the penetration depth and
removes the component of its motion that was going into the surface. Three
things about that are easy to write wrongly, and each has a test that names the
symptom rather than the assertion:
- **It writes `e` and `t`, never `v`.** akgl_physics_simulate recomputes velocity
as `e + t` at the top of every step, so a write to `v` is discarded before
anything reads it. Worse than useless: an actor holding a direction into a
wall would keep accumulating thrust while standing still and leave at speed
the instant the wall ended. Breaking this leaves the fall running at 300 px/s
through the floor.
- **It removes the component, not the axis.** Zeroing the whole thrust vector
costs the actor its motion *along* the surface as well, which is a character
scraping a ceiling stopping dead and a character landing on a floor losing its
run. Sliding along a wall is the difference between a wall and glue.
- **A separating contact is left alone.** An actor already moving out of a
surface that gets its velocity zeroed is an actor stuck to a wall it is
walking away from, and it reads to a player as the collision grabbing them.
`e` and `t` are treated separately rather than as their sum, so an actor
pressing into a floor loses its downward gravity without losing the sideways run
it is also doing.
A sensor is reported and never pushed -- walking through a coin is not being
blocked by it.
The default deliberately does not pre-load the environmental term with a step of
gravity. A resolver running *before* the step has to, or the step it is
correcting for re-adds gravity and commits a quarter pixel of penetration, which
is invisible and still fatal because every horizontal move then reads as
blocked. This library resolves after the move, so the case does not arise; the
sidescroller example carries that workaround because it had no choice.
Also asserted: the other six hooks survive. A seventh that displaced one of them
would be a silent regression somewhere unrelated.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:47:21 -04:00
|
|
|
CATCH(errctx, test_collide_block());
|
|
|
|
|
CATCH(errctx, test_collidefunc_is_installed());
|
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
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
}
|