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:
2026-08-02 06:38:12 -04:00
parent 68e042bd47
commit e632abf720
5 changed files with 570 additions and 0 deletions

View File

@@ -302,6 +302,10 @@ typedef struct akgl_CollisionWorld {
uint32_t flags; /**< `AKGL_COLLISION_TEST_*` bits applied to every test this world runs. */
uint32_t sweep; /**< Serial of the current pass. Stamped onto proxies so one spanning several cells is reported once. */
uint32_t tests; /**< Narrowphase calls during the last pass. Diagnostic. */
struct akgl_Tilemap *tilesource;/**< The map whose solid tile layers are static geometry, or `NULL` for none. Borrowed. */
uint32_t tilelayers; /**< Which of that map's layers are solid, copied from its `collidablelayers`. */
uint32_t tilelayermask; /**< The collision layer solid tiles are treated as being on. #AKGL_COLLISION_LAYER_STATIC by default. */
} akgl_CollisionWorld;
/**
@@ -347,6 +351,109 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_partitioner_factory(akgl_Partitioner *se
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_partitioner_init_grid(akgl_Partitioner *self);
/**
* @brief Make a map's solid layers count as collision geometry.
*
* @section collision_tiles Tiles are read, not registered
*
* A map's solid tiles are **not** given proxies. At the maximum map size that
* would be a quarter of a million of them per layer -- tens of megabytes of
* index, to describe data that is already a dense grid sitting in the tilemap.
* Instead the world keeps a borrowed pointer to the map and reads
* `layers[i].data[]` directly over whatever cell range a query covers, which is
* nine array reads for a 32-pixel actor on 16-pixel tiles and costs nothing to
* maintain when a level loads.
*
* Static geometry that is *not* tile-aligned -- a slope, a Tiled object
* rectangle, a platform that only moves between levels -- is still an ordinary
* proxy carrying #AKGL_COLLISION_FLAG_STATIC. Both mechanisms exist; tiles use
* the free one because there are a hundred thousand of them.
*
* @param self The world. Required.
* @param map The map, or `NULL` to detach the one already bound.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
* @throws AKERR_VALUE If the map's tile dimensions are not positive.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_bind_tilemap(akgl_CollisionWorld *self, struct akgl_Tilemap *map);
/**
* @brief Is the tile under this point solid?
*
* A point query against the bound map's collidable layers, and nothing else --
* it does not consult proxies. Off the map is **not** solid: a game that wants
* an edge of the world puts one there, and a pit that kills the player has to be
* a pit rather than a wall.
*
* @param self The world. Required.
* @param x World x in map pixels.
* @param y World y in map pixels.
* @param dest Receives whether a solid tile covers that point. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p self or @p dest is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_solid_at(akgl_CollisionWorld *self, float32_t x, float32_t y, bool *dest);
/**
* @brief Would this rectangle overlap anything solid?
*
* Tiles and proxies both. This is the query a game reaches for when it wants an
* answer without a response: a ledge probe ahead of a walking enemy, a check
* that a spawn point is clear, a door that only opens when nothing is standing
* in it.
*
* @param self The world. Required.
* @param box The rectangle, in map pixels. Required.
* @param mask Which collision layers count. #AKGL_COLLISION_LAYER_ALL for
* everything, #AKGL_COLLISION_LAYER_STATIC for map geometry only.
* @param dest Receives the answer. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If any pointer argument is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_box_blocked(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, bool *dest);
/**
* @brief Visit every proxy that may overlap a rectangle.
*
* The broad phase, exposed. Tiles are not proxies and so are not visited; use
* akgl_collision_box_blocked when the question includes map geometry.
*
* @param self The world. Required.
* @param box The rectangle, in map pixels. Required.
* @param mask Which collision layers to report.
* @param visit Called once per candidate. Required.
* @param data Passed through to @p visit.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If any pointer argument is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_query_box(akgl_CollisionWorld *self, SDL_FRect *box, uint32_t mask, akgl_CollisionVisitFunc visit, void *data);
/**
* @brief Lift a shape out of the geometry it was placed inside.
*
* Resolution stops a shape *entering* geometry and has nothing to say about one
* that started inside it -- what it does instead is refuse every move, so an
* actor spawned in a wall is simply stuck. That is not a hypothetical: level
* authors place a character on a step and Tiled rounds the object to a position
* that overlaps the tile below it.
*
* This walks the shape up a tile at a time until it is clear, and refuses rather
* than searching forever if it cannot be. Call it once, after a map load, for
* anything the map placed.
*
* @param self The world. Required.
* @param shape The shape being placed. Required.
* @param x Position in map pixels. Read and written.
* @param y Position in map pixels. Read and written; this is what moves.
* @param maxsteps How many tiles to try before giving up. 0 uses a sensible default.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If any pointer argument is `NULL`.
* @throws AKERR_VALUE If the shape is still inside geometry after @p maxsteps.
* The message carries the position, because the answer is almost always
* to move the object in the level rather than to raise the limit.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_settle(akgl_CollisionWorld *self, akgl_CollisionShape *shape, float32_t *x, float32_t *y, int maxsteps);
/*
* The following is part of the internal API. Proxies are created and destroyed
* by the library, not by a game.