A pure rename plus link rewrite and nothing else. Collision is a pluggable subsystem with two implementations, shapes, a response hook and a tile binding; that is a chapter, and burying it inside the physics chapter is the shape this manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and characters and actors are 10, 11 and 12. Kept separate from writing the chapter because **nothing validates a cross-reference target.** The example harness reads fenced blocks; it does not follow links. A rename mixed into five hundred lines of new prose is not reviewable, and a broken link would land silently. As its own commit it is reviewable as a rename, and a grep for dangling `](NN-` targets is clean. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
407 lines
20 KiB
Markdown
407 lines
20 KiB
Markdown
# 13. Tilemaps
|
|
|
|
libakgl does not have a map format. It has a *loader* for somebody else's, and the
|
|
somebody else is the [Tiled map editor](https://www.mapeditor.org/). Levels are authored in
|
|
Tiled, saved as JSON (`.tmj`), and read by `akgl_tilemap_load`.
|
|
|
|
That division is deliberate, and it means this chapter owes you almost nothing about the
|
|
format itself. What a layer is, what a tileset is, how `firstgid` works, how custom
|
|
properties are stored — all of that is
|
|
[the Tiled JSON map format reference](https://doc.mapeditor.org/en/stable/reference/json-map-format/),
|
|
maintained by the people who write the editor. Read it there.
|
|
|
|
What this chapter owes you is the part Tiled cannot tell you: **which subset of the format
|
|
libakgl actually reads, what it refuses, the three things it adds on top, and how big the
|
|
resulting object is.** All four have bitten somebody.
|
|
|
|
## The size of an `akgl_Tilemap`
|
|
|
|
Start here, because it is the one that produces a crash rather than an error message.
|
|
|
|
```text
|
|
sizeof(akgl_Tilemap) = 26,388,008 bytes = 25.2 MiB
|
|
|
|
layers[16] 16 x 1,120,296 = 17.1 MiB 512x512 ints of tile data, plus
|
|
128 x 560-byte objects, per layer
|
|
tilesets[16] 16 x 528,944 = 8.1 MiB 65,536 x 2 ints of tile offsets,
|
|
plus a PATH_MAX image path, each
|
|
everything else = ~0.0 MiB geometry, perspective, physics
|
|
```
|
|
|
|
**An `akgl_Tilemap` belongs in static storage and nowhere else.** A default thread stack is
|
|
8 MiB on Linux; this object is three times that, so a local one blows the stack before the
|
|
loader writes its first byte, and it does so as a segfault with no error context. That is
|
|
why the library's own map, `akgl_default_gamemap`, is a file-scope object in
|
|
`src/game.c`, and why `akgl_gamemap` is a pointer to it rather than a copy.
|
|
|
|
The bounds are fixed at compile time, so the size is fixed too. Every field is present
|
|
whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same
|
|
25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and
|
|
changes the ABI — see [Chapter 22](22-appendix-limits.md).
|
|
|
|
## Limits
|
|
|
|
```c excerpt=include/akgl/tilemap.h
|
|
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
|
#define AKGL_TILEMAP_MAX_WIDTH 512
|
|
/** @brief Tallest map, in tiles. */
|
|
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
|
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
|
#define AKGL_TILEMAP_MAX_LAYERS 16
|
|
/** @brief Tilesets per map. */
|
|
#define AKGL_TILEMAP_MAX_TILESETS 16
|
|
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
|
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
|
/** @brief Longest tileset name. */
|
|
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
|
/** @brief Longest resolved tileset image path. */
|
|
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
|
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
|
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
|
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
|
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
|
```
|
|
|
|
Two of these carry a wrinkle worth knowing before you lay out a level.
|
|
|
|
**The width and height bound is on the product, and it is exclusive.** `akgl_tilemap_load`
|
|
refuses when `width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, so
|
|
262,144 cells is one too many: **a literal 512x512 map is rejected with
|
|
`AKERR_OUTOFBOUNDS`**, and 512x511 is the largest rectangle that loads. The same
|
|
off-by-one applies per tile layer in `akgl_tilemap_load_layer_tile`. Nothing is wrong with
|
|
either check other than the `>=`; it is recorded here because "the maximum is 512x512" is
|
|
what the constant names say and is not what the code does.
|
|
|
|
**The doc comment on `AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` is stale.** It says the loader
|
|
does not enforce it. The loader does — `akgl_tilemap_load_layer_objects` checks `j` at the
|
|
top of the loop body and raises `AKERR_OUTOFBOUNDS` naming the layer, and
|
|
`akgl_tilemap_load_tilesets` does the same against `AKGL_TILEMAP_MAX_TILESETS`. Both were
|
|
genuinely unbounded and both were fixed in 0.5.0 (`TODO.md`, "Found while rewriting the
|
|
Doxygen comments" item 17), with fixtures in `tests/tilemap.c` at exactly 128, 129 and 17.
|
|
Trust the code.
|
|
|
|
## What the loader accepts
|
|
|
|
The loader reads a strict subset of the TMJ format. Everything in this section is a
|
|
requirement, not a suggestion: a key that is absent raises `AKERR_KEY` and a key with the
|
|
wrong JSON type raises `AKERR_TYPE`, both naming what was wanted. See
|
|
[Chapter 4](04-errors.md) for what those statuses mean in libakgl.
|
|
|
|
| Level | Members `akgl_tilemap_load` requires | Notes |
|
|
|---|---|---|
|
|
| Map root | `tilewidth`, `tileheight`, `width`, `height`, `layers`, `tilesets` | `orientation` is read from nowhere and forced to 0; isometric is not implemented |
|
|
| Every layer | `id`, `opacity`, `visible`, `x`, `y`, `type` | A `type` other than the three below keeps these fields and is otherwise skipped |
|
|
| `tilelayer` | `width`, `height`, `data` | `data` must be a plain array of integers |
|
|
| `imagelayer` | `image` | Layer `width`/`height` are taken from the loaded texture, not the JSON |
|
|
| `objectgroup` | `objects` | See below — every object has requirements of its own |
|
|
| Every object | `name`, `x`, `y`, `visible`, `type` | **Including plain rectangles.** An object with no `type` member fails the whole load |
|
|
| Every tileset | `columns`, `firstgid`, `imagewidth`, `imageheight`, `margin`, `spacing`, `tilecount`, `tilewidth`, `tileheight`, `name`, `image` | The **embedded** form; see below |
|
|
|
|
Three of these deserve their own paragraph.
|
|
|
|
**Tilesets must be embedded, not external.** Tiled writes a tileset into the map's
|
|
`tilesets` array in one of two shapes: the whole tileset inline, or a two-member stub
|
|
`{"firstgid": N, "source": "tiles.tsj"}` pointing at a separate file.
|
|
`akgl_tilemap_load_tilesets_each` reads `columns`, `image` and the rest directly out of the
|
|
array element and there is no code anywhere in `src/` that opens a `source` file, so **an
|
|
external tileset reference fails with `AKERR_KEY` on the missing `columns`.** Turn
|
|
*Embed tileset* on when you save, or use Tiled's *Map → Embed Tilesets* command. The
|
|
shipped fixture `tests/assets/testmap.tmj` is embedded, which is what makes it load.
|
|
|
|
**Every object needs a `type` string, and `type` is where the extensions live.** The loader
|
|
reads it on every object in an object group and compares it against `"actor"` and
|
|
`"perspective"`; anything else is recorded in the layer's object array and otherwise
|
|
ignored. An object with the member missing entirely does not reach that comparison — it
|
|
fails the load. If your editor writes the object's class under a different member name,
|
|
that is the drift to check first; Tiled's own field naming has changed across releases, and
|
|
the fixture in this repository is a Tiled 1.8.2 export.
|
|
|
|
**The map file's directory is the root every path resolves against**, so a map and its art
|
|
move together. The two resolvers are not the same, though, and the difference matters:
|
|
|
|
| Path | Resolved by | Consequence |
|
|
|---|---|---|
|
|
| A tileset's `image` | `akgl_path_relative` | Canonicalized against the map's directory; an absolute path works |
|
|
| An image layer's `image` | `aksl_snprintf("%s/%s", ...)` | A plain join. **Not** canonicalized, so an absolute path becomes `/maps//art/sky.png` and does not open |
|
|
|
|
Both are bounded at `AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE` (`PATH_MAX`). The image-layer
|
|
join raises `AKERR_OUTOFBOUNDS` naming both lengths when it does not fit; it used to
|
|
truncate silently, and an over-long path reported itself as a missing file with a name the
|
|
caller never wrote.
|
|
|
|
## libakgl's three extensions to Tiled
|
|
|
|
None of these are part of the TMJ format. They are conventions the loader imposes on
|
|
custom properties and object types, and Tiled will happily author all three without
|
|
knowing what they mean.
|
|
|
|
### 1. Object type `actor` spawns a real actor
|
|
|
|
An object whose `type` is the string `"actor"` does not stay a rectangle. The loader
|
|
claims an actor from the pool, initializes it, binds a character, publishes it in
|
|
`AKGL_REGISTRY_ACTOR`, and copies the object's position, visibility and layer index onto
|
|
it. The object's `name` is the registry key. See [Chapter 12](12-actors.md) for what an
|
|
actor is and [Chapter 11](11-characters.md) for what a character supplies.
|
|
|
|
Two custom properties are required on the object:
|
|
|
|
| Property | Tiled type | Meaning |
|
|
|---|---|---|
|
|
| `character` | `string` | Name of a character already in `AKGL_REGISTRY_CHARACTER` |
|
|
| `state` | `int` | The actor's initial `AKGL_ACTOR_STATE_*` bitmask, **as a number** |
|
|
|
|
**`state` is an integer here, and only here.** Character JSON accepts the string-array form
|
|
— `["AKGL_ACTOR_STATE_ALIVE", "AKGL_ACTOR_STATE_FACE_DOWN"]` — because
|
|
`AKGL_ACTOR_STATE_STRING_NAMES` exists to decode it. `akgl_tilemap_load_layer_object_actor`
|
|
calls `akgl_get_json_properties_integer`, which demands a property Tiled declared as
|
|
`"int"`, so a map that spells the state as names raises `AKERR_TYPE`. Add up the bit values
|
|
from [Chapter 12](12-actors.md) and write the sum.
|
|
|
|
Two consequences of the registry being the identity:
|
|
|
|
- **The characters have to be loaded before the map.** An unregistered `character` name
|
|
comes back as `AKERR_KEY` from `akgl_actor_set_character`, in the middle of a partly
|
|
loaded map.
|
|
- **Two objects naming one actor do not make two actors.** The second placement takes
|
|
another reference on the first and overwrites its position, so the actor ends up wherever
|
|
the last object put it. That is the mechanism for placing one actor from two layers; it
|
|
is not a way to spawn a crowd.
|
|
|
|
An `actor` object with an empty `name` is refused with `AKERR_KEY` — an actor has to be
|
|
addressable.
|
|
|
|
### 2. A `physics.model` property gives the map its own physics
|
|
|
|
A custom property named `physics.model` on the **map root** hands the map's name to
|
|
`akgl_physics_factory` and stores the resulting backend in `map->physics`, then sets
|
|
`map->use_own_physics`. Six more properties configure it, each defaulting to `0.0` when
|
|
absent:
|
|
|
|
| Property | Tiled type | Sets |
|
|
|---|---|---|
|
|
| `physics.model` | `string` | Which backend — `null` or `arcade`. See [Chapter 14](14-physics.md) |
|
|
| `physics.gravity.x` / `.y` / `.z` | `float` | The backend's gravity constants |
|
|
| `physics.drag.x` / `.y` / `.z` | `float` | The backend's drag coefficients |
|
|
|
|
A swimming level and a walking level then differ by data rather than by code.
|
|
|
|
**Nothing in the library acts on `use_own_physics`.** `akgl_game_update` steps the global
|
|
`akgl_physics` and never looks at the map's. The flag is a signal to *you*: read it after
|
|
loading and point `akgl_physics` at `map->physics` if you want it honoured. The example
|
|
below does exactly that.
|
|
|
|
A map with no `properties` array at all, or with properties but no `physics.model`, is the
|
|
normal case: `akgl_tilemap_load_physics` returns success and the map uses the game's global
|
|
backend. The `AKERR_KEY` handlers in that function are the "map did not ask" path, not
|
|
error paths. A `physics.model` naming a backend that does not exist *is* an error.
|
|
|
|
### 3. `p_foreground` and `p_vanishing` define the pseudo-3D band
|
|
|
|
Two objects in an object layer, each with `"type": "perspective"` and a `scale` custom
|
|
property declared `float`, define a band down the screen over which actors are scaled. That
|
|
is what makes a character look further away as they walk up a top-down map.
|
|
|
|
```text
|
|
y = 0 ----------------------------------------------------- top of the map
|
|
.
|
|
. actors above p_vanishing_y stay at
|
|
. p_vanishing_scale
|
|
p_vanishing_y ----------- [tiny actor] scale = p_vanishing_scale
|
|
/ \
|
|
/ \ linear interpolation between the
|
|
/ \ two rows, at p_rate per pixel of y
|
|
/ \
|
|
p_foreground_y --------- [ big actor ] scale = p_foreground_scale
|
|
actors below here stay at
|
|
p_foreground_scale
|
|
```
|
|
|
|
The names are matched literally: `p_foreground` sets the row where actors are at full size,
|
|
`p_vanishing` the row where they are smallest. **Both the type and the name have to be
|
|
right.** The loader checks `type == "perspective"` first and only then compares the name,
|
|
so an object named `p_vanishing` with type `"object"` is silently just an object — no error,
|
|
no perspective, and a very confusing afternoon.
|
|
|
|
Each marker contributes its `y` and its `scale`; `p_rate` is derived at load as the scale
|
|
change per pixel between the two. **A marker at `y = 0` disables perspective**, because the
|
|
rate is only computed when both `p_foreground_y` and `p_vanishing_y` are non-zero — and a
|
|
map with no markers has both scales at 1.0 and a rate of 0, so the whole feature is a no-op
|
|
rather than a special case. Perspective markers are forced invisible: they are geometry,
|
|
not scenery.
|
|
|
|
`akgl_tilemap_scale_actor` applies the band, writing only the actor's `scale`. It is called
|
|
per actor by `akgl_game_update` against the **global** `akgl_gamemap` when the frame's
|
|
iterator carries `AKGL_ITERATOR_OP_TILEMAPSCALE`; see [Chapter 7](07-the-game-and-the-frame.md).
|
|
The marker heights (`p_foreground_h`, `p_vanishing_h`) are recorded and not used by the
|
|
current rate calculation.
|
|
|
|
## A minimal map
|
|
|
|
Two 16-pixel tiles square, one tile layer, one embedded tileset, and one actor object. The
|
|
state value `17` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN` — 16 plus 1, from
|
|
the bit table in [Chapter 12](12-actors.md). Add the values you want; there is no symbolic
|
|
form on this side.
|
|
|
|
```json kind=tilemap setup=tilemap
|
|
{
|
|
"compressionlevel": -1,
|
|
"height": 2,
|
|
"infinite": false,
|
|
"nextlayerid": 3,
|
|
"nextobjectid": 2,
|
|
"orientation": "orthogonal",
|
|
"renderorder": "right-down",
|
|
"tiledversion": "1.8.2",
|
|
"tileheight": 16,
|
|
"tilewidth": 16,
|
|
"type": "map",
|
|
"version": "1.8",
|
|
"width": 2,
|
|
"layers": [
|
|
{
|
|
"data": [1, 2, 3, 4],
|
|
"height": 2,
|
|
"id": 1,
|
|
"name": "Tile Layer 1",
|
|
"opacity": 1,
|
|
"type": "tilelayer",
|
|
"visible": true,
|
|
"width": 2,
|
|
"x": 0,
|
|
"y": 0
|
|
},
|
|
{
|
|
"draworder": "topdown",
|
|
"id": 2,
|
|
"name": "Object Layer 1",
|
|
"opacity": 1,
|
|
"type": "objectgroup",
|
|
"visible": true,
|
|
"x": 0,
|
|
"y": 0,
|
|
"objects": [
|
|
{
|
|
"gid": 195,
|
|
"height": 16,
|
|
"id": 1,
|
|
"name": "testactor",
|
|
"rotation": 0,
|
|
"type": "actor",
|
|
"visible": true,
|
|
"width": 16,
|
|
"x": 16,
|
|
"y": 16,
|
|
"properties": [
|
|
{ "name": "character", "type": "string", "value": "testcharacter" },
|
|
{ "name": "state", "type": "int", "value": 17 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"tilesets": [
|
|
{
|
|
"columns": 48,
|
|
"firstgid": 1,
|
|
"image": "assets/tileset.png",
|
|
"imageheight": 576,
|
|
"imagewidth": 768,
|
|
"margin": 0,
|
|
"name": "World_A1",
|
|
"spacing": 0,
|
|
"tilecount": 1728,
|
|
"tileheight": 16,
|
|
"tilewidth": 16
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
## Loading one
|
|
|
|
```c
|
|
#include <akgl/tilemap.h>
|
|
#include <akgl/error.h>
|
|
#include <akgl/game.h>
|
|
|
|
/*
|
|
* 25 MiB of tilemap. Static storage, never a local: a stack frame this size
|
|
* overruns the default 8 MiB thread stack before the loader writes a byte.
|
|
*/
|
|
static akgl_Tilemap townmap;
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *load_town(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
|
|
/* Reusing a map struct leaks the previous map's textures unless this runs
|
|
* first -- akgl_tilemap_load zeroes rather than releases. */
|
|
PASS(errctx, akgl_tilemap_release(&townmap));
|
|
PASS(errctx, akgl_tilemap_load("assets/town.tmj", &townmap));
|
|
|
|
/* The map may have brought its own physics with it. */
|
|
if ( townmap.use_own_physics == true ) {
|
|
akgl_physics = &townmap.physics;
|
|
}
|
|
akgl_gamemap = &townmap;
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
Loading a map has side effects well beyond the destination struct: textures are uploaded,
|
|
actors are created in the pool, and names are published in the registry. So **the renderer,
|
|
the pools, the registries and every character the map names have to be in place first.**
|
|
The startup order that produces that is in [Chapter 7](07-the-game-and-the-frame.md).
|
|
|
|
Drawing is per layer. `akgl_tilemap_draw(map, viewport, layeridx)` draws one layer clipped
|
|
to a viewport in map pixels — usually `akgl_camera` — and draws the tiles at the viewport's
|
|
edges partially, so scrolling is smooth rather than snapping to the grid. An image layer
|
|
ignores the viewport and is drawn once at the origin. `akgl_render_2d_draw_world` makes one
|
|
pass per layer; see [Chapter 8](08-rendering.md).
|
|
|
|
## Known defects
|
|
|
|
Documented here because this is where a reader hits them. Each is cross-referenced to
|
|
`TODO.md`.
|
|
|
|
**A failed load releases nothing.** `akgl_tilemap_load` zeroes the destination up front and
|
|
then loads physics, geometry, layers and tilesets in order. If any of that fails, the
|
|
textures already uploaded and the actors already created stay exactly where they are, and
|
|
the destination holds a half-built map. `CLEANUP` gives back the JSON document and one pool
|
|
string and nothing else. **Treat a failed load as needing `akgl_tilemap_release` and a
|
|
fresh actor registry** before you try again — otherwise the retry inherits the corpse, and
|
|
`akgl_tilemap_load` will zero the struct over the previous attempt's texture pointers and
|
|
leak them.
|
|
|
|
**`akgl_tilemap_release` does not release the actors an object layer created.** It destroys
|
|
tileset and image-layer textures and clears each pointer as it goes — that half is correct
|
|
as of 0.5.0, and a second release is safe rather than a use-after-free (`TODO.md`, "Known
|
|
and still open" item 2). The actors, and their references on characters and sprites, are
|
|
yours to release. The header's `@warning` describing a tileset double-free is stale; the
|
|
loop it describes was fixed and `tests/tilemap.c` releases the fixture three times and
|
|
asserts every texture pointer is `NULL`.
|
|
|
|
**`akgl_tilemap_draw` does not bounds-check `layeridx`.** An index at or past `numlayers`
|
|
reads a neighbouring layer, and one past 16 reads past the array. `akgl_tilemap_draw_tileset`
|
|
checks its upper bound but not for a negative index.
|
|
|
|
**A tileset's `margin` is recorded and never applied.** `akgl_tilemap_compute_tileset_offsets`
|
|
steps by tile size plus `spacing` and ignores `margin` entirely, so a tileset image with a
|
|
border produces offsets shifted by it — every tile drawn from slightly the wrong place. The
|
|
FIXME is at `src/tilemap.c:187`. Author tilesets with `margin: 0` until it is fixed.
|
|
|
|
**A second tileset wastes its whole offset table.** The offset table is indexed by local
|
|
tile id from 0, so tileset B's entries sit at the front of B's own 65,536-entry table while
|
|
`akgl_tilemap_draw` indexes it by `tilenum - firstgid` — correct, but it means every
|
|
tileset pays 512 KiB regardless of how many tiles it has. The FIXME on
|
|
`akgl_Tileset::tile_offsets` explains it. Consequence for you: prefer one large tileset per
|
|
map to five small ones.
|
|
|
|
**Tileset images do not go through the spritesheet registry**, so an image shared between
|
|
two maps is loaded twice, into two textures.
|
|
|
|
**`numlayers` is set before the layer bound is checked.** `akgl_tilemap_load_layers` assigns
|
|
the JSON array's length and *then* refuses a seventeenth layer, so on that failure path the
|
|
struct briefly claims more layers than it has. It is only reachable on a path that has
|
|
already failed, but do not read `numlayers` off a map whose load raised.
|