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>
This commit is contained in:
@@ -696,6 +696,129 @@ akerr_ErrorContext *test_narrowphase_fast_path_agrees(void)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -712,6 +835,8 @@ int main(void)
|
||||
CATCH(errctx, test_narrowphase_box_normal_and_depth());
|
||||
CATCH(errctx, test_narrowphase_planar_guard());
|
||||
CATCH(errctx, test_narrowphase_fast_path_agrees());
|
||||
CATCH(errctx, test_collide_block());
|
||||
CATCH(errctx, test_collidefunc_is_installed());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
Reference in New Issue
Block a user