diff --git a/CMakeLists.txt b/CMakeLists.txt index 940d2d7..5c78aa0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -200,6 +200,7 @@ set(AKGL_PUBLIC_HEADERS assets audio character + collision collision_arena controller draw @@ -296,6 +297,7 @@ add_library(akgl SHARED src/actor.c src/collision.c src/collision_arena.c + src/collision_shape.c src/actor_state_string_names.c src/audio.c src/text.c @@ -384,6 +386,7 @@ set(AKGL_TEST_SUITES audio bitmasks character + collision collision_arena controller draw diff --git a/include/akgl/actor.h b/include/akgl/actor.h index 0066606..105050f 100644 --- a/include/akgl/actor.h +++ b/include/akgl/actor.h @@ -122,6 +122,8 @@ typedef struct akgl_Actor { void *actorData; /**< The game's own per-actor data. Never read or freed by the library. */ bool visible; /**< Whether to draw at all. An actor off-camera is skipped separately, so this is for deliberate hiding. */ SDL_Time movetimer; /**< Unused. Nothing in the library reads or writes it. */ + akgl_CollisionShape shape; /**< The collision volume this actor occupies. Copied from the character by akgl_actor_set_character unless #shape_override is set. Zeroed by akgl_actor_initialize, so an actor nobody configured does not collide. */ + bool shape_override; /**< The game set #shape itself and akgl_actor_set_character must leave it alone. Distinct from "the shape is empty": an actor deliberately given no collider and an actor not yet given one are different states, and only this flag tells them apart. */ // Velocity. Combined effect of all forces acting on the actor resulting // in energy along an axis. float32_t vx; /**< Velocity along x, world units per second. Recomputed each step as `ex + tx`; writing it directly is overwritten. */ diff --git a/include/akgl/character.h b/include/akgl/character.h index e309a51..830b255 100644 --- a/include/akgl/character.h +++ b/include/akgl/character.h @@ -17,6 +17,7 @@ #include #include +#include #include #define AKGL_CHARACTER_MAX_NAME_LENGTH 128 @@ -33,6 +34,7 @@ typedef struct akgl_Character { float32_t sx; /**< Maximum speed along x, world units per second. */ float32_t sy; /**< Maximum speed along y. */ float32_t sz; /**< Maximum speed along z. Not read from JSON; stays 0 unless set by hand. */ + akgl_CollisionShape shape; /**< The collision volume every actor of this character gets, unless the actor overrides it. A zeroed shape (#AKGL_COLLISION_SHAPE_NONE) means actors of this character do not collide, which is what a character loaded before shapes existed still gets. */ akerr_ErrorContext AKERR_NOIGNORE *(*sprite_add)(struct akgl_Character *, akgl_Sprite *, int); /**< Bound to akgl_character_sprite_add by akgl_character_initialize. */ akerr_ErrorContext AKERR_NOIGNORE *(*sprite_get)(struct akgl_Character *, int, akgl_Sprite **); /**< Bound to akgl_character_sprite_get by akgl_character_initialize. */ } akgl_Character; diff --git a/include/akgl/collision.h b/include/akgl/collision.h new file mode 100644 index 0000000..fe14059 --- /dev/null +++ b/include/akgl/collision.h @@ -0,0 +1,227 @@ +/** + * @file collision.h + * @brief Collision shapes: what an actor occupies, and what it will collide with. + * + * A shape is a convex volume positioned relative to an actor's origin. It lives + * on the akgl_Character, so every goblin sharing a character shares one shape + * definition, exactly as they share speeds and the state-to-sprite map -- and an + * individual actor may override it when it needs to. + * + * @section collision_leaf This header includes nothing of ours but types.h + * + * `actor.h` and `character.h` both need akgl_CollisionShape by value, so this + * header cannot include either of them without a cycle. It names actors and + * tilemaps as incomplete struct pointers instead, which is why they need struct + * tags and why `akgl_Tilemap` grew one. The `headers` suite compiles this file + * as the first include of a translation unit and would fail immediately if that + * ever stopped being true. + * + * @section collision_extrusion Why a 2D shape has a depth + * + * The narrowphase behind this is three-dimensional, which is what lets the same + * shapes carry into a 3D game later. It answers with the **minimum** translation + * that separates two volumes -- and if a 2D shape were extruded into a thin + * slab, the cheapest way to separate two of them would be along z. The + * narrowphase would report a contact, the resolver would push the actor into the + * screen, and on screen nothing would happen at all while the actor sank through + * the floor. + * + * So the setters give every shape a depth of #AKGL_COLLISION_DEPTH_RATIO times + * its largest planar half-extent unless told otherwise. That is not a large + * number chosen for comfort; it is the smallest ratio for which the z overlap of + * any two shapes built this way is provably larger than any planar penetration + * they can reach, so z can never be the minimum axis. Pass a depth by hand only + * if you know what that costs. + */ + +#ifndef _AKGL_COLLISION_H_ +#define _AKGL_COLLISION_H_ + +#include +#include + +#include +#include + +#include + +struct akgl_Actor; +struct akgl_Tilemap; + +/** @brief No shape. The actor takes no part in collision. */ +#define AKGL_COLLISION_SHAPE_NONE 0 +/** @brief An axis-aligned box. The common case, and the one with a closed-form answer. */ +#define AKGL_COLLISION_SHAPE_BOX 1 +/** @brief A circle in the xy plane, extruded along z. `hx` is the radius and `hy` is ignored. */ +#define AKGL_COLLISION_SHAPE_CIRCLE 2 +/** @brief A capsule whose long axis is x: a box with semicircular caps left and right. */ +#define AKGL_COLLISION_SHAPE_CAPSULE_X 3 +/** @brief A capsule whose long axis is y: a box with semicircular caps top and bottom. */ +#define AKGL_COLLISION_SHAPE_CAPSULE_Y 4 + +/** @brief No flags. */ +#define AKGL_COLLISION_FLAG_NONE 0x00000000u +/** @brief Never moved by the resolver and never re-inserted into the broad phase. */ +#define AKGL_COLLISION_FLAG_STATIC 0x00000001u +/** @brief Reports a contact and never pushes. Pickups, trigger volumes, damage zones. */ +#define AKGL_COLLISION_FLAG_SENSOR 0x00000002u +/** @brief Present but ignored this step. Cheaper than dropping the shape and putting it back. */ +#define AKGL_COLLISION_FLAG_DISABLED 0x00000004u +/** + * @brief Reserved for a swept narrowphase. **Not implemented; setting it does nothing.** + * + * Sub-stepping bounds how far an actor moves between collision tests, which + * stops anything moving at ordinary speeds from passing through a wall. A + * projectile is not moving at ordinary speeds. See `TODO.md`. + */ +#define AKGL_COLLISION_FLAG_BULLET 0x00000008u + +/** @brief On no layer. A shape with this `layermask` collides with nothing. */ +#define AKGL_COLLISION_LAYER_NONE 0x00000000u +/** @brief Map geometry: solid tiles and static proxies. The default `collidemask`. */ +#define AKGL_COLLISION_LAYER_STATIC (1u << 0) +/** @brief Ordinary actors. The default `layermask`. */ +#define AKGL_COLLISION_LAYER_ACTOR (1u << 1) +/** @brief Suggested layer for the player. Nothing in the library treats it specially. */ +#define AKGL_COLLISION_LAYER_PLAYER (1u << 2) +/** @brief Suggested layer for hostiles. */ +#define AKGL_COLLISION_LAYER_ENEMY (1u << 3) +/** @brief Suggested layer for things picked up rather than bumped into. */ +#define AKGL_COLLISION_LAYER_PICKUP (1u << 4) +/** @brief Every layer, including the ones a game defines for itself in bits 5 and above. */ +#define AKGL_COLLISION_LAYER_ALL 0xFFFFFFFFu + +/** + * @brief Depth given to a 2D shape, as a multiple of its largest planar half-extent. + * + * 2 and not 1. At 1, two identical squares fully overlapped have the same + * overlap on z as in the plane, and which axis the narrowphase calls "minimum" + * comes down to floating-point luck. At 2 the z overlap of any pair is at least + * twice the largest planar half-extent either can reach, and the planar + * penetration is at most that -- so z loses every time, by construction rather + * than by margin. + */ +#define AKGL_COLLISION_DEPTH_RATIO 2.0f + +/** + * @brief A convex volume, positioned relative to an actor's origin. + * + * Stored as a centre and half-extents rather than as an `SDL_FRect`, because + * that is the form both the overlap test and the narrowphase want and this way + * the conversion happens once, in a setter, instead of on every one of the tens + * of thousands of tests a frame. The setters take the rectangle form a game + * already has. + */ +typedef struct akgl_CollisionShape { + uint8_t kind; /**< One of the `AKGL_COLLISION_SHAPE_*` values. #AKGL_COLLISION_SHAPE_NONE means this actor does not collide, and is what a zeroed shape is. */ + uint32_t flags; /**< Bitwise OR of the `AKGL_COLLISION_FLAG_*` values. */ + uint32_t layermask; /**< Which layers this shape is *on*. What other shapes test against. */ + uint32_t collidemask; /**< Which layers this shape *responds to*. Asymmetric on purpose: a player can block against an NPC while the NPC ignores the player. */ + float32_t ox; /**< Centre offset from the actor's `x`, in map pixels. */ + float32_t oy; /**< Centre offset from the actor's `y`. Positive is down, matching screen space. */ + float32_t oz; /**< Centre offset from the actor's `z`. */ + float32_t hx; /**< Half-extent along x. The radius, for a circle or a capsule. */ + float32_t hy; /**< Half-extent along y. Ignored for a circle. */ + float32_t hz; /**< Half-extent along z. Never 0 after a setter has run; see the extrusion note on this file. */ +} akgl_CollisionShape; + +/** + * @brief Build an axis-aligned box from the frame-relative rectangle a game has. + * + * @p body is an offset and a size measured from the actor's position -- the same + * form a game already writes a hitbox in, inset into its sprite frame because + * sprite art does not reach the edges of its cell. It is converted here to the + * centre-and-half-extent form everything downstream uses. + * + * The shape is zeroed first, so every field not named by the arguments ends at a + * known value: no flags, and the default masks of #AKGL_COLLISION_LAYER_ACTOR on + * and #AKGL_COLLISION_LAYER_STATIC responded to. That default is chosen so an + * actor given a shape and nothing else collides with the map and with nothing + * else -- a town full of NPCs does not start shoving itself around because + * somebody gave the townsfolk hitboxes. + * + * @param dest Receives the shape. Required. + * @param body Offset and size in map pixels, relative to the actor's position. + * Required. Both `w` and `h` must be positive. + * @param depth Half-extent along z. Pass 0 for a 2D game and the extrusion is + * chosen for you; see the note on this file for why that matters. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p dest or @p body is `NULL`. + * @throws AKERR_VALUE If `body->w` or `body->h` is not positive, or @p depth is + * negative. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_shape_box(akgl_CollisionShape *dest, SDL_FRect *body, float32_t depth); + +/** + * @brief Build a circle, extruded along z into a cylinder. + * + * A cylinder and not a sphere: a sphere's caps would round away from the plane + * and let a shape slip past a corner in a way a 2D game never expects. + * + * @param dest Receives the shape. Required. + * @param ox Centre offset from the actor's `x`, in map pixels. + * @param oy Centre offset from the actor's `y`. + * @param radius Radius in map pixels. Must be positive. + * @param depth Half-extent along z, or 0 to have it chosen. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p dest is `NULL`. + * @throws AKERR_VALUE If @p radius is not positive, or @p depth is negative. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_shape_circle(akgl_CollisionShape *dest, float32_t ox, float32_t oy, float32_t radius, float32_t depth); + +/** + * @brief Build a capsule: a box with semicircular caps on one axis. + * + * Useful for a character that should slide off a corner rather than catch on it, + * which a box does and a capsule does not. + * + * @param dest Receives the shape. Required. + * @param body Offset and size in map pixels, as for akgl_collision_shape_box. + * Required. + * @param axis #AKGL_COLLISION_SHAPE_CAPSULE_X or #AKGL_COLLISION_SHAPE_CAPSULE_Y. + * @param depth Half-extent along z, or 0 to have it chosen. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p dest or @p body is `NULL`. + * @throws AKERR_VALUE If either side is not positive, @p depth is negative, @p + * axis is neither capsule kind, or the capped axis is not the longer one + * -- a capsule whose caps are wider than it is long is a circle written + * confusingly, and is refused rather than silently reinterpreted. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_shape_capsule(akgl_CollisionShape *dest, SDL_FRect *body, uint8_t axis, float32_t depth); + +/** + * @brief The planar bounds a shape occupies with its owner at a given position. + * + * This is what the broad phase indexes on and what a cheap overlap test uses + * before anything more expensive runs. `z` is not represented: the extrusion + * exists to keep the narrowphase honest, not to be searched. + * + * @param shape The shape. Required. + * @param x The owner's `x`. + * @param y The owner's `y`. + * @param dest Receives the bounds in map pixels. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p shape or @p dest is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_shape_bounds(akgl_CollisionShape *shape, float32_t x, float32_t y, SDL_FRect *dest); + +/** + * @brief Whether two shapes are allowed to interact, in the given direction. + * + * Asymmetric, and that is the point: @p self responds to @p other when @p + * other's `layermask` intersects @p self's `collidemask`. The reverse is a + * separate question with its own answer, which is how a player blocks against a + * pushable crate while the crate ignores everything. + * + * A shape of kind #AKGL_COLLISION_SHAPE_NONE, or one carrying + * #AKGL_COLLISION_FLAG_DISABLED, interacts with nothing in either direction. + * + * @param self The shape doing the responding. Required. + * @param other The shape being responded to. Required. + * @param dest Receives whether the pair is worth testing. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If any argument is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_shape_interacts(akgl_CollisionShape *self, akgl_CollisionShape *other, bool *dest); + +#endif diff --git a/src/actor.c b/src/actor.c index 61a390d..bdc27db 100644 --- a/src/actor.c +++ b/src/actor.c @@ -65,6 +65,17 @@ akerr_ErrorContext *akgl_actor_set_character(akgl_Actor *obj, char *basecharname obj->ay = 0; obj->sx = obj->basechar->sx; obj->sy = obj->basechar->sy; + + /* + * The character's shape is the template, and rebinding a character brings + * its shape with it -- unless the game has set one on this actor and said + * so. Without the flag there is no way to express "this one is different" + * that survives the next call, and no way to tell it apart from "this one + * has not been set up yet". + */ + if ( obj->shape_override == false ) { + obj->shape = obj->basechar->shape; + } SUCCEED_RETURN(errctx); } diff --git a/src/collision_shape.c b/src/collision_shape.c new file mode 100644 index 0000000..3517294 --- /dev/null +++ b/src/collision_shape.c @@ -0,0 +1,183 @@ +/** + * @file collision_shape.c + * @brief Building collision shapes, and the one invariant that keeps 2D flat. + * + * Nothing here touches the narrowphase. A shape is data, and this file is the + * only place that decides what a well-formed one looks like -- which is why the + * extrusion rule lives in the setters rather than at the point of use, where + * every caller would have to remember it. + */ + +#include + +#include +#include + +/** + * @brief Fill in the depth, unless the caller asked for a specific one. + * + * Called last by every setter, once `hx` and `hy` are known. See collision.h for + * why the ratio is what it is; the short version is that a thin extrusion makes + * z the cheapest axis to separate on, and an actor pushed along z goes nowhere a + * player can see while falling through the floor. + */ +static akerr_ErrorContext *shape_extrude(akgl_CollisionShape *dest, float32_t depth) +{ + float32_t largest = 0.0f; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_NONZERO_RETURN(errctx, (depth < 0.0f), AKERR_VALUE, + "Shape depth %f is negative", depth); + + if ( depth > 0.0f ) { + dest->hz = depth; + SUCCEED_RETURN(errctx); + } + + largest = dest->hx; + if ( dest->hy > largest ) { + largest = dest->hy; + } + dest->hz = AKGL_COLLISION_DEPTH_RATIO * largest; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Zero a shape and give it the defaults every setter shares. + * + * The masks are the interesting part. An actor that has been given a shape and + * nothing else collides with map geometry and with no other actor, because + * "everything with a hitbox shoves everything else" is a surprising default for + * a town full of scenery and a painful one to discover after the fact. Opting + * in to actor-versus-actor is one added bit; opting out of it would have been a + * hunt through every NPC a game spawns. + */ +static akerr_ErrorContext *shape_defaults(akgl_CollisionShape *dest, uint8_t kind) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference"); + + memset(dest, 0x00, sizeof(akgl_CollisionShape)); + dest->kind = kind; + dest->layermask = AKGL_COLLISION_LAYER_ACTOR; + dest->collidemask = AKGL_COLLISION_LAYER_STATIC; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_shape_box(akgl_CollisionShape *dest, SDL_FRect *body, float32_t depth) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "NULL rectangle reference"); + FAIL_NONZERO_RETURN(errctx, (body->w <= 0.0f), AKERR_VALUE, + "Box width %f is not positive", body->w); + FAIL_NONZERO_RETURN(errctx, (body->h <= 0.0f), AKERR_VALUE, + "Box height %f is not positive", body->h); + + PASS(errctx, shape_defaults(dest, AKGL_COLLISION_SHAPE_BOX)); + + // Top-left-and-size in, centre-and-half-extent out. Done once here so that + // nothing on the frame path has to do it per test. + dest->hx = body->w / 2.0f; + dest->hy = body->h / 2.0f; + dest->ox = body->x + dest->hx; + dest->oy = body->y + dest->hy; + + PASS(errctx, shape_extrude(dest, depth)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_shape_circle(akgl_CollisionShape *dest, float32_t ox, float32_t oy, float32_t radius, float32_t depth) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_VALUE, + "Circle radius %f is not positive", radius); + + PASS(errctx, shape_defaults(dest, AKGL_COLLISION_SHAPE_CIRCLE)); + dest->ox = ox; + dest->oy = oy; + dest->hx = radius; + dest->hy = radius; + PASS(errctx, shape_extrude(dest, depth)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_shape_capsule(akgl_CollisionShape *dest, SDL_FRect *body, uint8_t axis, float32_t depth) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "NULL rectangle reference"); + FAIL_NONZERO_RETURN( + errctx, + ((axis != AKGL_COLLISION_SHAPE_CAPSULE_X) && (axis != AKGL_COLLISION_SHAPE_CAPSULE_Y)), + AKERR_VALUE, + "Capsule axis %u is neither AKGL_COLLISION_SHAPE_CAPSULE_X nor _Y", + (unsigned int)axis + ); + FAIL_NONZERO_RETURN(errctx, (body->w <= 0.0f), AKERR_VALUE, + "Capsule width %f is not positive", body->w); + FAIL_NONZERO_RETURN(errctx, (body->h <= 0.0f), AKERR_VALUE, + "Capsule height %f is not positive", body->h); + + /* + * The capped axis has to be the longer one. A capsule whose caps are wider + * than the body is long is a circle with extra steps, and accepting it would + * mean the shape a caller described and the shape the narrowphase built were + * different -- silently, and only for some sizes. + */ + if ( axis == AKGL_COLLISION_SHAPE_CAPSULE_X ) { + FAIL_NONZERO_RETURN(errctx, (body->w <= body->h), AKERR_VALUE, + "An x-axis capsule is %fx%f; the capped axis must be the longer one", + body->w, body->h); + } else { + FAIL_NONZERO_RETURN(errctx, (body->h <= body->w), AKERR_VALUE, + "A y-axis capsule is %fx%f; the capped axis must be the longer one", + body->w, body->h); + } + + PASS(errctx, shape_defaults(dest, axis)); + dest->hx = body->w / 2.0f; + dest->hy = body->h / 2.0f; + dest->ox = body->x + dest->hx; + dest->oy = body->y + dest->hy; + PASS(errctx, shape_extrude(dest, depth)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_shape_bounds(akgl_CollisionShape *shape, float32_t x, float32_t y, SDL_FRect *dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL rectangle reference"); + + dest->x = x + shape->ox - shape->hx; + dest->y = y + shape->oy - shape->hy; + dest->w = shape->hx * 2.0f; + dest->h = shape->hy * 2.0f; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_collision_shape_interacts(akgl_CollisionShape *self, akgl_CollisionShape *other, bool *dest) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL shape reference"); + FAIL_ZERO_RETURN(errctx, other, AKERR_NULLPOINTER, "NULL other shape reference"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference"); + + *dest = false; + if ( (self->kind == AKGL_COLLISION_SHAPE_NONE) || + (other->kind == AKGL_COLLISION_SHAPE_NONE) ) { + SUCCEED_RETURN(errctx); + } + if ( ((self->flags & AKGL_COLLISION_FLAG_DISABLED) != 0) || + ((other->flags & AKGL_COLLISION_FLAG_DISABLED) != 0) ) { + SUCCEED_RETURN(errctx); + } + + // One direction only. The mirrored question is asked separately, and may + // legitimately have a different answer. + *dest = ((other->layermask & self->collidemask) != 0); + SUCCEED_RETURN(errctx); +} diff --git a/tests/collision.c b/tests/collision.c new file mode 100644 index 0000000..594f535 --- /dev/null +++ b/tests/collision.c @@ -0,0 +1,324 @@ +/** + * @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. + */ + +#include + +#include + +#include +#include + +#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); +} + +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()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); + + return 0; +} diff --git a/tests/collision_arena.c b/tests/collision_arena.c index d13dd6e..b98d0c8 100644 --- a/tests/collision_arena.c +++ b/tests/collision_arena.c @@ -165,6 +165,7 @@ akerr_ErrorContext *test_arena_drives_a_real_query(void) bool hit = false; float32_t depth = 0.0f; size_t highwater = 0; + akerr_ErrorContext *inner = NULL; int i = 0; PREPARE_ERROR(errctx); @@ -214,10 +215,18 @@ akerr_ErrorContext *test_arena_drives_a_real_query(void) * Each query resets the arena on entry, so a thousand of them in a row * cost what one costs. Without that, a no-op free and a bump allocator * would fill 64 KB in nine queries. + * + * The loop body cannot use CATCH: it reports by `break`ing, which would + * leave the loop rather than the ATTEMPT block and turn a failing query + * into a silently short run. Collect the failure and hand it over after. */ for ( i = 0; i < 64; i++ ) { - CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth)); + inner = akgl_collision_arena_selftest(1.0f, &hit, &depth); + if ( inner != NULL ) { + break; + } } + CATCH(errctx, inner); TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= highwater), "64 queries used %zu bytes where one used %zu; the arena is not being reset", akgl_ccd_arena_used(), highwater);