diff --git a/include/akgl/actor.h b/include/akgl/actor.h index 77ddd8b..f424d6b 100644 --- a/include/akgl/actor.h +++ b/include/akgl/actor.h @@ -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 diff --git a/src/actor.c b/src/actor.c index bdc27db..1db7c40 100644 --- a/src/actor.c +++ b/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); +} diff --git a/tests/collision.c b/tests/collision.c index 5cdb060..a9ce144 100644 --- a/tests/collision.c +++ b/tests/collision.c @@ -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);