Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was navigation and worked examples. Chapters teach a task and link to the Doxygen output; where a declaration or a constant table has to be in front of the reader it arrives as a `c excerpt=` block, so the text *is* the header and cannot diverge from it. That also preserves the hand-aligned bit-flag tables scripts/reindent.el goes out of its way not to destroy. The manual does not re-document its dependencies. libakerror owns the ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events, Tiled owns the map format, jansson owns json_t. A chapter that restated any of them would be wrong the day upstream changed and nothing here would notice -- the same drift this work exists to fix, arriving from a different direction. So each chapter says what libakgl adds or constrains and links out for the rest. Chapter 4 is the exception and the reason for it: libakerror documents the mechanism, but only libakgl can say which statuses its own functions raise and what they mean here, and that was written down nowhere. It carries three tables -- libakgl's five status codes, the libakerror statuses libakgl actually raises with their meaning in this library, and the exit-status trap where `exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256. Every chapter was written against src/ rather than against the header comments, which is how 27 false claims in those comments came to light. Where a chapter documents a known defect rather than a design decision it says so and points at TODO.md. README.md keeps the development process and hands the reader to docs/. Its task-oriented FAQ is deleted rather than moved, because one source of truth per topic is the whole point and that FAQ's examples did not compile. Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run through the real loaders, one linked-and-executed program with its output compared byte for byte, one generated figure. 11 norun blocks, each justified. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
366
docs/14-physics.md
Normal file
366
docs/14-physics.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 14. Physics
|
||||
|
||||
The physics subsystem has the same shape as the renderer: a record of function pointers
|
||||
plus an initializer that fills it in. Two backends ship. `null` accepts every call and
|
||||
changes nothing, which is what a menu screen or a test harness wants. `arcade` applies
|
||||
gravity, drag, thrust and a speed cap, which is what a 2D game wants.
|
||||
|
||||
**There is no "physics body" type.** The body *is* the actor plus the character it
|
||||
instantiates. An actor carries the position and the three velocity terms; its character
|
||||
supplies the accelerations and the top speeds. Nothing else participates. If you have read
|
||||
[Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) you have already met every
|
||||
field this chapter uses.
|
||||
|
||||
## The model
|
||||
|
||||
```text
|
||||
thrust (tx, ty, tz) the actor's own effort, while it is moving
|
||||
+
|
||||
environ (ex, ey, ez) gravity, less drag -- accumulates across steps
|
||||
=
|
||||
velocity(vx, vy, vz) recomputed every step; writing it directly is overwritten
|
||||
(a CHILD actor is the exception -- see below)
|
||||
|
||||
caps (sx, sy, sz) from the character. Caps the THRUST VECTOR, not velocity
|
||||
accel (ax, ay, az) from the character, signed by the direction of travel
|
||||
```
|
||||
|
||||
Two properties of that arrangement carry all the consequences.
|
||||
|
||||
**The cap is on thrust, not on velocity.** `sx`/`sy`/`sz` are the character's own top
|
||||
speed, not a speed limit on the world, and `akgl_physics_arcade_gravity` touches only the
|
||||
environmental term. So a falling actor accelerates straight past its stated top speed —
|
||||
which is the point, for a falling one. It also means there is no terminal velocity; see
|
||||
below.
|
||||
|
||||
**The cap is on the thrust *vector*, scaled to an ellipse, not on each axis separately.**
|
||||
|
||||
```text
|
||||
ty
|
||||
| Capping each axis against its own limit
|
||||
......|...... admits the corner of the box: an actor
|
||||
..' | '.. holding two directions got both caps
|
||||
.' +---+---+ '. at once and travelled their
|
||||
: | | | : diagonal -- 41% faster than either
|
||||
: ----+---+---+---- : alone. Scaling the vector to the
|
||||
----:------+---+---+------:---- tx ellipse keeps a character
|
||||
: | | | : whose horizontal and vertical
|
||||
:. +---+---+ .: speeds differ moving at the ratio
|
||||
'.. | sx ..' it asked for, and makes them equal
|
||||
''|.......''' where the speeds are.
|
||||
|
|
||||
dotted ellipse: the cap solid box: the old per-axis clamp
|
||||
```
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
overshoot = 0.0f;
|
||||
if ( actor->sx != 0.0f ) {
|
||||
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
|
||||
} else {
|
||||
actor->tx = 0.0f;
|
||||
}
|
||||
if ( actor->sy != 0.0f ) {
|
||||
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
||||
} else {
|
||||
actor->ty = 0.0f;
|
||||
}
|
||||
if ( actor->sz != 0.0f ) {
|
||||
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
|
||||
} else {
|
||||
actor->tz = 0.0f;
|
||||
}
|
||||
if ( overshoot > 1.0f ) {
|
||||
thrustscale = 1.0f / sqrtf(overshoot);
|
||||
actor->tx *= thrustscale;
|
||||
actor->ty *= thrustscale;
|
||||
actor->tz *= thrustscale;
|
||||
}
|
||||
```
|
||||
|
||||
An axis whose top speed is zero cannot be thrust along at all — that is what the old
|
||||
per-axis clamp did with it — and it stays out of the magnitude entirely, because dividing
|
||||
by it would not end well.
|
||||
|
||||
**Only x and y ever accumulate thrust.** `akgl_physics_simulate` reads the
|
||||
`AKGL_ACTOR_STATE_MOVING_LEFT`/`RIGHT` bits into `tx` and `MOVING_UP`/`DOWN` into `ty`.
|
||||
`AKGL_ACTOR_STATE_MOVING_IN` and `MOVING_OUT` exist and nothing reads them, so `tz` is only
|
||||
ever zeroed or scaled — the z axis has gravity, drag and movement but **no thrust an actor
|
||||
can apply**. Set `tz` yourself from a `movementlogicfunc` if you need depth motion.
|
||||
|
||||
## One step, in order
|
||||
|
||||
`akgl_physics_simulate` is shared by both backends; what differs is the `gravity` and
|
||||
`move` they point at. It walks the whole actor pool in index order, and per actor:
|
||||
|
||||
```text
|
||||
refcount == 0 ? ------------------------------> skip (free pool slot)
|
||||
parent != NULL ? -> x,y,z = parent's + own v, -> skip (never simulates)
|
||||
basechar == NULL ? ------------------------------> skip (no speeds)
|
||||
LAYERMASK set and actor->layer != layerid ? -------> skip
|
||||
|
||||
tx += ax * dt if MOVING_LEFT or MOVING_RIGHT
|
||||
ty += ay * dt if MOVING_UP or MOVING_DOWN
|
||||
scale (tx,ty,tz) to the (sx,sy,sz) ellipse if it is outside
|
||||
|
||||
+- ATTEMPT ----------------------------------------------------------+
|
||||
| actor->movementlogicfunc(actor, dt) |
|
||||
| AKGL_ERR_LOGICINTERRUPT raised here is swallowed: the actor |
|
||||
| is skipped for the rest of the tick and the loop continues |
|
||||
| self->gravity(self, actor, dt) |
|
||||
| ex -= ex * drag_x * dt (and y, z, each only if drag is nonzero) |
|
||||
| vx = ex + tx (and y, z) |
|
||||
| self->move(self, actor, dt) |
|
||||
+--------------------------------------------------------------------+
|
||||
|
||||
after the loop: self->gravity_time = curtime
|
||||
```
|
||||
|
||||
**A child actor is snapped and skipped.** An actor with a `parent` has its position set to
|
||||
the parent's plus its own `vx`/`vy`/`vz`, read as a fixed offset rather than as a velocity,
|
||||
and then it is skipped entirely. Children do not simulate. That is the mechanism a party
|
||||
member or a held item is built on — see [Chapter 12](12-actors.md).
|
||||
|
||||
**The thrust accumulation happens *before* `movementlogicfunc`, which is what sets `ax` and
|
||||
`sx`.** So the `ax` and `sx` a step uses are the ones the *previous* step's movement logic
|
||||
installed, and an actor's very first step sees `sx == 0` and has its thrust zeroed. The
|
||||
header's summary — "movement logic, then gravity, then drag, then velocity, then move" —
|
||||
leaves that out. It is one frame of lag at the start of a movement and no player will see
|
||||
it, but it is the reason a unit test that calls `simulate` once and checks `tx` gets zero.
|
||||
|
||||
**`gravity_time` is stamped after the loop, not before it.** A `gravity` or `move` that
|
||||
raises returns straight out of `akgl_physics_simulate` without stamping, so the *next*
|
||||
step measures `dt` from the older epoch and takes a double-length step — bounded by
|
||||
`max_timestep`, but still a visible jump. Treat a raised status out of `simulate` as
|
||||
needing a re-stamp of `gravity_time` before you continue.
|
||||
|
||||
## `dt` is not yours to supply
|
||||
|
||||
`akgl_physics_simulate` takes no timestep. It calls `SDL_GetTicksNS()` itself and measures
|
||||
against `self->gravity_time`, then bounds the result:
|
||||
|
||||
```text
|
||||
dt = (SDL_GetTicksNS() - self->gravity_time) / 1e9
|
||||
if ( max_timestep > 0 and dt > max_timestep ) dt = max_timestep
|
||||
if ( dt < 0 ) dt = 0
|
||||
```
|
||||
|
||||
`max_timestep` comes from the `physics.max_timestep` property and defaults to
|
||||
`AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` — a twentieth of a second, three frames at 60 Hz, so an
|
||||
ordinary dropped frame passes through untouched and a level load does not.
|
||||
|
||||
**That bound is not a nicety.** Everything below it multiplies by `dt`, so one enormous
|
||||
`dt` moves every actor by however far its velocity carries it over the whole stall. A
|
||||
quarter-second load under platformer gravity is a hundred pixels of fall between the first
|
||||
frame and the second, straight through whatever was underneath. That was a live defect
|
||||
until 0.6.0: `gravity_time` was never seeded, so the first step measured the entire time
|
||||
since SDL started. Both initializers seed it now, and the bound catches the rest. Advancing
|
||||
the world in slow motion through a hitch is the trade every engine makes here.
|
||||
|
||||
A zero or negative `max_timestep` disables the bound, for a caller who would rather have
|
||||
the real elapsed time and handle it themselves.
|
||||
|
||||
### Stepping deterministically in a test
|
||||
|
||||
Because `dt` is measured rather than passed, **there is no way to step the engine by an
|
||||
exact interval — but there is a way to step it by an exact bound.** `tests/physics_sim.c`
|
||||
documents the only technique that holds up under load:
|
||||
|
||||
```text
|
||||
sim_physics.max_timestep = dt; /* the step you want */
|
||||
sim_physics.gravity_time = 0; /* measured interval = whole process uptime */
|
||||
sim_physics.simulate(&sim_physics, NULL);
|
||||
```
|
||||
|
||||
Zeroing `gravity_time` makes the measured interval the process's whole uptime, which is
|
||||
always past the bound, so the step taken is `max_timestep` and nothing else. The scheduler
|
||||
cannot get in the way of that. The first version of that helper set `gravity_time` to
|
||||
`now - dt` and let the real clock supply the step; a machine busy enough to deschedule the
|
||||
process between that store and the `SDL_GetTicksNS()` inside `simulate` produced a longer
|
||||
step and a red suite, exactly once, under a parallel `ctest`. **That the engine cannot be
|
||||
stepped exactly is itself a finding**, recorded in `TODO.md` under "Arcade physics feel".
|
||||
|
||||
Two more rules from that suite, both learned the hard way. `main` must call `SDL_Init` —
|
||||
without it SDL sets its clock epoch on first use, `SDL_GetTicksNS()` returns something
|
||||
around 130 ns, and every `dt`-dependent assertion passes for a reason unrelated to the
|
||||
code. And read the numbers each simulation prints even when it is green: a change that
|
||||
keeps every assertion passing while moving the apex of a jump by 30 px is a change worth
|
||||
noticing.
|
||||
|
||||
## Choosing a backend
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
if ( strncmp(type->data, "null", 4) == 0) {
|
||||
PASS(errctx, akgl_physics_init_null(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( strncmp(type->data, "arcade", 6) == 0) {
|
||||
PASS(errctx, akgl_physics_init_arcade(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);
|
||||
```
|
||||
|
||||
**The match is on leading characters.** `"nullify"` selects `null` and `"arcadia"` selects
|
||||
`arcade`; anything else raises `AKERR_KEY` quoting what was asked for. Adding a backend
|
||||
means adding a name here, not a branch in the simulation.
|
||||
|
||||
**`akgl_game_init` does not call the factory, and there is no `physics.engine` property.**
|
||||
The `@file` block and the `akgl_physics_factory` doc comment in `include/akgl/physics.h`
|
||||
both say otherwise. They are wrong: `akgl_game_init` points `akgl_physics` at
|
||||
`akgl_default_physics` — zeroed storage whose four method pointers are all `NULL` — and
|
||||
never initializes it, and the string `physics.engine` appears nowhere in `src/`. The
|
||||
startup sequence in `include/akgl/game.h` has it right: **the application calls an
|
||||
initializer or the factory itself**, at step 4, after the configuration properties are set
|
||||
and before the first frame. Skip that and the first `akgl_game_update` calls through a
|
||||
`NULL` `simulate`.
|
||||
|
||||
The only caller of `akgl_physics_factory` inside the library is
|
||||
`akgl_tilemap_load_physics`, driven by a map's `physics.model` custom property — see
|
||||
[Chapter 13](13-tilemaps.md). `util/charviewer.c` shows the direct form,
|
||||
`akgl_physics_init_null(akgl_physics)`. Correcting the header comments is filed separately;
|
||||
until then, believe `game.h` and this chapter.
|
||||
|
||||
```c
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
/*
|
||||
* Bring up the arcade backend. akgl_game_init does NOT do this: it points
|
||||
* akgl_physics at akgl_default_physics, which is zeroed storage whose method
|
||||
* pointers are all NULL. Call an initializer, or the first akgl_game_update
|
||||
* calls through a NULL simulate.
|
||||
*
|
||||
* Properties first, because akgl_physics_init_arcade reads them.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *bring_up_physics(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_set_property("physics.gravity.y", "900.0"));
|
||||
/* There is no terminal-velocity setting. Drag is the mechanism: ey
|
||||
* approaches gravity_y / drag_y, so 900 / 1.8 caps the fall at 500 px/s. */
|
||||
PASS(errctx, akgl_set_property("physics.drag.y", "1.8"));
|
||||
|
||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_physics_init_arcade` reads seven properties — `physics.gravity.x`/`.y`/`.z`,
|
||||
`physics.drag.x`/`.y`/`.z` and `physics.max_timestep` — all with defaults, so an arcade
|
||||
backend with no configuration behaves like the null one until something is set. **Set the
|
||||
properties first.** With `AKGL_REGISTRY_PROPERTIES` uninitialized every read comes back as
|
||||
its default and this silently configures zero gravity and zero drag rather than reporting
|
||||
anything; see [Chapter 6](06-the-registry.md).
|
||||
|
||||
The gravity signs are chosen for screen space: x pulls toward screen left, y pulls down
|
||||
because y grows downward, z pulls away from the camera.
|
||||
|
||||
## `AKGL_ERR_LOGICINTERRUPT`
|
||||
|
||||
The one status in libakgl that is not a failure. Raised from an actor's
|
||||
`movementlogicfunc`, it means **"skip the rest of this tick for me"** — no gravity, no drag,
|
||||
no move for that actor this step — and `akgl_physics_simulate` handles it and carries on to
|
||||
the next actor. It is a control signal wearing an error's clothes.
|
||||
|
||||
**Only `movementlogicfunc` gets that treatment.** The `gravity` and `move` calls return
|
||||
straight out of the loop on any status, `AKGL_ERR_LOGICINTERRUPT` included, so a backend
|
||||
must never use it to opt an actor out from there — there it aborts the whole step, leaving
|
||||
the actors already processed advanced and the rest not.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
/*
|
||||
* A movement logic function that pins an actor to a floor at y = 400.
|
||||
*
|
||||
* The arcade backend never calls collide and never consults the tilemap, so
|
||||
* standing on something is the caller's job. Doing it here rather than after
|
||||
* the step is what keeps the actor from being drawn inside the floor for one
|
||||
* frame.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *stand_on_floor(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
/* The default logic still has to run: it is what copies the character's
|
||||
* speeds and accelerations onto the actor. */
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
if ( obj->y >= 400.0f ) {
|
||||
obj->y = 400.0f;
|
||||
/* ey is where gravity accumulates. Zero it, or the actor keeps
|
||||
* "falling" into the floor and cannot jump out of it. */
|
||||
obj->ey = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Raising this from movement logic means "skip the rest of this tick for me".
|
||||
* akgl_physics_simulate handles it and moves on to the next actor; the actor
|
||||
* gets no gravity, no drag and no move this step. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *frozen_in_cutscene(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)dt;
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "cutscene");
|
||||
}
|
||||
```
|
||||
|
||||
## What is not implemented
|
||||
|
||||
Stated plainly, because all of it is reachable by an ordinary-looking call and none of it
|
||||
announces itself at compile time.
|
||||
|
||||
**There is no collision detection, at all.**
|
||||
|
||||
- `akgl_physics_arcade_collide` always raises `AKERR_API` with the message "Not
|
||||
implemented". It is deliberately loud rather than silently permissive.
|
||||
- **`akgl_physics_simulate` never calls `collide`.** Not for the arcade backend, not for
|
||||
the null one, not ever. The vtable slot exists and the simulation does not use it.
|
||||
- `akgl_physics_arcade_move` does `position += velocity * dt` and nothing else. It does not
|
||||
clamp to the map, consult the tilemap, or test anything. **An actor walks straight
|
||||
through a wall and off the edge of the world.**
|
||||
|
||||
`akgl_physics_null_collide` *succeeds*, and that is not an oversight: the null backend's
|
||||
contract is "nothing collides", which is an answer. The arcade one means "nobody has
|
||||
written this yet", and a caller who reaches it should find out.
|
||||
|
||||
Until it exists, collision belongs in your `movementlogicfunc`, as in the example above.
|
||||
The sidescroller tutorial in [Chapter 19](19-tutorial-sidescroller.md) does exactly that and
|
||||
says why. `akgl_collide_*` in [Chapter 18](18-utilities.md) gives you the rectangle tests.
|
||||
|
||||
### Four gaps in the feel
|
||||
|
||||
Found by `tests/physics_sim.c`, which runs the arcade backend the way a game does — a
|
||||
Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed —
|
||||
and prints what the actor actually did. Three other defects that suite found *are* fixed in
|
||||
0.6.0. These four are not. All are in `TODO.md`, "Arcade physics feel".
|
||||
|
||||
| Gap | What you see | The workaround today |
|
||||
|---|---|---|
|
||||
| **No terminal velocity** | Gravity adds `gravity_y * dt` to `ey` every step and nothing bounds it. The jump simulation reaches 560 px/s in 0.7 s and would keep going | `physics.drag.y`. It makes `ey` approach `gravity_y / drag_y` instead of diverging. That is the only brake, and it is not documented as one anywhere else |
|
||||
| **Releasing a direction stops the actor dead** | `akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character at full speed stops within one frame — 0.0 px of drift measured in the second after release | Correct for Zelda, wrong for Mario. There is no friction or deceleration anywhere in the backend; `ax` is the only rate and it applies only while a direction is held. Decay `tx` yourself in `movementlogicfunc` |
|
||||
| **Explicit Euler, so trajectories are frame-rate dependent** | The same jump traces a slightly different arc at 30 Hz than at 144 Hz. The reversal measures 0.500 s to turn around where the closed form predicts 0.489 s — about 2% at 60 Hz, growing with the step | Nothing. 2% is not a bug a player can see. It is recorded because the fix (a fixed-step accumulator) is the same piece of work as removing the slow-motion trade `max_timestep` makes |
|
||||
| **A large drag coefficient inverts velocity** | `ex -= ex * drag_x * dt` is a first-order decay, so it only decays for `drag * dt < 1`. Past that it overshoots zero; past `2` it diverges. **Nothing rejects it** | With `dt` bounded to the default 0.05 s that needs a drag above 20, which is not a plausible setting. But `max_timestep` is caller-settable, so keep `drag * max_timestep` below 1 |
|
||||
|
||||
The `_off` handlers used to be worse: they zeroed `ay`, `ey`, `ty` and `vy` together, and
|
||||
`ey` is where gravity accumulates, so tapping down mid-jump stopped the character in the
|
||||
air. They clear `ax`/`tx` (or `ay`/`ty`) and nothing else now. Velocity was never theirs to
|
||||
clear either — `simulate` recomputes `v` as `e + t` every step.
|
||||
|
||||
## Statuses
|
||||
|
||||
`akgl_physics_simulate` propagates whatever an actor's `movementlogicfunc`, or the
|
||||
backend's `gravity` or `move`, raises, and **the first failure aborts the whole step** —
|
||||
the actors already processed are advanced and the rest are not. The status meanings are in
|
||||
[Chapter 4](04-errors.md); the ones you will meet here are `AKERR_NULLPOINTER` (a `NULL`
|
||||
`self` or `self->move`), `AKERR_API` (`collide`, always), `AKERR_VALUE` and `ERANGE` (a
|
||||
`physics.*` property that is not a number, or does not fit a `double`), and
|
||||
`AKGL_ERR_LOGICINTERRUPT`, which is not a failure.
|
||||
Reference in New Issue
Block a user