Delete the JRPG's collision too, and wall the map with proxies

cell_solid and feet_blocked go, and so does the prediction in the player's
movementlogicfunc. That prediction was only ever exact because the town has zero
gravity and zero drag -- v is t, so `x + tx * dt` is where the step lands. A map
with gravity would have had to fold ey in as well, which is the game
re-implementing the integrator. Resolution runs after the move now, so there is
nothing to predict.

The map's decoration layer carries `collidable`, which retires JRPG_LAYER_SOLID:
akgl_TilemapLayer has no name member, so the game and the map had to agree on an
index out of band and inserting a layer in Tiled broke it.

The edge of the world is not on any layer, so it is four static proxies covering
the outer ring plus a tile of overhang -- one pool slot per side instead of a
hundred solid tiles, and the first use of the static-proxy path in either
example. They carry LAYER_STATIC explicitly: shape_box defaults a shape to
LAYER_ACTOR, and a wall left on that layer is a wall everything walks through.

Only the player gets a shape. NPCs stand still and are spoken to; the follower
is a child snapped to its parent every step.

Verified against the old implementation with the demo script rewritten to hold
one direction: holding left into a building stops the player at x=122 with both,
and holding up into the map's edge stops them at y=-4 with both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 07:46:19 -04:00
parent 490e62dbbf
commit cbf7c1b6c2
5 changed files with 226 additions and 156 deletions

View File

