Delete the sidescroller's collision, and let the library do it
collision.c was 383 lines that re-implemented akgl_physics_simulate's own arithmetic, because movementlogicfunc runs before gravity, drag and the move and so had to predict where the actor would end up. The whole file goes. What replaces it is three lines in main.c, a shape on the player and the blob, and a `collidable` property on the map's terrain layer -- which also retires SS_TERRAIN_LAYER_ID, a compiled-in layer id that broke whenever somebody reordered layers in Tiled. grounded stays a probe rather than becoming a contact test. A contact says something pushed back this step; grounded asks whether there is a floor to push off, and that stays true through the frame after landing. The blob keeps its own wall and ledge probes for the same reason: the resolver does not say which side of the blob it was pushed from, and says nothing at all about a floor that is missing. The summary line now carries the player's resting position, because exiting 0 was not evidence that collision ran. It reports 136.0,160.0 grounded after 240 autoplay frames -- feet at y=192, flush on the surface of terrain row 12. The tutorial's collision section is rewritten around the library's, keeping the quarter-of-a-pixel story as what a predicting resolver costs. The docs harness learns `json excerpt=`, so the map property the chapter quotes is checked against the map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
@@ -240,189 +240,135 @@ files, four characters, a map and a tileset image — happened between the backe
|
||||
created and the first step. The `max_timestep` bound would have caught it, at the cost of
|
||||
one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md).
|
||||
|
||||
## The collision libakgl does not have
|
||||
## Collision, in three lines
|
||||
|
||||
Here is the whole problem, stated as three facts about `src/physics.c`:
|
||||
The game does not implement collision. It attaches a world and the library resolves through
|
||||
it:
|
||||
|
||||
- **`akgl_physics_arcade_collide` raises `AKERR_API`** with the message "Not implemented".
|
||||
- **`akgl_physics_simulate` never calls `collide` at all** — not for the arcade backend, not
|
||||
for the null one. The vtable slot exists and the simulation does not use it.
|
||||
- **`akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.** It does
|
||||
not clamp to the map, consult the tilemap, or test anything.
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
PASS(errctx, akgl_collision_world_init(&ss_collision, NULL, (float32_t)SS_TILE_SIZE, (float32_t)SS_TILE_SIZE));
|
||||
PASS(errctx, akgl_collision_bind_tilemap(&ss_collision, akgl_gamemap));
|
||||
akgl_physics->collision = &ss_collision;
|
||||
```
|
||||
|
||||
So an actor walks through a wall and off the edge of the world, and none of that announces
|
||||
itself at compile time. [Chapter 14](14-physics.md) says so plainly and this game is what
|
||||
the consequence looks like.
|
||||
Plus a shape on anything that should collide, which is two lines per actor:
|
||||
|
||||
The only hook the physics step calls is the actor's own `movementlogicfunc`, so that is
|
||||
where the collision goes. And that placement is awkward in exactly one way, which shapes
|
||||
everything in `collision.c`:
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f));
|
||||
obj->shape_override = true;
|
||||
```
|
||||
|
||||
That is the whole of it. [Chapter 15](15-collision.md) is the reference; what follows is
|
||||
what this game had to know.
|
||||
|
||||
### Why it resolves after the move and not before
|
||||
|
||||
The only per-actor hook the physics step used to offer was `movementlogicfunc`, and it runs
|
||||
in the wrong place:
|
||||
|
||||
```text
|
||||
akgl_physics_simulate, per actor:
|
||||
|
||||
tx += ax * dt <- thrust, from the previous step's ax
|
||||
cap (tx,ty,tz) to the speed ellipse
|
||||
movementlogicfunc(actor, dt) <- YOU ARE HERE
|
||||
movementlogicfunc(actor, dt) <- the only hook there used to be
|
||||
gravity(self, actor, dt) ey += gravity_y * dt
|
||||
ex -= ex * drag_x * dt (and y, z)
|
||||
vx = ex + tx (and y, z)
|
||||
move(self, actor, dt) x += vx * dt
|
||||
collide(self, actor, dt) <- where resolution actually happens
|
||||
```
|
||||
|
||||
**Your hook runs before the step it has to resolve.** It cannot look at where the actor
|
||||
ended up, because the actor has not moved yet. So the game predicts the step instead:
|
||||
A resolver in `movementlogicfunc` runs before gravity, drag and the move, so it cannot look
|
||||
at where the actor ended up — the actor has not moved yet. It has to *predict* the step,
|
||||
which means copying the six lines above into the game, `!= 0` guards included, in a file
|
||||
that will not be recompiled when they change.
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
ex = obj->ex;
|
||||
ey = obj->ey;
|
||||
if ( akgl_physics->gravity_x != 0 ) {
|
||||
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
||||
This tutorial used to carry that copy, and 383 lines around it. All of it is gone. `collide`
|
||||
runs after `move` on a position that already happened, so there is nothing to predict.
|
||||
|
||||
**One symptom is worth keeping, because it is what a predicting resolver costs.** The
|
||||
obvious thing for such a resolver to do on a blocked vertical axis is zero `ey`. It is
|
||||
wrong: the step is about to add `gravity_y * dt` back and `move` commits `gravity_y * dt²`
|
||||
of fall — a quarter of a pixel at 900 px/s² and 60 Hz. Invisible, and fatal. That quarter
|
||||
pixel of overlap means the box intersects the floor tile, so on the *next* step the
|
||||
horizontal sweep finds itself blocked wherever it goes and snaps back to a tile boundary.
|
||||
The symptom is a character who cannot walk, jerking backwards by up to a tile every time it
|
||||
tries, and nothing about it looks like a vertical problem. The fix was to pre-load the
|
||||
cancellation — `ey = -gravity_y * dt` — which is arithmetic no game should ever have had to
|
||||
write. Resolving after the move deletes the whole class.
|
||||
|
||||
### Solid is data now, not a layer id compiled into the game
|
||||
|
||||
The old version matched Tiled's numeric layer id, because **`akgl_TilemapLayer` does not
|
||||
record the layer's name** — `akgl_tilemap_load_layers` reads `id`, `opacity`, `visible`,
|
||||
`x`, `y` and `type` and nothing else, so there is no way to ask for "the layer called
|
||||
terrain". A `#define SS_TERRAIN_LAYER_ID 2` broke the moment somebody reordered layers in
|
||||
the editor.
|
||||
|
||||
The map says it instead. `level1.tmj`'s terrain layer carries a custom property:
|
||||
|
||||
```json excerpt=docs/tutorials/assets/sidescroller/level1.tmj
|
||||
"properties": [
|
||||
{
|
||||
"name": "collidable",
|
||||
"type": "bool",
|
||||
"value": true
|
||||
}
|
||||
if ( akgl_physics->gravity_y != 0 ) {
|
||||
ey += (float32_t)akgl_physics->gravity_y * dt;
|
||||
}
|
||||
if ( akgl_physics->drag_x != 0 ) {
|
||||
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
|
||||
}
|
||||
if ( akgl_physics->drag_y != 0 ) {
|
||||
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
|
||||
}
|
||||
*dx = (ex + obj->tx) * dt;
|
||||
*dy = (ey + obj->ty) * dt;
|
||||
]
|
||||
```
|
||||
|
||||
That is `akgl_physics_simulate`'s own arithmetic in its own order, `!= 0` guards included —
|
||||
dropping them would let a zero-gravity axis pick up drag, which the library does not do. Get
|
||||
it wrong and the actor is resolved against a step it never takes.
|
||||
`akgl_collision_bind_tilemap` reads it off every layer and the game names no layer at all.
|
||||
|
||||
Be clear about what this is: a copy of somebody else's implementation, in a file that will
|
||||
not be recompiled when that implementation changes. It is a liability, and it is not a
|
||||
design — it is what the missing `collide` call costs. When `akgl_physics_simulate` grows a
|
||||
collision hook, this function is the first thing to delete.
|
||||
### Standing on something is still measured
|
||||
|
||||
### Solid is a layer, not a flag
|
||||
Nothing in libakgl records whether an actor is on the ground, and a contact does not answer
|
||||
it either — a contact says something pushed back this step, which is a different question
|
||||
from "is there a floor to push off". So it stays a probe:
|
||||
|
||||
There is no per-tile "solid" property. The level says what is solid by which layer a tile is
|
||||
drawn on:
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
PASS(errctx, akgl_collision_shape_bounds(shape, x, y + 1.0f, &feet));
|
||||
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &feet, AKGL_COLLISION_LAYER_STATIC, dest));
|
||||
```
|
||||
|
||||
Finding that layer takes a small piece of knowledge that is easy to lose an afternoon to:
|
||||
**`akgl_TilemapLayer` does not record the layer's name.** `akgl_tilemap_load_layers` reads
|
||||
`id`, `opacity`, `visible`, `x`, `y` and `type` and nothing else, so there is no way to ask
|
||||
for "the layer called terrain". The game matches on Tiled's numeric layer id instead:
|
||||
One pixel, and only one: a taller probe reports a floor the actor is still falling towards,
|
||||
and a jump that fires off it looks like the player jumped out of thin air.
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
||||
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
||||
ss_terrain = &map->layers[i];
|
||||
}
|
||||
```
|
||||
|
||||
Off the left or right edge of the map counts as solid, so the level has walls at its ends.
|
||||
Off the top or the bottom does not: the sky is open, and falling into the pit is the whole
|
||||
point of the pit.
|
||||
|
||||
### Sweeping, not testing
|
||||
|
||||
A fall at 600 px/s with the step bounded to 0.05 s covers 30 pixels, which is nearly two
|
||||
tiles. A single test at the destination walks straight through a floor. So the motion is
|
||||
walked in sub-steps of at most half a tile, each axis separately — testing the axes
|
||||
separately is what lets an actor slide along a wall instead of sticking to it — and a
|
||||
blocked axis is snapped to the boundary it was about to cross rather than simply not moved,
|
||||
so an actor lands flush at whatever speed it arrives:
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
if ( stepy != 0.0f ) {
|
||||
trial = *box;
|
||||
trial.y += stepy;
|
||||
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||
if ( solid == true ) {
|
||||
if ( stepy > 0.0f ) {
|
||||
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
|
||||
} else {
|
||||
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||
}
|
||||
stepy = 0.0f;
|
||||
dest->blocked_y = true;
|
||||
} else {
|
||||
box->y = trial.y;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only a blocked axis is written back to the actor. The free axis is left for
|
||||
`akgl_physics_arcade_move` to advance by exactly the amount that was predicted — writing it
|
||||
here as well would move the actor twice.
|
||||
|
||||
### The quarter of a pixel that breaks everything
|
||||
|
||||
This one cost an afternoon and is the most useful thing in the chapter.
|
||||
|
||||
The obvious thing to do on a blocked axis is to zero the environmental term. It is wrong,
|
||||
and the reason is the ordering again: the step is about to add `gravity_y * dt` back, and
|
||||
`move` commits `gravity_y * dt²` of fall. At 900 px/s² and 60 Hz that is a quarter of a
|
||||
pixel. Invisible — and fatal.
|
||||
|
||||
A quarter of a pixel of overlap means the actor's box intersects the floor tile. On the next
|
||||
step the **horizontal** sweep therefore finds itself blocked wherever it tries to go, and
|
||||
snaps the actor back to a tile boundary. The symptom is a character who cannot walk, jerking
|
||||
backwards by up to a tile every time it tries. Nothing about it looks like a vertical
|
||||
problem.
|
||||
|
||||
The fix is to pre-load the cancellation instead of zeroing:
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
if ( dest->blocked_y == true ) {
|
||||
obj->y = box.y - body->y;
|
||||
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
||||
obj->ty = 0.0f;
|
||||
}
|
||||
```
|
||||
|
||||
After the step's gravity, `ey` is exactly zero; after drag, still zero; `move` commits
|
||||
nothing. A standing actor rests exactly on the surface, forever, at a `y` that is a whole
|
||||
number of pixels.
|
||||
|
||||
### Standing on something is measured, not recorded
|
||||
|
||||
Nothing in libakgl knows whether an actor is on the ground. It is one probe, a pixel below
|
||||
where the box will be when the step finishes:
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
probe = box;
|
||||
probe.y += 1.0f;
|
||||
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
||||
dest->grounded = solid;
|
||||
```
|
||||
|
||||
The verdict is one frame stale by the time the jump reads it, because this hook runs before
|
||||
the step it is deciding about. One frame of coyote time is not something a player can feel.
|
||||
The verdict is one frame stale by the time the jump reads it, because `movementlogicfunc`
|
||||
runs before the step it is deciding about. One frame of coyote time is not something a
|
||||
player can feel.
|
||||
|
||||
### Spawning inside the scenery
|
||||
|
||||
A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the
|
||||
player at x=32 with a step at tile (3,11) — under the right half of the player's frame. The
|
||||
swept resolution cannot help: it stops an actor *entering* terrain and has nothing to say
|
||||
about one that began inside it. What it does instead is refuse every horizontal move,
|
||||
because the box is blocked wherever it goes.
|
||||
player at x=32 with a step at tile (3,11) — under the right half of the player's frame.
|
||||
Resolution cannot help: it stops an actor *entering* geometry and has nothing to say about
|
||||
one that began inside it. What it does instead is refuse every move, so the actor is simply
|
||||
stuck.
|
||||
|
||||
So spawn points get lifted clear once, before the first step:
|
||||
|
||||
```c excerpt=examples/sidescroller/collision.c
|
||||
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
||||
if ( solid == false ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
obj->y -= (float32_t)SS_TILE_SIZE;
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0));
|
||||
```
|
||||
|
||||
The player and the blob both need it, and both end up standing on the block they were
|
||||
overlapping. Failing after four tiles is deliberate: a spawn point buried that deep is a
|
||||
level bug, and a silent nudge would hide it.
|
||||
overlapping. `akgl_collision_settle` refuses loudly after four tiles rather than searching
|
||||
forever: a spawn point buried that deep is a level bug, and a silent nudge would hide it.
|
||||
|
||||
### What the blob still asks for itself
|
||||
|
||||
The blob turns around at a wall or a ledge, and the resolver answers neither question — it
|
||||
says something pushed back, not which side of the blob it was on, and it says nothing at all
|
||||
about a floor that is *missing*. Both are queries:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead));
|
||||
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &ahead, AKGL_COLLISION_LAYER_STATIC, &wall_ahead));
|
||||
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded));
|
||||
```
|
||||
|
||||
This is what the query API is for: asking without being pushed.
|
||||
|
||||
## Making it feel like a platformer
|
||||
|
||||
@@ -508,7 +454,7 @@ The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever t
|
||||
not on the ground:
|
||||
|
||||
```c excerpt=examples/sidescroller/player.c
|
||||
if ( contact.grounded == true ) {
|
||||
if ( ss_game.grounded == true ) {
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
} else {
|
||||
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
@@ -579,12 +525,13 @@ Two rules go with it, both from [Chapter 4](04-errors.md):
|
||||
- The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN`
|
||||
inside one returns past `CLEANUP`.
|
||||
|
||||
The blob is the counter-example: it stays inside the physics step, walks under the level's
|
||||
gravity like the player does, and uses the same swept resolution. It turns around when the
|
||||
sweep blocks it or when there is no floor one pixel past its leading edge:
|
||||
The blob is the counter-example: it stays inside the physics step and walks under the
|
||||
level's gravity like the player does, and the library resolves it the same way. All its hook
|
||||
decides is which way to face next — a wall ahead, or no floor one pixel past its leading
|
||||
edge:
|
||||
|
||||
```c excerpt=examples/sidescroller/actors.c
|
||||
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
||||
if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) {
|
||||
data->facing = -data->facing;
|
||||
```
|
||||
|
||||
|
||||
@@ -1227,7 +1227,14 @@
|
||||
"visible": true,
|
||||
"width": 40,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
"y": 0,
|
||||
"properties": [
|
||||
{
|
||||
"name": "collidable",
|
||||
"type": "bool",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"draworder": "topdown",
|
||||
|
||||
Reference in New Issue
Block a user