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",
|
||||
|
||||
@@ -9,7 +9,6 @@ get_filename_component(SS_ASSET_DIR
|
||||
|
||||
add_executable(sidescroller
|
||||
main.c
|
||||
collision.c
|
||||
player.c
|
||||
actors.c
|
||||
)
|
||||
|
||||
@@ -75,15 +75,19 @@ static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
||||
* @brief The blob: walk, and turn around at a wall or a ledge.
|
||||
*
|
||||
* This one stays inside the physics step -- it walks on the ground under the
|
||||
* map's gravity like the player does, and uses the same swept resolution.
|
||||
* map's gravity like the player does, and the library resolves it the same way.
|
||||
* All this hook decides is which way to face next.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
ss_ActorData *data = NULL;
|
||||
ss_Contact contact;
|
||||
SDL_FRect ahead;
|
||||
float32_t probe_x = 0.0f;
|
||||
float32_t probe_y = 0.0f;
|
||||
float32_t step = 0.0f;
|
||||
bool floor_ahead = false;
|
||||
bool wall_ahead = false;
|
||||
bool grounded = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
@@ -99,7 +103,18 @@ static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
/* Signs the acceleration from the movement bits just set. */
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
|
||||
step = 1.0f;
|
||||
if ( data->facing < 0.0f ) {
|
||||
step = -1.0f;
|
||||
}
|
||||
|
||||
/* Three probes, all taken from where the last step left the blob. The blob
|
||||
* turns on a wall or a ledge, and a contact from 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. */
|
||||
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));
|
||||
|
||||
/* One pixel past the leading edge of the box and one pixel below its feet:
|
||||
* no floor there means the next step walks off. */
|
||||
@@ -109,12 +124,13 @@ static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
} else {
|
||||
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
|
||||
}
|
||||
PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
|
||||
PASS(errctx, akgl_collision_solid_at(&ss_collision, probe_x, probe_y, &floor_ahead));
|
||||
|
||||
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
||||
if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) {
|
||||
data->facing = -data->facing;
|
||||
/* The resolution already zeroed `tx` on a blocked axis; zero it on the
|
||||
* ledge path too, or the blob keeps its old momentum through the turn. */
|
||||
/* The library's response removes the component of `tx` going into a
|
||||
* wall, but nothing removes it on the ledge path -- and without this the
|
||||
* blob keeps its old momentum through the turn. */
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -165,7 +181,10 @@ akerr_ErrorContext *ss_actors_bind(void)
|
||||
}
|
||||
|
||||
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
|
||||
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
|
||||
PASS(errctx, akgl_collision_shape_box(&ss_game.hazards[0]->shape, &ss_blob_body, 0.0f));
|
||||
ss_game.hazards[0]->shape_override = true;
|
||||
PASS(errctx, akgl_collision_settle(&ss_collision, &ss_game.hazards[0]->shape,
|
||||
&ss_game.hazards[0]->x, &ss_game.hazards[0]->y, 0));
|
||||
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
|
||||
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
|
||||
ss_hazard_data[0].facing = -1.0f;
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
/**
|
||||
* @file collision.c
|
||||
* @brief The collision libakgl does not have.
|
||||
*
|
||||
* This file exists because of a gap, and the gap is worth stating exactly:
|
||||
*
|
||||
* - `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.
|
||||
*
|
||||
* So an actor walks through a wall and off the edge of the world, and the only
|
||||
* hook that runs inside the physics step is the actor's own `movementlogicfunc`.
|
||||
* That is where this game's collision lives, and everything here is called from
|
||||
* there.
|
||||
*
|
||||
* The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
|
||||
* drag, the velocity recomputation and `move`, so it cannot look at where the
|
||||
* actor ended up -- the step has not happened yet. ss_collide_predict therefore
|
||||
* repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
|
||||
* resolves against that predicted position. Get the prediction wrong and the
|
||||
* actor is resolved against a step it never takes.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
#include "sidescroller.h"
|
||||
|
||||
/**
|
||||
* @brief Half a pixel-thousandth, taken off the far edge of a box before it is
|
||||
* turned into tile indices.
|
||||
*
|
||||
* A box whose right edge sits exactly on a tile boundary does not overlap the
|
||||
* tile on the far side of it. Without this, an actor standing flush against a
|
||||
* wall reads as inside it, and the sweep snaps it back a tile every frame.
|
||||
*/
|
||||
#define SS_COLLIDE_EPSILON 0.001f
|
||||
|
||||
/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
|
||||
#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
|
||||
|
||||
/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
|
||||
#define SS_COLLIDE_SETTLE_TILES 4
|
||||
|
||||
static akgl_TilemapLayer *ss_terrain = NULL;
|
||||
|
||||
/**
|
||||
* @brief Is this map cell solid?
|
||||
*
|
||||
* Off the left or right edge of the map is solid, so the level has walls at its
|
||||
* ends. Off the top or the bottom is not: the sky is open and the pit in the
|
||||
* middle of level1.tmj has to be fallable-into, which is the whole point of it.
|
||||
*/
|
||||
static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
|
||||
|
||||
if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
|
||||
*dest = true;
|
||||
} else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
|
||||
*dest = false;
|
||||
} else {
|
||||
/* A tile layer's data is global tile ids in row-major order, and 0 is
|
||||
* the empty cell. Any tile at all on the terrain layer is solid: the
|
||||
* level says what is solid by which layer the tile is drawn on. */
|
||||
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
|
||||
{
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
|
||||
|
||||
ss_terrain = NULL;
|
||||
for ( i = 0; i < map->numlayers; i++ ) {
|
||||
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
||||
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
||||
ss_terrain = &map->layers[i];
|
||||
}
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
ss_terrain,
|
||||
AKERR_KEY,
|
||||
"Map has no tile layer with id %d to collide against",
|
||||
SS_TERRAIN_LAYER_ID
|
||||
);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
|
||||
{
|
||||
int x0 = 0;
|
||||
int x1 = 0;
|
||||
int y0 = 0;
|
||||
int y1 = 0;
|
||||
int tilex = 0;
|
||||
int tiley = 0;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((box->w <= 0.0f) || (box->h <= 0.0f)),
|
||||
AKERR_VALUE,
|
||||
"Collision box is %fx%f; both sides must be positive",
|
||||
box->w,
|
||||
box->h
|
||||
);
|
||||
|
||||
*dest = false;
|
||||
x0 = (int)floorf(box->x / SS_TILE_SIZE);
|
||||
x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||
y0 = (int)floorf(box->y / SS_TILE_SIZE);
|
||||
y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||
|
||||
for ( tiley = y0; tiley <= y1; tiley++ ) {
|
||||
for ( tilex = x0; tilex <= x1; tilex++ ) {
|
||||
PASS(errctx, solid_tile(tilex, tiley, &solid));
|
||||
if ( solid == true ) {
|
||||
*dest = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
|
||||
{
|
||||
float32_t ex = 0.0f;
|
||||
float32_t ey = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
|
||||
FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
|
||||
FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
|
||||
|
||||
/*
|
||||
* Everything below is akgl_physics_simulate's own arithmetic, in its own
|
||||
* order: gravity onto the environmental term, then drag off it, then
|
||||
* velocity as environmental plus thrust, then position plus velocity times
|
||||
* dt. The `!= 0` guards are the library's too -- it skips each axis whose
|
||||
* constant is zero rather than multiplying by it -- and are repeated here
|
||||
* because dropping them would make a zero-gravity axis pick up drag.
|
||||
*
|
||||
* This mirrors the *arcade* backend. Against the null backend gravity does
|
||||
* nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
|
||||
* right.
|
||||
*/
|
||||
ex = obj->ex;
|
||||
ey = obj->ey;
|
||||
if ( akgl_physics->gravity_x != 0 ) {
|
||||
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
||||
}
|
||||
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;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
|
||||
*
|
||||
* Each axis is tested on its own, which is what lets an actor slide along a wall
|
||||
* instead of sticking to it, and the sub-step is capped at half a tile so a
|
||||
* fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
|
||||
* the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
|
||||
* step, which is nearly two tiles -- so this is not a theoretical concern.
|
||||
*
|
||||
* On a blocked axis the box is snapped to the tile boundary it was about to
|
||||
* cross rather than simply not moved, so an actor lands flush on a floor at
|
||||
* whatever speed it arrives.
|
||||
*/
|
||||
static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
|
||||
{
|
||||
SDL_FRect trial;
|
||||
float32_t span = 0.0f;
|
||||
float32_t stepx = 0.0f;
|
||||
float32_t stepy = 0.0f;
|
||||
int steps = 0;
|
||||
int i = 0;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
span = fabsf(dx);
|
||||
if ( fabsf(dy) > span ) {
|
||||
span = fabsf(dy);
|
||||
}
|
||||
steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
|
||||
stepx = dx / (float32_t)steps;
|
||||
stepy = dy / (float32_t)steps;
|
||||
|
||||
for ( i = 0; i < steps; i++ ) {
|
||||
if ( stepx != 0.0f ) {
|
||||
trial = *box;
|
||||
trial.x += stepx;
|
||||
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||
if ( solid == true ) {
|
||||
if ( stepx > 0.0f ) {
|
||||
box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
|
||||
} else {
|
||||
box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||
}
|
||||
stepx = 0.0f;
|
||||
dest->blocked_x = true;
|
||||
} else {
|
||||
box->x = trial.x;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
|
||||
{
|
||||
SDL_FRect box;
|
||||
SDL_FRect probe;
|
||||
float32_t dx = 0.0f;
|
||||
float32_t dy = 0.0f;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
dest->blocked_x = false;
|
||||
dest->blocked_y = false;
|
||||
dest->grounded = false;
|
||||
|
||||
PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
|
||||
|
||||
box.x = obj->x + body->x;
|
||||
box.y = obj->y + body->y;
|
||||
box.w = body->w;
|
||||
box.h = body->h;
|
||||
PASS(errctx, sweep(&box, dx, dy, dest));
|
||||
|
||||
/*
|
||||
* Only a blocked axis is written back. The free axis is left for
|
||||
* akgl_physics_arcade_move to advance by exactly the amount this function
|
||||
* predicted -- writing it here as well would move the actor twice.
|
||||
*
|
||||
* `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
|
||||
* effort; a blocked axis has to give up both, or the actor keeps pressing
|
||||
* into the wall and the next step's prediction is wrong by everything it
|
||||
* accumulated while stuck.
|
||||
*
|
||||
* The environmental term is not set to zero, it is set to *minus one step of
|
||||
* gravity*, and that is not a nicety. Zeroing it leaves the step about to add
|
||||
* `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
|
||||
* quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
|
||||
* still fatal: the box now overlaps the floor tile, so the *horizontal*
|
||||
* sweep on the next step finds itself blocked wherever it is, and the actor
|
||||
* is snapped back a whole tile every time it tries to walk. Pre-loading the
|
||||
* cancellation leaves the actor resting exactly on the surface instead.
|
||||
*/
|
||||
if ( dest->blocked_x == true ) {
|
||||
obj->x = box.x - body->x;
|
||||
obj->ex = (float32_t)akgl_physics->gravity_x * dt;
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
if ( dest->blocked_y == true ) {
|
||||
obj->y = box.y - body->y;
|
||||
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
||||
obj->ty = 0.0f;
|
||||
}
|
||||
|
||||
/*
|
||||
* Standing on a floor is not a state the library records, so it is measured:
|
||||
* one pixel below where the box will be at the end of this step. Note that
|
||||
* zeroing `ey` above does not stop gravity -- the step still adds
|
||||
* `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
|
||||
* fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
|
||||
* it straight back off, so a standing actor rests within a pixel of the
|
||||
* surface forever rather than sinking through it.
|
||||
*/
|
||||
probe = box;
|
||||
probe.y += 1.0f;
|
||||
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
||||
dest->grounded = solid;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
|
||||
{
|
||||
SDL_FRect box;
|
||||
bool solid = false;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||
|
||||
/*
|
||||
* A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
|
||||
* actor can start out with its box partly inside a step -- level1.tmj puts
|
||||
* the player at x=32 and a block at tiles (3,11), which is under the right
|
||||
* half of the player's frame.
|
||||
*
|
||||
* The swept resolution cannot fix that. 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 already
|
||||
* blocked wherever it goes. So a spawn point gets lifted clear once, before
|
||||
* the first step, a tile at a time.
|
||||
*/
|
||||
for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
|
||||
box.x = obj->x + body->x;
|
||||
box.y = obj->y + body->y;
|
||||
box.w = body->w;
|
||||
box.h = body->h;
|
||||
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
||||
if ( solid == false ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
obj->y -= (float32_t)SS_TILE_SIZE;
|
||||
SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
|
||||
}
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"%s spawned inside more than %d tiles of terrain at %f, %f",
|
||||
(char *)obj->name,
|
||||
SS_COLLIDE_SETTLE_TILES,
|
||||
obj->x,
|
||||
obj->y
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,23 @@
|
||||
#define SS_PATH_MAX 1024
|
||||
|
||||
ss_Game ss_game;
|
||||
akgl_CollisionWorld ss_collision;
|
||||
|
||||
akerr_ErrorContext *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest)
|
||||
{
|
||||
SDL_FRect feet;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "shape");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
/* One pixel down, 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. */
|
||||
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));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The sprites, loaded in this order.
|
||||
@@ -240,7 +257,17 @@ static akerr_ErrorContext *load_level(char *assetdir)
|
||||
(akgl_physics->gravity_y / akgl_physics->drag_y));
|
||||
}
|
||||
|
||||
PASS(errctx, ss_collide_bind(akgl_gamemap));
|
||||
/*
|
||||
* Collision is opt-in: a backend with no world attached moves an actor and
|
||||
* consults nothing, which is what this game used to rely on and then spend
|
||||
* 383 lines of its own working around. 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 a layer id compiled into the game.
|
||||
*/
|
||||
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;
|
||||
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
|
||||
@@ -346,6 +373,14 @@ static akerr_ErrorContext *run(int frames)
|
||||
* driver, where there is nothing to sync to. */
|
||||
SDL_Delay(16);
|
||||
}
|
||||
|
||||
/* Recorded here and not in main(): shutdown_game() runs in main()'s CLEANUP
|
||||
* block and releases the actor pool, so ss_game.player is stale by the time
|
||||
* the summary is printed. */
|
||||
if ( ss_game.player != NULL ) {
|
||||
ss_game.final_x = ss_game.player->x;
|
||||
ss_game.final_y = ss_game.player->y;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -446,11 +481,20 @@ int main(int argc, char *argv[])
|
||||
*/
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
/*
|
||||
* The position is in the summary because exiting 0 is not evidence that
|
||||
* collision ran. The map is 240 pixels tall and gravity is 900 px/s^2: an
|
||||
* unresolved player is hundreds of pixels below the floor within a second,
|
||||
* and this line is what makes that visible in a CI log instead of passing.
|
||||
*/
|
||||
SDL_Log(
|
||||
"sidescroller: %d frames, %d of %d coins, %d deaths",
|
||||
"sidescroller: %d frames, %d of %d coins, %d deaths, player at %.1f,%.1f%s",
|
||||
ss_game.frame,
|
||||
ss_game.coins_taken,
|
||||
SS_COIN_COUNT,
|
||||
ss_game.deaths);
|
||||
ss_game.deaths,
|
||||
ss_game.final_x,
|
||||
ss_game.final_y,
|
||||
((ss_game.grounded == true) ? " grounded" : " airborne"));
|
||||
return ss_failed;
|
||||
}
|
||||
|
||||
@@ -86,14 +86,15 @@ static akerr_ErrorContext *respawn(akgl_Actor *obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
|
||||
* @brief The player's `movementlogicfunc`: friction and the jump.
|
||||
*
|
||||
* Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
|
||||
* drag, the velocity recomputation and `move`.
|
||||
* drag, the velocity recomputation and `move`. Terrain is no longer this
|
||||
* function's problem -- the library resolves it after the move, which is the
|
||||
* only place it can be resolved without predicting the arithmetic above.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
ss_Contact contact;
|
||||
float32_t friction = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
@@ -113,6 +114,11 @@ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
* ground than in the air. Below a pixel per second it is snapped to zero,
|
||||
* because an exponential decay never actually arrives.
|
||||
*/
|
||||
/* Where the last step left the player, one pixel down. This runs before the
|
||||
* step it is deciding about, so it is one frame stale by construction --
|
||||
* which is one frame of coyote time, and not a thing a player can feel. */
|
||||
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &ss_game.grounded));
|
||||
|
||||
if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) &&
|
||||
AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||
friction = SS_FRICTION_AIR;
|
||||
@@ -132,18 +138,13 @@ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
* `speed_y`, which is 0 for this character, so a jump written as thrust is
|
||||
* scaled to nothing by the ellipse cap in akgl_physics_simulate.
|
||||
*
|
||||
* `ss_game.grounded` is last step's verdict, one frame stale. That is the
|
||||
* ordering again -- this hook runs before the step it is deciding about --
|
||||
* and one frame of coyote time is not a thing a player can feel.
|
||||
* `ss_game.grounded` is the probe above.
|
||||
*/
|
||||
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
||||
obj->ey = -SS_JUMP_SPEED;
|
||||
}
|
||||
ss_game.jump_requested = false;
|
||||
|
||||
PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact));
|
||||
ss_game.grounded = contact.grounded;
|
||||
|
||||
/*
|
||||
* The character maps MOVING_UP to the jump sprites, so the state word says
|
||||
* "in the air" whether the actor is rising or falling. Nothing else reads
|
||||
@@ -151,7 +152,7 @@ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
* character_ss_player.json, so the thrust it would authorise is capped to
|
||||
* nothing.
|
||||
*/
|
||||
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);
|
||||
@@ -285,9 +286,12 @@ akerr_ErrorContext *ss_player_bind(akgl_Actor *obj)
|
||||
|
||||
ss_game.player = obj;
|
||||
|
||||
/* Lift the spawn point clear of the step it overlaps *before* recording it,
|
||||
* so a respawn does not put the player back inside the geometry. */
|
||||
PASS(errctx, ss_collide_settle(obj, &ss_player_body));
|
||||
/* The library's default response only stops an actor *entering* geometry;
|
||||
* one that starts inside it is simply stuck, so lift the spawn point clear
|
||||
* of the step it overlaps *before* recording it. */
|
||||
PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f));
|
||||
obj->shape_override = true;
|
||||
PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0));
|
||||
ss_player_data.home_x = obj->x;
|
||||
ss_player_data.home_y = obj->y;
|
||||
obj->actorData = (void *)&ss_player_data;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* The game is four translation units and this is the seam between them:
|
||||
*
|
||||
* main.c startup, asset loading, the frame loop, teardown
|
||||
* collision.c the tile queries and the swept resolution libakgl does not have
|
||||
* player.c the player's hooks and its control bindings
|
||||
* actors.c the coins, the patrolling blob, the flying moth
|
||||
*
|
||||
@@ -23,8 +22,10 @@
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/collision.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
@@ -38,16 +39,6 @@
|
||||
#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
|
||||
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
|
||||
|
||||
/*
|
||||
* Which layer of the map is solid.
|
||||
*
|
||||
* akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the
|
||||
* layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`,
|
||||
* `y` and `type`, and nothing else. So a game cannot ask for "the layer called
|
||||
* terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2.
|
||||
*/
|
||||
#define SS_TERRAIN_LAYER_ID 2
|
||||
|
||||
/* The player's collision box, as an offset into its 32x32 sprite frame. The art
|
||||
* does not fill the frame edge to edge, and a box the full width of the frame
|
||||
* catches on doorways the character visibly clears. */
|
||||
@@ -81,19 +72,6 @@
|
||||
*/
|
||||
#define SS_AUTOPLAY_KBID 11
|
||||
|
||||
/**
|
||||
* @brief What one call to ss_collide_resolve found.
|
||||
*
|
||||
* `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this
|
||||
* step; `grounded` says there is solid terrain one pixel under where the actor
|
||||
* will be when the step finishes, which is the test a jump is gated on.
|
||||
*/
|
||||
typedef struct {
|
||||
bool blocked_x;
|
||||
bool blocked_y;
|
||||
bool grounded;
|
||||
} ss_Contact;
|
||||
|
||||
/**
|
||||
* @brief Per-actor game data, hung off akgl_Actor::actorData.
|
||||
*
|
||||
@@ -120,17 +98,31 @@ typedef struct {
|
||||
bool grounded; /**< Last step's verdict; what gates the next jump. */
|
||||
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
|
||||
int frame; /**< Frames drawn so far. */
|
||||
float32_t final_x; /**< Where the player finished, stamped before teardown for the summary line. */
|
||||
float32_t final_y;
|
||||
} ss_Game;
|
||||
|
||||
extern ss_Game ss_game;
|
||||
|
||||
/* collision.c -- everything akgl_physics_arcade_collide would have done. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body);
|
||||
/**
|
||||
* @brief The collision world, owned by main.c.
|
||||
*
|
||||
* The physics backend resolves through it after every sub-move; the game only
|
||||
* touches it to ask questions -- a ledge probe, a wall probe, a spawn point that
|
||||
* needs lifting clear. Everything that used to be in collision.c is behind it.
|
||||
*/
|
||||
extern akgl_CollisionWorld ss_collision;
|
||||
|
||||
/**
|
||||
* @brief Is there solid geometry one pixel under this shape?
|
||||
*
|
||||
* The predicate a jump is gated on, and it is deliberately *not* the same
|
||||
* question as "did the last step's resolution stop me falling". A contact says
|
||||
* something pushed back this step; this says there is a floor to push off, which
|
||||
* stays true through the frame after landing and is what makes a jump feel like
|
||||
* it registered.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest);
|
||||
|
||||
/* player.c */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# ```c excerpt=PATH must appear verbatim in PATH; not compiled
|
||||
# ```c screenshot=NAME also the source of docs/images/NAME.png
|
||||
# ```json kind=KIND loaded through the real akgl_*_load_json
|
||||
# ```json excerpt=PATH must appear verbatim in PATH; not loaded
|
||||
# ```output the exact stdout of the runnable block above it
|
||||
# ```sh run in a sandbox; must exit 0
|
||||
# ```sh setup=NAME the same, after tests/docs_setups/NAME.sh
|
||||
@@ -434,9 +435,10 @@ function run_c_run()
|
||||
fi
|
||||
}
|
||||
|
||||
# A declaration echoed from a header. Compiling it would redefine the type, so
|
||||
# the check is that it still says what the header says -- which is the drift
|
||||
# that actually happens, and the one a compile could not catch.
|
||||
# A declaration echoed from a header, or a fragment quoted out of an asset file
|
||||
# too large to show whole. Compiling or loading it is not the point -- the check
|
||||
# is that it still says what the file says, which is the drift that actually
|
||||
# happens and the one a compile could not catch.
|
||||
function run_excerpt()
|
||||
{
|
||||
local where="$1" body="$2" path="$3"
|
||||
@@ -640,7 +642,12 @@ for DOC in "${DOCS[@]}"; do
|
||||
fi
|
||||
;;
|
||||
json)
|
||||
run_json "${WHERE}" "${BODY}" "${INFO}"
|
||||
EXCERPT="$(attr excerpt "${INFO}")"
|
||||
if [ -n "${EXCERPT}" ]; then
|
||||
run_excerpt "${WHERE}" "${BODY}" "${EXCERPT}"
|
||||
else
|
||||
run_json "${WHERE}" "${BODY}" "${INFO}"
|
||||
fi
|
||||
;;
|
||||
sh)
|
||||
run_sh "${WHERE}" "${BODY}" "${INFO}" "${EXPECTED}"
|
||||
|
||||
Reference in New Issue
Block a user