@@ -198,72 +198,105 @@ and `akgl_actor_cmhf_*_off` clears only the movement bit on the way up — so th
facing an actor is left with is exactly the one it was walking in. The automatic
`facefunc` is for an actor whose facing is not otherwise decided.
## Walls, because the library has none
## Walls
There is no collision in libakgl. Not "a simple one" — none:
The library resolves them ([Chapter 15](15-collision.md)); this game says which
things are solid and gets out of the way.
- `akgl_physics_arcade_collide` raises `AKERR_API` with the message
`"Not implemented"`.
- `akgl_physics_simulate` never calls `collide` at all, so that status is
unreachable through the normal path.
- `akgl_physics_arcade_move` is `x += vx * dt` on three axes and consults no
tilemap, no other actor and no bound of any kind.
Two rules, and they arrive by two different mechanisms because they are two
different kinds of geometry. Every non-empty cell of the `decoration` layer — a
building, a tree, a lamp post — is solid, and the layer says so itself:
An actor therefore walks through a building and off the edge of the world.
[Chapter 14](14-physics.md) lists this among what is not implemented. A game
that wants a wall writes one, and the place to write it is the actor's
`movementlogicfunc` — the one hook the physics step calls *before* it commits
anything.
The rule this game applies is that the outer ring of the map is solid and every
non-empty cell of the `decoration` layer is solid:
```c excerpt=examples/jrpg/world.c
if ( (tx <= 0) || (ty <= 0) ||
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
return true;
```json excerpt=docs/tutorials/assets/jrpg/town.tmj
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
]
```
`JRPG_LAYER_SOLID` is the number 1, not the string `"decoration"`, and that is
the second workaround. **`akgl_TilemapLayer` has no `name` member.** The loader
reads a layer's `id`, `type`, `opacity`, `visible` and offset, and drops the
name Tiled wrote — the only `"name"` an object layer keeps is the one on each
*object*. So a game cannot say "the layer called collision"; the map and the
program agree on an index, and if somebody inserts a layer in Tiled the game
starts colliding with the scenery. This is not in `TODO.md`.
That replaces a `#define JRPG_LAYER_SOLID 1`, and the replacement matters more
than it looks. **`akgl_TilemapLayer` has no `name` member** — the loader reads a
layer's `id`, `type`, `opacity`, `visible` and offset and drops the name Tiled
wrote, so a game could not say "the layer called decoration" and had to agree
with the map on an *index*. Insert a layer in Tiled and the game starts colliding
with the scenery. Now the map carries the answer and the game names no layer at
all.
Then the hook itself. Two things make the prediction below exact rather than a
guess, and both are worth understanding before you copy it:
- By the time a `movementlogicfunc` runs, `akgl_physics_simulate` has **already**
integrated this step's thrust and capped it against the character's speed
ellipse. `tx` and `ty` are this step's final thrust.
- Velocity is recomputed as `e + t` every step, and this map has zero gravity and
zero drag, so `e` stays zero and `v` *is* `t`. A map with gravity would have to
fold `ey` into the prediction as well.
The second rule is the edge of the world, which is not on any layer. That is four
static proxies:
```c excerpt=examples/jrpg/world.c
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) {
obj->tx = 0.0f;
obj->ax = 0.0f;
}
if ( feet_blocked(obj->x, obj->y + (obj->ty * dt)) ) {
obj->ty = 0.0f;
obj->ay = 0.0f;
}
PASS(errctx, akgl_heap_next_collision_proxy(&jrpg_edges[i]));
PASS(errctx, akgl_collision_shape_box(&jrpg_edge_shapes[i], &sides[i], 0.0f));
jrpg_edge_shapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
jrpg_edge_shapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
jrpg_edge_shapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;
```
One axis at a time, so walking diagonally into a wall slides along it instead of
stopping dead. Zeroing thrust rather than moving the actor back is what keeps
this inside the library's model: the arcade backend will still do its own
`x += vx * dt` a few lines later, and it will add zero.
Four proxies rather than a hundred solid tiles, because a long thin box costs one
pool slot whatever its length — this is what proxies are for, and tiles are the
other mechanism precisely because there are a hundred thousand of them.
`feet_blocked` tests a 20×10 footprint at the bottom of the 32×32 frame rather
than the whole frame, because a character stands on their feet and every
doorway in the town is one tile wide. Four corners is enough only because the
footprint is smaller than a tile in both directions.
Three details in five lines:
- **`layermask` is set by hand.** `akgl_collision_shape_box` defaults a shape to
`AKGL_COLLISION_LAYER_ACTOR`, which is right for an actor and wrong for
scenery. An actor responds to `AKGL_COLLISION_LAYER_STATIC` and nothing else,
so a wall left on the default layer is a wall everything walks through.
- **`collidemask` is cleared.** The wall is not asking to be pushed out of
anything.
- **The acquire and the initialize are adjacent, with nothing between them.**
`akgl_heap_next_collision_proxy` finds a free slot and does *not* claim it; the
initialize takes the reference. Between the two lines a second acquire returns
the same pointer. [Chapter 5](05-the-heap.md) covers why the pools are built
that way.
The walls cover the map's outermost ring of cells *and* extend a tile beyond it.
The ring is what the art expects; the overhang is what stops an actor arriving
fast enough from being resolved out the far side of a wall exactly one tile
thick.
Then the player gets a footprint:
```c excerpt=examples/jrpg/world.c
body = (SDL_FRect){ FEET_X, FEET_TOP, FEET_W, FEET_H };
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
player->shape_override = true;
```
A 20×10 box at the bottom of the 32×32 frame rather than the whole frame, because
a character stands on their feet and every doorway in the town is one tile wide.
Testing the whole frame would make every corridor two tiles wide.
**Only the player gets one.** The NPCs stand still and are spoken to, not walked
into; shapes on them would make the town a pinball table. The follower is a
child, snapped to the player's position every step, so a shape on it would fight
the snap rather than stop anything. That is what the default masks are for — an
actor with a shape collides with map geometry and with no other actor until you
add a bit.
### What went away
This chapter used to carry a `movementlogicfunc` that *predicted* the step:
```text
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) { obj->tx = 0.0f; ... }
```
It worked, and it came with a qualifier: velocity is recomputed as `e + t` every
step, and it was only exact because this map has zero gravity and zero drag, so
`e` stays zero and `v` *is* `t`. A map with gravity would have had to fold `ey`
into the prediction as well — which is the game re-implementing the library's
integrator, in a file that would not be recompiled when the integrator changed.
Resolution runs after the move now, on a position that already happened, so
there is nothing to predict and the qualifier goes away with it. Both this game
and the sidescroller land in exactly the same pixel they did before: holding left
into a building stops the player at x=122, and holding up into the map's edge
stops them at y=-4, with either implementation.
## Freezing the world with `AKGL_ERR_LOGICINTERRUPT`
@@ -639,18 +672,18 @@ jrpg: 320 frames, player at (280, 146)
## What this costs
Six things in this program exist because the library does not do them. None of
Four things in this program exist because the library does not do them. None of
them is hidden, and none of them is a criticism of a design — they are the
current state of a library that is honest about being unfinished.
current state of a library that is honest about being unfinished. Two more were
here in 0.7.0 and are gone: the hard-coded collision layer index, and the
tile-based blocking in the player's `movementlogicfunc`. Collision closed both.
| # | Gap | What this game does | Recorded in |
|---|---|---|---|
| 1 | `movement_controls_face` erases the facing bit of any actor that is not moving, so every map-spawned actor stops being drawn on frame one | Clears the field on every live actor after loading the map | `actor.h` `@note`; a `TODO` in `src/actor.c`. Not in `TODO.md` |
| 2 | `akgl_TilemapLayer` has no `name`, so a collision layer cannot be found by name | Hard-codes the layer index in `JRPG_LAYER_SOLID` | Not recorded anywhere |
| 3 | `akgl_default_physics` is zeroed BSS and `akgl_game_init` never calls the factory; `akgl_game_update` calls `simulate` through a `NULL` pointer | Calls `akgl_physics_init_arcade` explicitly, and switches to the map's backend when it declares one | The false `physics.engine` claim in `physics.h` is in the plan's out-of-scope list |
| 4 | No collision of any kind: `arcade_collide` raises `AKERR_API`, `simulate` never calls it, `arcade_move` clamps nothing | Tile-based blocking in the player's `movementlogicfunc` | `TODO.md`; [Chapter 14](14-physics.md) |
| 5 | A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice | A `renderfunc` that detaches the parent for the duration of the draw | **Not in `TODO.md`.** `actor.h` documents both readings |
| 6 | `akgl_tilemap_release` double-frees tileset textures and never frees image-layer ones | Does not call it; lets `SDL_Quit` reclaim them | `TODO.md`, "Known and still open" item 2 |
| 2 | `akgl_default_physics` is zeroed BSS and `akgl_game_init` never calls the factory; `akgl_game_update` calls `simulate` through a `NULL` pointer | Calls `akgl_physics_init_arcade` explicitly, and switches to the map's backend when it declares one | The false `physics.engine` claim in `physics.h` is in the plan's out-of-scope list |
| 3 | A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice | A `renderfunc` that detaches the parent for the duration of the draw | **Not in `TODO.md`.** `actor.h` documents both readings |
| 4 | `akgl_tilemap_release` double-frees tileset textures and never frees image-layer ones | Does not call it; lets `SDL_Quit` reclaim them | `TODO.md`, "Known and still open" item 2 |
Two more that are not workarounds but will bite anyone extending this: `akgl_Actor::layer`
is a `uint32_t` with no bound, while `akgl_render_2d_draw_world` stops at