Add collision shapes, on the character and overridable per actor
A shape is a convex volume positioned relative to an actor's origin. It lives on akgl_Character, so every goblin sharing a character shares one shape definition the way they already share speeds and the state-to-sprite map, and an actor may override it with akgl_Actor::shape_override. The flag is not redundant with an empty shape. "This actor deliberately has no collider" and "this actor has not been given one yet" are different states, and without somewhere to record the difference akgl_actor_set_character cannot tell whether it is allowed to overwrite what it finds. include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete struct pointers rather than including their headers, because both of those need akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite compiles it as the first include of a translation unit, so that property is enforced rather than merely intended -- which is why the tilemap types got struct tags two commits ago. The masks are asymmetric and the defaults are the load-bearing part: layermask ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with map geometry and with no other actor. "Everything with a hitbox shoves everything else" would be 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 bit, opting out of it would have been a hunt through every NPC a game spawns. Every shape gets a z depth, because the narrowphase behind this is three dimensional and answers with the *minimum* separating translation. A thin extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a player can see while remaining inside the floor -- a collision system reporting success while doing nothing. The ratio of 2 is the smallest for which the z overlap of any two shapes the setters build provably exceeds any planar penetration they can reach. The test for that asserts the inequality across a spread of sizes rather than asserting the constant is 2, so a change to the constant fails here rather than in a game. Two things about that test are worth recording, because the first version had both: - It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by `break`ing, and the assertion was inside a nested `for` -- so the break left the loop rather than the ATTEMPT block and the failure evaporated. This is the hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified by setting the ratio to 0.5 and watching the suite still pass, then fixing it and watching it report the 512x512 case by name. - tests/collision_arena.c had the same bug in a loop added in the previous commit, and it is fixed here too. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -200,6 +200,7 @@ set(AKGL_PUBLIC_HEADERS
|
|||||||
assets
|
assets
|
||||||
audio
|
audio
|
||||||
character
|
character
|
||||||
|
collision
|
||||||
collision_arena
|
collision_arena
|
||||||
controller
|
controller
|
||||||
draw
|
draw
|
||||||
@@ -296,6 +297,7 @@ add_library(akgl SHARED
|
|||||||
src/actor.c
|
src/actor.c
|
||||||
src/collision.c
|
src/collision.c
|
||||||
src/collision_arena.c
|
src/collision_arena.c
|
||||||
|
src/collision_shape.c
|
||||||
src/actor_state_string_names.c
|
src/actor_state_string_names.c
|
||||||
src/audio.c
|
src/audio.c
|
||||||
src/text.c
|
src/text.c
|
||||||
@@ -384,6 +386,7 @@ set(AKGL_TEST_SUITES
|
|||||||
audio
|
audio
|
||||||
bitmasks
|
bitmasks
|
||||||
character
|
character
|
||||||
|
collision
|
||||||
collision_arena
|
collision_arena
|
||||||
controller
|
controller
|
||||||
draw
|
draw
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ typedef struct akgl_Actor {
|
|||||||
void *actorData; /**< The game's own per-actor data. Never read or freed by the library. */
|
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. */
|
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. */
|
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
|
// Velocity. Combined effect of all forces acting on the actor resulting
|
||||||
// in energy along an axis.
|
// 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. */
|
float32_t vx; /**< Velocity along x, world units per second. Recomputed each step as `ex + tx`; writing it directly is overwritten. */
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
#include <SDL3/SDL_properties.h>
|
#include <SDL3/SDL_properties.h>
|
||||||
#include <akgl/types.h>
|
#include <akgl/types.h>
|
||||||
|
#include <akgl/collision.h>
|
||||||
#include <akgl/sprite.h>
|
#include <akgl/sprite.h>
|
||||||
|
|
||||||
#define AKGL_CHARACTER_MAX_NAME_LENGTH 128
|
#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 sx; /**< Maximum speed along x, world units per second. */
|
||||||
float32_t sy; /**< Maximum speed along y. */
|
float32_t sy; /**< Maximum speed along y. */
|
||||||
float32_t sz; /**< Maximum speed along z. Not read from JSON; stays 0 unless set by hand. */
|
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_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. */
|
akerr_ErrorContext AKERR_NOIGNORE *(*sprite_get)(struct akgl_Character *, int, akgl_Sprite **); /**< Bound to akgl_character_sprite_get by akgl_character_initialize. */
|
||||||
} akgl_Character;
|
} akgl_Character;
|
||||||
|
|||||||
227
include/akgl/collision.h
Normal file
227
include/akgl/collision.h
Normal file
@@ -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 <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 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
|
||||||
11
src/actor.c
11
src/actor.c
@@ -65,6 +65,17 @@ akerr_ErrorContext *akgl_actor_set_character(akgl_Actor *obj, char *basecharname
|
|||||||
obj->ay = 0;
|
obj->ay = 0;
|
||||||
obj->sx = obj->basechar->sx;
|
obj->sx = obj->basechar->sx;
|
||||||
obj->sy = obj->basechar->sy;
|
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);
|
SUCCEED_RETURN(errctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
183
src/collision_shape.c
Normal file
183
src/collision_shape.c
Normal file
@@ -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 <string.h>
|
||||||
|
|
||||||
|
#include <akgl/collision.h>
|
||||||
|
#include <akgl/error.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
324
tests/collision.c
Normal file
324
tests/collision.c
Normal file
@@ -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 <string.h>
|
||||||
|
|
||||||
|
#include <akerror.h>
|
||||||
|
|
||||||
|
#include <akgl/collision.h>
|
||||||
|
#include <akgl/error.h>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -165,6 +165,7 @@ akerr_ErrorContext *test_arena_drives_a_real_query(void)
|
|||||||
bool hit = false;
|
bool hit = false;
|
||||||
float32_t depth = 0.0f;
|
float32_t depth = 0.0f;
|
||||||
size_t highwater = 0;
|
size_t highwater = 0;
|
||||||
|
akerr_ErrorContext *inner = NULL;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
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
|
* 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
|
* cost what one costs. Without that, a no-op free and a bump allocator
|
||||||
* would fill 64 KB in nine queries.
|
* 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++ ) {
|
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),
|
TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= highwater),
|
||||||
"64 queries used %zu bytes where one used %zu; the arena is not being reset",
|
"64 queries used %zu bytes where one used %zu; the arena is not being reset",
|
||||||
akgl_ccd_arena_used(), highwater);
|
akgl_ccd_arena_used(), highwater);
|
||||||
|
|||||||
Reference in New Issue
Block a user