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

@@ -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

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
```json excerpt=docs/tutorials/assets/jrpg/town.tmj
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
]
```
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.
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
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;
```
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.
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 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:
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
if ( (tx <= 0) || (ty <= 0) ||
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
return true;
}
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
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;
```
`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`.
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.
Then the hook itself. Two things make the prediction below exact rather than a
guess, and both are worth understanding before you copy it:
**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.
- 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.
### What went away
```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;
}
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; ... }
```
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.
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.
`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.
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

View File

@@ -1227,7 +1227,14 @@
"visible": true,
"width": 30,
"x": 0,
"y": 0
"y": 0,
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
]
},
{
"draworder": "topdown",

View File

@@ -19,6 +19,7 @@
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/collision.h>
#include <akgl/types.h>
/*
@@ -49,16 +50,6 @@
/** @brief Point size the font is rasterized at. A size is baked into the handle. */
#define JRPG_FONT_SIZE 12
/**
* @brief Index of the tile layer this game treats as solid.
*
* An index, not a name, because akgl_TilemapLayer has no `name` member: the
* loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data,
* and drops the name Tiled wrote. A map cannot say "the layer called
* collision", so the game and the map agree on a number. See chapter 20.
*/
#define JRPG_LAYER_SOLID 1
/** @brief Longest line an NPC can say, terminator included. */
#define JRPG_TEXTBOX_MAX_TEXT 256
@@ -89,6 +80,15 @@ typedef struct {
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
/**
* @brief The collision world, owned by world.c.
*
* The physics backend resolves through it after every sub-move. Nothing else in
* the game touches it: the town is walls and people, and the walls are the
* map's own decoration layer plus four proxies around the edge.
*/
extern akgl_CollisionWorld jrpg_collision;
/**
* @brief Fix up the actors the map spawned, and attach the party member.
*
@@ -108,13 +108,13 @@ akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
*/
akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
/**
* @brief The player's `movementlogicfunc`: the library default, plus walls.
* @brief The player's `movementlogicfunc`: the library default, plus the freeze.
*
* libakgl implements no collision at all -- akgl_physics_arcade_collide raises
* AKERR_API, akgl_physics_simulate never calls `collide`, and
* akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall
* writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open,
* which is the documented way to tell the simulator to skip an actor's tick.
* Walls are the library's now -- see chapter 15 -- so all this adds to
* akgl_actor_logic_movement is raising AKGL_ERR_LOGICINTERRUPT while the text
* box is open, which is the documented way to tell the simulator to skip an
* actor's tick. That also skips `collide`, since the CATCH around
* movementlogicfunc jumps past the whole rest of the step.
*
* @param obj The actor to compute acceleration for. Required, along with its
* `basechar`.

View File

@@ -6,7 +6,8 @@
* Almost nothing here is game logic. It is the four steps between a directory
* of JSON and a world with people standing in it, in the one order that works,
* plus the two hooks -- a `movementlogicfunc` and a `renderfunc` -- that a game
* has to supply because the library does not.
* has to supply because the library does not, and the collision world the
* library resolves through.
*/
#include <limits.h>
@@ -19,6 +20,7 @@
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/collision.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
@@ -92,10 +94,26 @@ static jrpg_Townsfolk TOWNSFOLK[] = {
#define FEET_TOP 20.0f
#define FEET_H 10.0f
/**
* @brief How far past the edge of the map the boundary walls extend, in pixels.
*
* The walls cover the map's outer ring of cells *and* this much beyond it. The
* ring is what the old hand-rolled rule made solid and what the art is drawn to
* expect; the overhang is what stops an actor arriving fast enough from being
* resolved out the far side of a wall exactly one tile thick.
*/
#define EDGE_OVERHANG 16.0f
/** @brief Where the party member walks, in pixels relative to the player. */
#define FOLLOWER_OFFSET_X (-14.0f)
#define FOLLOWER_OFFSET_Y (10.0f)
akgl_CollisionWorld jrpg_collision;
/** @brief The four map-edge walls, and the shapes they were built from. */
static akgl_CollisionProxy *jrpg_edges[4];
static akgl_CollisionShape jrpg_edge_shapes[4];
/**
* @brief Load one sprite definition by its three naming components.
*
@@ -154,6 +172,62 @@ static akerr_ErrorContext *character_load(char *cast)
SUCCEED_RETURN(errctx);
}
/**
* @brief Wall off the edges of the map with four static proxies.
*
* The town's decoration layer says what is solid -- a building, a tree, a lamp
* post -- and the map's edge is not on it. Nothing else stops an actor walking
* off the edge of the world.
*
* Four proxies rather than a hundred solid tiles, because static geometry that
* is not tile-shaped is exactly what a proxy is for: a long thin box costs one
* slot whatever its length. `layermask` is set by hand because
* akgl_collision_shape_box defaults a shape to LAYER_ACTOR, which is right for
* an actor and wrong for scenery -- an actor responds to LAYER_STATIC and would
* walk straight through these.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_HEAP If the proxy pool is exhausted.
*/
static akerr_ErrorContext *wall_off_the_edges(void)
{
SDL_FRect sides[4];
float32_t ow = 0.0f; /* Map width, plus the overhang at each end. */
float32_t oh = 0.0f; /* Map height, the same. */
float32_t tw = 0.0f; /* One tile of ring, plus one overhang. */
float32_t th = 0.0f;
int i = 0;
PREPARE_ERROR(errctx);
ow = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) + (2.0f * EDGE_OVERHANG);
oh = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) + (2.0f * EDGE_OVERHANG);
tw = (float32_t)akgl_gamemap->tilewidth + EDGE_OVERHANG;
th = (float32_t)akgl_gamemap->tileheight + EDGE_OVERHANG;
/* x y w h */
sides[0] = (SDL_FRect){ -EDGE_OVERHANG, -EDGE_OVERHANG, tw, oh }; /* west */
sides[1] = (SDL_FRect){ (ow - tw - EDGE_OVERHANG), -EDGE_OVERHANG, tw, oh }; /* east */
sides[2] = (SDL_FRect){ -EDGE_OVERHANG, -EDGE_OVERHANG, ow, th }; /* north */
sides[3] = (SDL_FRect){ -EDGE_OVERHANG, (oh - th - EDGE_OVERHANG), ow, th }; /* south */
for ( i = 0; i < 4; i++ ) {
/*
* Acquire and initialize adjacent, with nothing between them: until
* akgl_collision_proxy_initialize takes the reference the slot is still
* free, and the next acquire hands out the same pointer.
*/
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;
PASS(errctx, akgl_collision_proxy_initialize(jrpg_edges[i], NULL, &jrpg_edge_shapes[i],
0.0f, 0.0f, 0.0f));
PASS(errctx, jrpg_collision.partitioner.insert(&jrpg_collision.partitioner, jrpg_edges[i]));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_world_load(void)
{
PREPARE_ERROR(errctx);
@@ -192,6 +266,16 @@ akerr_ErrorContext *jrpg_world_load(void)
akgl_physics = &akgl_gamemap->physics;
}
// Collision is opt-in, and this is the whole of turning it on. The cell
// arguments are placeholders: akgl_collision_bind_tilemap overwrites them
// from the map's own tile size, and reads which layers are solid off each
// layer's `collidable` property rather than an index the game and the map
// had to agree on out of band.
PASS(errctx, akgl_collision_world_init(&jrpg_collision, NULL, 16.0f, 16.0f));
PASS(errctx, akgl_collision_bind_tilemap(&jrpg_collision, akgl_gamemap));
PASS(errctx, wall_off_the_edges());
akgl_physics->collision = &jrpg_collision;
SUCCEED_RETURN(errctx);
}
@@ -200,6 +284,7 @@ akerr_ErrorContext *jrpg_world_populate(void)
PREPARE_ERROR(errctx);
akgl_Actor *player = NULL;
akgl_Actor *follower = NULL;
SDL_FRect body;
int i = 0;
// Every actor the map spawned, including the player.
@@ -228,6 +313,15 @@ akerr_ErrorContext *jrpg_world_populate(void)
);
player->movementlogicfunc = &jrpg_actor_logic_walk;
// The footprint, and only the player gets one. The NPCs stand still and are
// spoken to, not walked into: giving them shapes would make the town a
// pinball table and would change what the proximity test in jrpg.c means.
// 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.
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;
// The party member. Nothing in the map creates it, and nothing has to: an
// actor is a pool object with a name, and the character it instantiates is
// already registered. This one borrows the elder's template, which is the
@@ -252,58 +346,6 @@ akerr_ErrorContext *jrpg_world_populate(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief Is the map cell containing this pixel one the player cannot enter?
*
* Two rules, and both of them are the game's rather than the library's. The
* outer ring of the map is solid, because nothing else stops an actor walking
* off the edge of the world. Everything else is the decoration layer: a cell
* with a tile in it is a building, a tree or a lamp post, and a cell with 0 in
* it is open ground.
*
* @param px Position in map pixels along x.
* @param py Position in map pixels along y.
* @return True when the cell blocks movement.
*/
static bool cell_solid(float32_t px, float32_t py)
{
int tx = 0;
int ty = 0;
tx = (int)(px / (float32_t)akgl_gamemap->tilewidth);
ty = (int)(py / (float32_t)akgl_gamemap->tileheight);
if ( (tx <= 0) || (ty <= 0) ||
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
return true;
}
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
}
/**
* @brief Would an actor whose frame is at (x, y) have its feet in a wall?
*
* Tests the four corners of the footprint. Four corners is enough only because
* the footprint is smaller than a tile in both directions; a bigger one would
* step over a single-cell wall between two of its corners.
*
* @param x Candidate frame position along x, in map pixels.
* @param y Candidate frame position along y, in map pixels.
* @return True when any corner of the footprint lands in a solid cell.
*/
static bool feet_blocked(float32_t x, float32_t y)
{
float32_t left = x + FEET_X;
float32_t right = x + FEET_X + FEET_W;
float32_t top = y + FEET_TOP;
float32_t bottom = y + FEET_TOP + FEET_H;
return (cell_solid(left, top) ||
cell_solid(right, top) ||
cell_solid(left, bottom) ||
cell_solid(right, bottom));
}
akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt)
{
PREPARE_ERROR(errctx);
@@ -335,25 +377,13 @@ akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt)
// Everything the default hook does -- re-copy the character's speed limits,
// turn the movement bits into signed acceleration -- is still wanted. This
// hook adds to it rather than replacing it.
PASS(errctx, akgl_actor_logic_movement(obj, dt));
// Where this step would put us. akgl_physics_simulate has already
// integrated thrust for this step and capped it against the character's
// speed ellipse, and it computes velocity as `e + t` -- and with the town's
// zero gravity and zero drag, `e` stays zero, so `v` is `t`. That is what
// makes the prediction exact rather than approximate. A map with gravity
// would have to account for `ey` here as well.
//
// Axis at a time, so walking into a wall diagonally slides along it rather
// than stopping dead.
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;
}
// Walls used to be here too: a prediction of where this step would land,
// tested axis at a time. It was only ever correct because the town has zero
// gravity and zero drag, so `v` is `t` and the prediction is exact. The
// library resolves after the move instead, on a position that already
// happened, and that qualifier goes away with it.
PASS(errctx, akgl_actor_logic_movement(obj, dt));
SUCCEED_RETURN(errctx);
}