Say what was hit, which way is out, and how far

The narrowphase has been producing a contact since it went in, and the interpreter
was throwing it away. `RCOLLISION(n, f)` reports it: what was hit (a sprite or a
`SOLID` rectangle), which one, the contact normal, the penetration depth, the
contact point, and which axis to reverse.

**The normal points out of the other thing and toward this one**, so a program
moves along it by the depth and is exactly clear. That sign is the one assertion
in the new test that could not be caught any other way -- both parties of a
sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move, and sharing one would tell both to go the same direction,
which is how two things end up stuck inside each other.

**Field 7 is the one that deletes the most BASIC.** It is the minimum translation
axis, computed from the normal in C, and it is there because doing it in BASIC
means comparing two floats -- which is exactly where this dialect's left-operand
rule catches people. `BALLBRICKS`/`TESTCELL` in the artwork breakout spend six
lines computing an overlap rectangle and comparing its width to its height to get
this number.

The record is **sticky and deepest-wins**: replaced whenever that sprite is in a
contact and otherwise left alone, so `BUMP` stays the event and this stays the
detail of it. Making it clear itself when nothing touches would break the pairing,
because `BUMP` accumulates across steps and a once-a-frame poll would find the
detail already gone. Reading `BUMP` clears both, so they cannot disagree.

Deliberately narrower than `akgl_Contact`: no actor pointers, because BASIC has no
actor; no tile fields, because there is no tilemap; no z, because every test is
planar; and **no `dt` and no `sensor`**, which libakgl documents as filled in by
the resolver. This interpreter never resolves anything, so those two come back
zero and mean nothing, and an always-zero field in a reference table is a lie.

Documented with the two caveats that matter: fields 2, 3 and 4 are floats and want
a `%` variable, and the contact *point* is exact only for two boxes -- libakgl's
solver returns a point on the portal it converged to, while the normal and depth
are exact for every pair.

Chapter 8's collision section stops claiming only type 1 exists, which has been
false since the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
This commit is contained in:
2026-08-02 10:53:26 -04:00
parent 5709dc160c
commit a9600c3fcc
12 changed files with 401 additions and 4 deletions

View File

@@ -357,6 +357,17 @@ typedef struct
uint16_t lastmask;
uint16_t lastsolidmask;
bool lastvalid;
/**
* The deepest contact each sprite was in when the scan last ran.
*
* Kept as a side effect of the pass that computes the masks, because that is
* the only place the information exists -- `akgl_collision_test()` produces
* it and a caller that discards it cannot get it back. Deepest wins, because
* the deepest overlap is the one a program has to undo.
*/
akbasic_Contact contacts[AKBASIC_MAX_SPRITES];
bool hascontact[AKBASIC_MAX_SPRITES];
} akbasic_AkglSprites;
/**

View File

@@ -151,6 +151,34 @@ typedef struct
double y2;
} akbasic_Solid;
/**
* @brief What sprite n last collided with, and how.
*
* Narrower than libakgl's `akgl_Contact` on purpose. No actor pointers, because
* BASIC has no actor; no tile fields, because there is no tilemap; no z, because
* every test is planar. And **no `dt` and no `sensor`**, which libakgl documents
* as filled in by the resolver -- this interpreter never resolves anything, so
* they would come back zero and mean nothing. An always-zero field in a reference
* table is a lie.
*
* `axis` is the one field that is not the library's. It is the minimum
* translation axis -- which way to reverse -- and it is here because working it
* out from the normal is exactly where this dialect's left-operand arithmetic
* rule catches people, and because it is the single field that deletes the most
* BASIC.
*/
typedef struct
{
int what; /* 0 nothing yet, 1 a sprite, 2 static geometry */
int other; /* the other sprite's number, or the SOLID id */
double nx; /* unit normal, out of the other and toward this */
double ny;
double depth; /* how far in, in pixels */
double px; /* where they touched */
double py;
int axis; /* 1 reverse x, 2 reverse y */
} akbasic_Contact;
/**
* @brief The sprite verbs' own state, which lives on the runtime.
*
@@ -164,6 +192,7 @@ typedef struct
{
akbasic_Sprite sprites[AKBASIC_MAX_SPRITES];
akbasic_Solid solids[AKBASIC_MAX_SOLIDS];
akbasic_Contact contacts[AKBASIC_MAX_SPRITES];
int sharedcolor1; /* SPRCOLOR's two multicolour registers, 1-16 */
int sharedcolor2;
uint16_t bumped; /* bit n-1 set when sprite n has collided */
@@ -282,6 +311,22 @@ typedef struct akbasic_SpriteBackend
* reports none -- a step is not a statement and has no line to blame.
*/
akerr_ErrorContext AKERR_NOIGNORE *(*solids)(struct akbasic_SpriteBackend *self, uint16_t *mask);
/**
* Describe sprite @p n's deepest collision from the scan just run.
*
* @p found comes back false when that sprite hit nothing, and @p dest is
* then untouched. Deepest rather than first, because the deepest is the one
* a program has to undo.
*
* A pure accessor: it reads what the same pass that answered `collisions`
* and `solids` already worked out. That is why the contract on `collisions`
* says the two are about the same instant -- this depends on it.
*
* Optional. Withholding it makes `RCOLLISION` report nothing rather than
* refuse, the way a runtime with no backend at all does.
*/
akerr_ErrorContext AKERR_NOIGNORE *(*contact)(struct akbasic_SpriteBackend *self, int n,
akbasic_Contact *dest, bool *found);
} akbasic_SpriteBackend;
/**