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:
@@ -162,12 +162,13 @@ typedef struct akgl_Actor {
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*movementlogicfunc)(struct akgl_Actor *obj, float32_t dt); /**< Called by the physics step before gravity. Defaults to akgl_actor_logic_movement. May raise AKGL_ERR_LOGICINTERRUPT to skip the rest of the step. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*changeframefunc)(struct akgl_Actor *obj, akgl_Sprite *curSprite, SDL_Time curtimems); /**< Advances the animation. Defaults to akgl_actor_logic_changeframe. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*addchild)(struct akgl_Actor *obj, struct akgl_Actor *child); /**< Attaches a child. Defaults to akgl_actor_add_child. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*collidefunc)(struct akgl_Actor *obj, akgl_Contact *contact); /**< Responds to one contact. Defaults to akgl_actor_collide_block. Called once per contact found against this actor, with the normal already pointing the way this actor has to move. */
|
||||
} akgl_Actor;
|
||||
|
||||
/**
|
||||
* @brief Zero a pooled actor, name it, wire up its default behaviour, and register it.
|
||||
*
|
||||
* Sets `scale` to 1.0 and `movement_controls_face` to true, installs all six
|
||||
* Sets `scale` to 1.0 and `movement_controls_face` to true, installs all seven
|
||||
* default function pointers, publishes the actor in #AKGL_REGISTRY_ACTOR, and
|
||||
* takes the first reference. It does *not* set a character: the actor cannot
|
||||
* update, render, or simulate until akgl_actor_set_character has run.
|
||||
@@ -340,6 +341,55 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_automatic_face(akgl_Actor *obj);
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_add_child(akgl_Actor *obj, akgl_Actor *child);
|
||||
|
||||
/**
|
||||
* @brief The default `collidefunc`: stop at the surface and give up the motion into it.
|
||||
*
|
||||
* Moves @p obj out along the contact normal by exactly the penetration depth,
|
||||
* and removes the component of its motion that was going into the surface. What
|
||||
* is left is the tangential part, which is what makes an actor slide along a
|
||||
* wall instead of sticking to it.
|
||||
*
|
||||
* @section collide_block_velocity Why this writes `e` and `t` and not `v`
|
||||
*
|
||||
* akgl_physics_simulate recomputes velocity as `e + t` at the top of every step,
|
||||
* so a write to `vx`/`vy`/`vz` is discarded before anything reads it. The
|
||||
* component has to come off the environmental and thrust terms themselves --
|
||||
* `e` is where gravity accumulates and `t` is the actor's own effort -- or an
|
||||
* actor holding a direction into a wall keeps accumulating thrust while standing
|
||||
* still and leaves at speed the instant the wall ends.
|
||||
*
|
||||
* They 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.
|
||||
*
|
||||
* @section collide_block_separating A separating contact is left alone
|
||||
*
|
||||
* If the actor is already moving out of the surface, nothing is removed. Zeroing
|
||||
* a separating velocity is how an actor gets stuck to a wall it is walking away
|
||||
* from, and it looks like the collision "grabbing" the player.
|
||||
*
|
||||
* @section collide_block_sensor A sensor is reported and never pushed
|
||||
*
|
||||
* A contact flagged as a sensor returns success having changed nothing. Walking
|
||||
* through a coin is not being blocked by it.
|
||||
*
|
||||
* @note This does **not** pre-load the environmental term with a step of
|
||||
* gravity. A resolver that runs *before* the step has to, or the step it
|
||||
* is correcting for immediately re-adds `gravity * dt` and commits
|
||||
* `gravity * dt^2` of penetration -- a quarter of a pixel at 60 Hz, which
|
||||
* is invisible and still fatal, because the actor is then overlapping the
|
||||
* floor and every horizontal move reads as blocked. This library resolves
|
||||
* *after* the move, so the case does not arise. The sidescroller example
|
||||
* carries that workaround because it had to.
|
||||
*
|
||||
* @param obj The actor to resolve. Required.
|
||||
* @param contact The contact. Required. Its normal points the way @p obj must
|
||||
* move.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p contact is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_collide_block(akgl_Actor *obj, akgl_Contact *contact);
|
||||
|
||||
/*
|
||||
* The control-map handler functions ("cmhf"). These are what
|
||||
* akgl_controller_default binds the arrow keys and the D-pad to, and what a
|
||||
|
||||
51
src/actor.c
51
src/actor.c
@@ -40,6 +40,7 @@ akerr_ErrorContext *akgl_actor_initialize(akgl_Actor *obj, char *name)
|
||||
obj->movementlogicfunc = &akgl_actor_logic_movement;
|
||||
obj->changeframefunc = &akgl_actor_logic_changeframe;
|
||||
obj->addchild = &akgl_actor_add_child;
|
||||
obj->collidefunc = &akgl_actor_collide_block;
|
||||
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
@@ -451,3 +452,53 @@ akerr_ErrorContext *akgl_actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event)
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_actor_collide_block(akgl_Actor *obj, akgl_Contact *contact)
|
||||
{
|
||||
float32_t en = 0.0f;
|
||||
float32_t tn = 0.0f;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor reference");
|
||||
FAIL_ZERO_RETURN(errctx, contact, AKERR_NULLPOINTER, "NULL contact reference");
|
||||
|
||||
// A sensor reports and never pushes. Walking through a coin is not being
|
||||
// blocked by it.
|
||||
if ( contact->sensor == true ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
obj->x += contact->nx * contact->depth;
|
||||
obj->y += contact->ny * contact->depth;
|
||||
obj->z += contact->nz * contact->depth;
|
||||
|
||||
/*
|
||||
* The environmental and thrust terms, separately, and never `v`. See the
|
||||
* long note on this function in actor.h: simulate recomputes `v` as `e + t`
|
||||
* every step, so writing `v` here is writing to something nobody reads --
|
||||
* and treating the two terms separately is what lets an actor pressing into
|
||||
* a floor lose its gravity without losing the run it is also doing.
|
||||
*
|
||||
* A component already pointing out of the surface is left alone. Removing a
|
||||
* separating velocity is how an actor sticks to a wall it is walking away
|
||||
* from.
|
||||
*/
|
||||
en = (obj->ex * contact->nx) + (obj->ey * contact->ny) + (obj->ez * contact->nz);
|
||||
if ( en < 0.0f ) {
|
||||
obj->ex -= en * contact->nx;
|
||||
obj->ey -= en * contact->ny;
|
||||
obj->ez -= en * contact->nz;
|
||||
}
|
||||
|
||||
tn = (obj->tx * contact->nx) + (obj->ty * contact->ny) + (obj->tz * contact->nz);
|
||||
if ( tn < 0.0f ) {
|
||||
obj->tx -= tn * contact->nx;
|
||||
obj->ty -= tn * contact->ny;
|
||||
obj->tz -= tn * contact->nz;
|
||||
}
|
||||
|
||||
obj->vx = obj->ex + obj->tx;
|
||||
obj->vy = obj->ey + obj->ty;
|
||||
obj->vz = obj->ez + obj->tz;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -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