Let a map say which layers are solid, and answer questions about them
A tile layer with a `collidable` boolean custom property is collision geometry. Before this a game had to hard-code a layer index -- both examples do, because akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes the moment somebody reorders layers in the editor. Solid tiles are **not** given proxies. At the maximum map size that is a quarter of a million per layer, tens of megabytes of index to describe data that is already a dense grid sitting in the tilemap. The world keeps a borrowed pointer and reads layers[i].data[] over whatever cell range a query covers: nine array reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load, and nothing to maintain per frame. Static geometry that is not tile-aligned is still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist and tiles use the free one because there are a hundred thousand of them. Four public queries come with it, all of which answer without resolving: solid_at, box_blocked, query_box and settle. They are what a game reaches for when it wants to know rather than to be pushed -- a ledge probe ahead of a walking enemy, a check that a doorway is clear -- and the sidescroller cannot drop its hand-rolled collision without them. akgl_collision_settle is the one worth naming. Resolution stops a shape entering geometry and has nothing to say about one that began inside it: what it does instead is refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that constantly, so settling walks a shape up a tile at a time and refuses loudly rather than searching forever. Two defects found by writing the tests: - The fixture put an akgl_Tilemap on the stack and segfaulted before the first assertion. It is about 26 MB -- the layer and tileset arrays are sized for the worst case the format allows -- and tilemap.h says so. It is static now, with a comment saying why, because the next person to write a map fixture will reach for a local first as well. - The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a sensible tolerance on a unit vector and a meaningless one on a map coordinate: `float` carries about seven significant digits, so at a coordinate of 144 the smallest representable step is around 1.5e-5 and `144.0f - 1e-6f` is exactly 144.0f. The nudge did nothing, a box resting flush on the floor read as inside it, and every move it tried looked blocked -- an actor standing on the ground unable to walk. Two different quantities were sharing one constant; the tile one is now its own, at a thousandth of a pixel, which is what the sidescroller example independently arrived at. Both breaks verified: removing the nudge and ignoring the `collidable` bit each turn the suite red with the symptom named. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
227
src/collision.c
227
src/collision.c
@@ -17,6 +17,7 @@
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/collision_arena.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
#include <ccd/ccd.h>
|
||||
@@ -37,6 +38,24 @@
|
||||
/** @brief Below this, a normal is treated as having no length at all. */
|
||||
#define AKGL_COLLISION_EPSILON 1e-6f
|
||||
|
||||
/**
|
||||
* @brief Nudge applied to a box's far edge before it is turned into tile indices.
|
||||
*
|
||||
* A thousandth of a pixel, and **not** #AKGL_COLLISION_EPSILON. That one is a
|
||||
* tolerance on a unit vector, where 1e-6 is meaningful; this one is subtracted
|
||||
* from a map coordinate, where it is not. `float` has about seven significant
|
||||
* digits, so at a coordinate of 144 the smallest representable step is around
|
||||
* 1.5e-5 and `144.0f - 1e-6f` is exactly 144.0f. The nudge does nothing, the far
|
||||
* edge lands on the next tile, and an actor standing flush on the ground reads
|
||||
* as inside it -- which makes every move it tries look blocked.
|
||||
*
|
||||
* The sidescroller example arrived at the same number for the same reason.
|
||||
*/
|
||||
#define AKGL_COLLISION_TILE_EPSILON 0.001f
|
||||
|
||||
/** @brief Tiles akgl_collision_settle will lift a shape before giving up. */
|
||||
#define AKGL_COLLISION_SETTLE_STEPS 4
|
||||
|
||||
/** @brief One positioned shape, in the form libccd's callbacks read. */
|
||||
typedef struct {
|
||||
uint8_t kind; /**< AKGL_COLLISION_SHAPE_*. */
|
||||
@@ -443,8 +462,216 @@ akerr_ErrorContext *akgl_collision_world_init(akgl_CollisionWorld *self, char *t
|
||||
// Every game today is 2D, so the guard is on by default. A caller with a
|
||||
// real third axis to resolve on clears it.
|
||||
self->flags = AKGL_COLLISION_TEST_PLANAR;
|
||||
self->tilelayermask = AKGL_COLLISION_LAYER_STATIC;
|
||||
|
||||
PASS(errctx, akgl_partitioner_factory(&self->partitioner, type));
|
||||
PASS(errctx, self->partitioner.reset(&self->partitioner, self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Is a tile coordinate solid on any of the world's collidable layers?
|
||||
*
|
||||
* Reads the tilemap's own array. A gid of 0 is an empty cell; anything else is
|
||||
* a tile, and whether a tile is solid is decided by which layer it is drawn on,
|
||||
* not by the tile itself. Off the map is not solid -- see akgl_collision_solid_at.
|
||||
*/
|
||||
static bool collision_tile_solid(akgl_CollisionWorld *self, int32_t tx, int32_t ty)
|
||||
{
|
||||
akgl_Tilemap *map = self->tilesource;
|
||||
int layer = 0;
|
||||
|
||||
if ( (map == NULL) || (self->tilelayers == 0) ) {
|
||||
return false;
|
||||
}
|
||||
if ( (tx < 0) || (ty < 0) || (tx >= map->width) || (ty >= map->height) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for ( layer = 0; layer < map->numlayers; layer++ ) {
|
||||
if ( (self->tilelayers & (1u << layer)) == 0 ) {
|
||||
continue;
|
||||
}
|
||||
// Row-major, strided by the map's width rather than the array's, which
|
||||
// is how akgl_tilemap_draw indexes it too.
|
||||
if ( map->layers[layer].data[(ty * map->width) + tx] != 0 ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_bind_tilemap(akgl_CollisionWorld *self, akgl_Tilemap *map)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
|
||||
if ( map == NULL ) {
|
||||
self->tilesource = NULL;
|
||||
self->tilelayers = 0;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
FAIL_NONZERO_RETURN(errctx, (map->tilewidth <= 0), AKERR_VALUE,
|
||||
"Map tile width %d is not positive", map->tilewidth);
|
||||
FAIL_NONZERO_RETURN(errctx, (map->tileheight <= 0), AKERR_VALUE,
|
||||
"Map tile height %d is not positive", map->tileheight);
|
||||
|
||||
self->tilesource = map;
|
||||
self->tilelayers = map->collidablelayers;
|
||||
|
||||
/*
|
||||
* Cells are keyed on the map's tiles. That is the sizing the performance
|
||||
* record argues for, and it makes the tile scan below exactly one cell's
|
||||
* worth of array reads per cell a query touches.
|
||||
*/
|
||||
self->cellwidth = (float32_t)map->tilewidth;
|
||||
self->cellheight = (float32_t)map->tileheight;
|
||||
PASS(errctx, self->partitioner.reset(&self->partitioner, self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_solid_at(akgl_CollisionWorld *self, float32_t x, float32_t y, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference");
|
||||
|
||||
*dest = false;
|
||||
if ( self->tilesource == NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
// floorf, not a cast: truncation rounds toward zero, so -0.5 and +0.5 would
|
||||
// land in the same tile and a coordinate just off the left edge of the map
|
||||
// would read as being on it.
|
||||
*dest = collision_tile_solid(self,
|
||||
(int32_t)floorf(x / (float32_t)self->tilesource->tilewidth),
|
||||
(int32_t)floorf(y / (float32_t)self->tilesource->tileheight));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Carries the answer out of a query visitor. */
|
||||
typedef struct {
|
||||
SDL_FRect box;
|
||||
bool blocked;
|
||||
} collision_blocked_probe;
|
||||
|
||||
static akerr_ErrorContext *collision_blocked_visit(akgl_CollisionProxy *proxy, void *data)
|
||||
{
|
||||
collision_blocked_probe *probe = (collision_blocked_probe *)data;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy in a blocked probe");
|
||||
FAIL_ZERO_RETURN(errctx, probe, AKERR_NULLPOINTER, "NULL probe reference");
|
||||
|
||||
if ( (proxy->shape.flags & AKGL_COLLISION_FLAG_SENSOR) != 0 ) {
|
||||
// A sensor reports, it does not block. Walking through a coin is not
|
||||
// being blocked by it.
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( SDL_HasRectIntersectionFloat(&proxy->bounds, &probe->box) ) {
|
||||
probe->blocked = true;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_box_blocked(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, bool *dest)
|
||||
{
|
||||
collision_blocked_probe probe;
|
||||
int32_t tx = 0;
|
||||
int32_t ty = 0;
|
||||
int32_t x0 = 0;
|
||||
int32_t y0 = 0;
|
||||
int32_t x1 = 0;
|
||||
int32_t y1 = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "NULL box reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL result reference");
|
||||
|
||||
*dest = false;
|
||||
|
||||
if ( ((mask & self->tilelayermask) != 0) && (self->tilesource != NULL) ) {
|
||||
/*
|
||||
* The far edge is nudged inward. Without it a box whose right edge sits
|
||||
* exactly on a tile boundary reads as overlapping the tile beyond it,
|
||||
* and an actor standing flush against a wall is reported as inside it --
|
||||
* which makes every move it tries look blocked.
|
||||
*/
|
||||
x0 = (int32_t)floorf(box->x / (float32_t)self->tilesource->tilewidth);
|
||||
y0 = (int32_t)floorf(box->y / (float32_t)self->tilesource->tileheight);
|
||||
x1 = (int32_t)floorf(((box->x + box->w) - AKGL_COLLISION_TILE_EPSILON) / (float32_t)self->tilesource->tilewidth);
|
||||
y1 = (int32_t)floorf(((box->y + box->h) - AKGL_COLLISION_TILE_EPSILON) / (float32_t)self->tilesource->tileheight);
|
||||
|
||||
for ( ty = y0; ty <= y1; ty++ ) {
|
||||
for ( tx = x0; tx <= x1; tx++ ) {
|
||||
if ( collision_tile_solid(self, tx, ty) ) {
|
||||
*dest = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memset(&probe, 0x00, sizeof(probe));
|
||||
probe.box = *box;
|
||||
PASS(errctx, self->partitioner.query(&self->partitioner, box, mask, &collision_blocked_visit, &probe));
|
||||
*dest = probe.blocked;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_query_box(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, akgl_CollisionVisitFunc visit, void *data)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "NULL box reference");
|
||||
FAIL_ZERO_RETURN(errctx, visit, AKERR_NULLPOINTER, "NULL visitor reference");
|
||||
|
||||
PASS(errctx, self->partitioner.query(&self->partitioner, box, mask, visit, data));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collision_settle(akgl_CollisionWorld *self, akgl_CollisionShape *shape, float32_t *x, float32_t *y, int maxsteps)
|
||||
{
|
||||
SDL_FRect box;
|
||||
bool blocked = false;
|
||||
float32_t step = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL collision world reference");
|
||||
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
|
||||
FAIL_ZERO_RETURN(errctx, x, AKERR_NULLPOINTER, "NULL x reference");
|
||||
FAIL_ZERO_RETURN(errctx, y, AKERR_NULLPOINTER, "NULL y reference");
|
||||
|
||||
if ( maxsteps <= 0 ) {
|
||||
maxsteps = AKGL_COLLISION_SETTLE_STEPS;
|
||||
}
|
||||
step = self->cellheight;
|
||||
if ( step <= 0.0f ) {
|
||||
step = 1.0f;
|
||||
}
|
||||
|
||||
for ( i = 0; i <= maxsteps; i++ ) {
|
||||
PASS(errctx, akgl_collision_shape_bounds(shape, *x, *y, &box));
|
||||
PASS(errctx, akgl_collision_box_blocked(self, &box, AKGL_COLLISION_LAYER_STATIC, &blocked));
|
||||
if ( blocked == false ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
// Up, because in a game with gravity the free space is above and the
|
||||
// floor is below. Lifting is the move that does not drop a character
|
||||
// through the world.
|
||||
*y -= step;
|
||||
}
|
||||
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"A shape at %f, %f is still inside solid geometry after being lifted %d tiles; "
|
||||
"the object is probably placed wrong in the level rather than the limit being low",
|
||||
*x,
|
||||
*y,
|
||||
maxsteps
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,16 @@ akerr_ErrorContext *akgl_get_json_properties_string(json_t *obj, char *key, akgl
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_properties_boolean(json_t *obj, char *key, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *property = NULL;
|
||||
PASS(errctx, akgl_get_json_tilemap_property(obj, key, "bool", &property));
|
||||
PASS(errctx, akgl_get_json_boolean_value(property, "value", dest));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_properties_integer(json_t *obj, char *key, int *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -394,6 +404,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_tile(akgl_Tilemap *dest, json_t *roo
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *layerdata = NULL;
|
||||
bool collidable = false;
|
||||
int j;
|
||||
int layerdatalen;
|
||||
|
||||
@@ -416,6 +427,27 @@ akerr_ErrorContext *akgl_tilemap_load_layer_tile(akgl_Tilemap *dest, json_t *roo
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
/*
|
||||
* Which layers are solid is the map's business, not the game's. Before this
|
||||
* a game had to hard-code a layer index -- both example games do, because
|
||||
* akgl_TilemapLayer does not retain the name Tiled wrote -- and that index
|
||||
* changes the moment somebody reorders layers in the editor.
|
||||
*
|
||||
* A layer with no `collidable` property is not solid. That is the common
|
||||
* case, it is not an error, and it is why this is its own ATTEMPT block with
|
||||
* a HANDLE rather than a CATCH in the one above.
|
||||
*/
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_properties_boolean(root, "collidable", &collidable));
|
||||
if ( collidable == true ) {
|
||||
dest->collidablelayers |= (1u << layerid);
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_KEY) {
|
||||
// No such property. Not solid, and not a complaint.
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user