Files
libakgl/include/akgl/collision.h
Andrew Kesterson d0b057f47a 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

295 lines
15 KiB
C

/**
* @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 <stdbool.h>
#include <stdint.h>
#include <SDL3/SDL_rect.h>
#include <akerror.h>
#include <akgl/types.h>
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 One shape's registration in the broad phase.
*
* A proxy is what the partitioner indexes. It carries a **copy** of the shape
* rather than a pointer to it, which is deliberate: an actor's own logic may
* legitimately rewrite its shape mid-step -- a character crouching, a projectile
* arming -- and a broad phase whose bounds were computed from a shape that has
* since changed is a source of bugs that only appear when two things happen in
* the same frame. The copy is refreshed once per step, from one place.
*
* Proxies are pool objects. The library creates and destroys them; a game does
* not, and a game that releases an actor gets its proxy released with it.
*/
typedef struct akgl_CollisionProxy {
uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. First member, as in every pooled type here. */
struct akgl_Actor *owner; /**< The actor this proxy tracks, or `NULL` for static geometry with no actor behind it. Borrowed; no reference is taken. */
akgl_CollisionShape shape; /**< A copy of the owner's shape as of the last refresh. */
SDL_FRect bounds; /**< Planar world bounds in map pixels, computed from #shape at (#x, #y). */
float32_t x; /**< World position #bounds was computed from. */
float32_t y; /**< World position #bounds was computed from. */
float32_t z; /**< World position #bounds was computed from. Not represented in #bounds. */
uint32_t stamp; /**< Sweep serial this proxy was last visited on, so a proxy spanning several cells is reported once. Written by the partitioner. */
} akgl_CollisionProxy;
/*
* The following is part of the internal API. Proxies are created and destroyed
* by the library, not by a game.
*/
/**
* @brief Claim a pooled proxy for an actor's shape and take the reference on it.
*
* Follows the convention every pool here uses: akgl_heap_next_collision_proxy
* finds a free slot and does **not** claim it, and this takes the reference.
* Write the two adjacent, because until the reference is taken the slot is still
* free and the next acquire hands out the same pointer. That is `TODO.md` "Known
* and still open" item 8, it applies to four of the five existing pools, and
* this one does not depart from it -- a sixth convention would be worse than the
* defect.
*
* @param obj The proxy to initialize. Required.
* @param owner The actor it tracks, or `NULL` for static geometry.
* @param shape The shape to copy into it. Required.
* @param x World position of the owner.
* @param y World position of the owner.
* @param z World position of the owner.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj or @p shape is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_proxy_initialize(akgl_CollisionProxy *obj, struct akgl_Actor *owner, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z);
/**
* @brief Recompute a proxy's bounds from its owner's current shape and position.
*
* Called once per step per live proxy, before the broad phase is asked anything.
* This is the one place the shape copy is refreshed.
*
* @param obj The proxy. Required.
* @param shape The current shape to copy. Required.
* @param x Current world position of the owner.
* @param y Current world position of the owner.
* @param z Current world position of the owner.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj or @p shape is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z);
/**
* @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