From cbf7c1b6c2acca206ad42cbed926da6fce50355f Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 07:46:19 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- docs/20-tutorial-sidescroller.md | 14 +-- docs/21-tutorial-jrpg.md | 155 +++++++++++++++---------- docs/tutorials/assets/jrpg/town.tmj | 9 +- examples/jrpg/jrpg.h | 32 +++--- examples/jrpg/world.c | 172 ++++++++++++++++------------ 5 files changed, 226 insertions(+), 156 deletions(-) diff --git a/docs/20-tutorial-sidescroller.md b/docs/20-tutorial-sidescroller.md index 48cc803..c0d804f 100644 --- a/docs/20-tutorial-sidescroller.md +++ b/docs/20-tutorial-sidescroller.md @@ -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 diff --git a/docs/21-tutorial-jrpg.md b/docs/21-tutorial-jrpg.md index e33e7ec..fc657c7 100644 --- a/docs/21-tutorial-jrpg.md +++ b/docs/21-tutorial-jrpg.md @@ -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 diff --git a/docs/tutorials/assets/jrpg/town.tmj b/docs/tutorials/assets/jrpg/town.tmj index 85e3901..0bffd2b 100644 --- a/docs/tutorials/assets/jrpg/town.tmj +++ b/docs/tutorials/assets/jrpg/town.tmj @@ -1227,7 +1227,14 @@ "visible": true, "width": 30, "x": 0, - "y": 0 + "y": 0, + "properties": [ + { + "name": "collidable", + "type": "bool", + "value": true + } + ] }, { "draworder": "topdown", diff --git a/examples/jrpg/jrpg.h b/examples/jrpg/jrpg.h index 1c708b0..d958ce3 100644 --- a/examples/jrpg/jrpg.h +++ b/examples/jrpg/jrpg.h @@ -19,6 +19,7 @@ #include #include #include +#include #include /* @@ -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`. diff --git a/examples/jrpg/world.c b/examples/jrpg/world.c index 8f7edac..830bd9f 100644 --- a/examples/jrpg/world.c +++ b/examples/jrpg/world.c @@ -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 @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -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); }