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:
@@ -1,14 +1,14 @@
|
||||
# 20. Tutorial: a 2D sidescroller
|
||||
|
||||
A complete game: a Tiled level, a player who runs and jumps, platforms that hold him up,
|
||||
four coins to collect, a blob that patrols and a moth that flies. It is about nine hundred
|
||||
lines of C — a header and four translation units, and rather more comment than that — and it
|
||||
builds and runs as part of this repository's ordinary `ctest` run.
|
||||
four coins to collect, a blob that patrols and a moth that flies. It is a header and three
|
||||
translation units, rather more comment than code, and it builds and runs as part of this
|
||||
repository's ordinary `ctest` run.
|
||||
|
||||
It is here for one reason above the others. **libakgl has no collision detection at all**,
|
||||
and a sidescroller is the shortest path to finding that out. So this chapter is mostly about
|
||||
what you write when the engine stops, and where exactly that code has to live for the frame
|
||||
to come out right.
|
||||
It used to be four translation units. The fourth was 383 lines of collision, and it is gone
|
||||
— which makes this chapter as much about **where in the frame a thing has to happen** as
|
||||
about what to write. That is the lesson that survived: the same test, run a few lines
|
||||
earlier, is wrong in ways that take an afternoon to diagnose.
|
||||
|
||||
The program is `examples/sidescroller/`; the art, the map and the JSON are in
|
||||
`docs/tutorials/assets/sidescroller/`. Every listing below is quoted straight out of those
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1227,7 +1227,14 @@
|
||||
"visible": true,
|
||||
"width": 30,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
"y": 0,
|
||||
"properties": [
|
||||
{
|
||||
"name": "collidable",
|
||||
"type": "bool",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"draworder": "topdown",
|
||||
|
||||
Reference in New Issue
Block a user