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

@@ -2220,7 +2220,12 @@ should loop — and the third is why both games are built out of `LABEL` and `GO
`akgl_CollisionShape` and a pooled proxy. Three things were deliberately left for later, and
each is here so the reasoning does not have to be reconstructed.
38. **Finish moving `COLLISION` and `BUMP` onto the subsystem.** What landed is the narrowphase
38. **Finish moving `COLLISION` and `BUMP` onto the subsystem.** **Half done.** The contact
is no longer discarded: `RCOLLISION(n, f)` reports what was hit, the normal, the depth,
the contact point and the minimum translation axis, and
`tests/akgl_backends.c::test_contact_geometry` pins the normal's *sign* for both parties
of a sprite-against-sprite hit -- getting that backwards tells both to move the same way,
and nothing else would have noticed. What stands is the pairing and the threshold below. What landed is the narrowphase
and the shapes; the seam is still `collisions(self, uint16_t *mask)` and the pairing is
still all-against-all over eight slots with a bounding-box reject in front of it.

View File

@@ -129,10 +129,13 @@ the target disarms it.
**reading it clears it**, which is what makes "has anything hit me since I last looked"
answerable.
Only type 1, sprite-to-sprite, is implemented. Types 2 and 3 are refused by name.
Type 1 is sprite against sprite and **type 2 is sprite against the static geometry
`SOLID` registers** — a second handler, on its own accumulator, so the two never disturb
each other. Type 3 is refused: there is no light pen.
Collision is by bounding box, not by pixel: two sprites whose boxes overlap but whose
artwork does not are reported as colliding.
Collision is by shape, not by pixel, and a sprite nobody has shaped collides with its
whole picture — so two sprites whose frames overlap but whose artwork does not are
reported as colliding.
```basic requires=akgl screenshot=sprite-collision
10 COLOR 1, 2
@@ -258,6 +261,60 @@ sixty-fifth is refused by name.
would be an unexplainable collision in the next; `CLR` leaves them alone, because it clears
variables and a rectangle is not one.
## What was hit, and which way to go
`BUMP` says *that* something collided. `RCOLLISION(n, f)` says what it was and how:
| `f` | Gives |
|---|---|
| 0 | what sprite `n` last hit — 0 nothing yet, 1 a sprite, 2 static geometry |
| 1 | which: the other sprite's number, or the `SOLID` id |
| 2 | the contact normal's x, a float from -1 to 1 |
| 3 | the normal's y. Negative means the surface is above, because y grows downward |
| 4 | how deep, in pixels |
| 5 | where they touched, x |
| 6 | where they touched, y |
| 7 | which axis to reverse: 1 for x, 2 for y |
**The normal points out of the other thing and toward this one.** Move along it by the
depth and you are exactly clear:
```basic norun
D% = RCOLLISION(1, 4)
BX# = BX# + (RCOLLISION(1, 2) * D%)
BY# = BY# + (RCOLLISION(1, 3) * D%)
```
**Field 7 is the one that deletes the most BASIC.** Working out which axis to reverse from
an overlap rectangle is four `IF`s and a comparison, and doing it from the normal means
comparing two floats — which is where [Chapter 13](13-differences.md)'s left-operand rule
catches people. So it is computed for you:
```basic norun
A# = RCOLLISION(1, 7)
IF A# = 1 THEN BVX# = 0 - BVX#
IF A# = 2 THEN BVY# = 0 - BVY#
```
Both sides of a sprite-against-sprite hit get their own record, each pointing the way *that*
sprite has to move. Sharing one would tell both to go the same direction, which is how two
things end up stuck inside each other.
**Fields 2, 3 and 4 are floats**, so assign them to a `%` variable. A `#` variable throws
the fraction away and a push-out lands a pixel short.
**The record is sticky.** It is replaced whenever that sprite is in a contact and otherwise
left alone, so `BUMP` stays the event and this stays the detail of it — ask `BUMP` first,
then read the record. Reading `BUMP` clears it, so the two never disagree about whether
there was anything to describe.
A sprite in several overlaps at once reports the **deepest**, because the deepest is the one
you have to undo.
**Fields 5 and 6 are exact for two boxes and approximate for anything else** — libakgl's
solver returns a point on the portal it converged to. The normal and the depth are exact for
every pair, so if you need the contact *point*, keep both shapes boxes.
## Reading state back
| Function | Gives |

View File

@@ -25,6 +25,7 @@ so a call with the wrong number is a syntax error rather than a surprise.
| `POINTER` | 1 | `POINTER(V)` | The address of a variable's value. |
| `POINTERVAR` | 1 | `POINTERVAR(V)` | The address of the variable structure itself, metadata included. |
| `RAD` | 1 | `RAD(n)` | Degrees converted to radians. |
| `RCOLLISION` | 2 | `RCOLLISION(n, f)` | What sprite `n` last collided with and how: what, which, the normal, the depth, the point, and which axis to reverse. See Chapter 8. |
| `RGR` | 1 | `RGR(f)` | The `GRAPHIC` mode (0), the drawing surface's width (1) or height (2) in pixels, or a character cell's width (3) or height (4). |
| `RIGHT` | 2 | `RIGHT(A$, n)` | The rightmost `n` characters. Clamped. |
| `RWINDOW` | 1 | `RWINDOW(f)` | The current text window's rows (0) or columns (1). Field 2 is a C128 screen mode and is refused. |

View File

@@ -185,6 +185,9 @@ interpreter's error code, which bears no relation to a Commodore error number. P
there is no light pen.
- **`SOLID` is an addition.** BASIC 7.0 has no static collision geometry at all, and it is
what lets a program collide with something that is not one of the eight sprites.
- **`RCOLLISION` is an addition, and nothing is resolved.** A contact is a *report*: it says
what was hit, which way is out and how far, and reversing the ball is still the program's
job. BASIC 7.0 has `BUMP` and a bitmask, and no way to ask any of this.
- **Priority and multicolour are recorded but not drawn.**
- **`SPRDEF` is out of scope.**

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;
/**

View File

@@ -582,6 +582,27 @@ akerr_ErrorContext *akbasic_collision_service(akbasic_Runtime *obj)
if ( obj->sprites->solids != NULL ) {
PASS(errctx, obj->sprites->solids(obj->sprites, &solidmask));
}
/*
* Pull the detail for whatever collided, while the scan that produced it is
* still the current one. RCOLLISION answers from here rather than from the
* device, because by the time a handler runs the sprites have moved on --
* akbasic_sprite_service() runs immediately before this.
*/
if ( (mask | solidmask) != 0 && obj->sprites->contact != NULL ) {
uint16_t both = (uint16_t)(mask | solidmask);
int i = 0;
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
bool found = false;
if ( (both & (uint16_t)(1u << i)) == 0 ) {
continue;
}
/* PASS rather than CATCH: this is a loop. */
PASS(errctx, obj->sprites->contact(obj->sprites, i + 1,
&obj->sprite_state.contacts[i], &found));
}
}
if ( solidmask != 0 ) {
obj->sprite_state.bumpedsolid |= solidmask;
PASS(errctx, akbasic_runtime_raise_interrupt(obj, AKBASIC_INTERRUPT_BACKGROUND));
@@ -895,6 +916,65 @@ akerr_ErrorContext *akbasic_fn_rsphit(akbasic_Runtime *obj, akbasic_ASTLeaf *exp
SUCCEED_RETURN(errctx);
}
/**
* @brief One field of the contact sprite n is in.
*
* Sticky: the record is replaced whenever that sprite is in a contact and
* otherwise left alone, so `BUMP` remains the event -- "has anything hit me since
* I last looked" -- and this is the detail of it. Making it clear itself when
* nothing is touching would break the pairing, because `BUMP` accumulates across
* steps and a once-a-frame poll would find the detail already gone.
*
* Reading `BUMP` clears it, so the two stay in step.
*/
akerr_ErrorContext *akbasic_fn_rcollision(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
PREPARE_ERROR(errctx);
akbasic_Contact *contact = NULL;
int64_t n = 0;
int64_t field = 0;
int index = 0;
(void)lval; (void)rval;
FAIL_ZERO_RETURN(errctx, (obj != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in RCOLLISION");
PASS(errctx, nth_number(obj, expr, "RCOLLISION", 0, &n));
PASS(errctx, nth_number(obj, expr, "RCOLLISION", 1, &field));
PASS(errctx, sprite_index((int)n, "RCOLLISION", &index));
contact = &obj->sprite_state.contacts[index];
switch ( field ) {
case 0:
PASS(errctx, integer_result(obj, (int64_t)contact->what, dest));
break;
case 1:
PASS(errctx, integer_result(obj, (int64_t)contact->other, dest));
break;
case 2:
PASS(errctx, float_result(obj, contact->nx, dest));
break;
case 3:
PASS(errctx, float_result(obj, contact->ny, dest));
break;
case 4:
PASS(errctx, float_result(obj, contact->depth, dest));
break;
case 5:
PASS(errctx, float_result(obj, contact->px, dest));
break;
case 6:
PASS(errctx, float_result(obj, contact->py, dest));
break;
case 7:
PASS(errctx, integer_result(obj, (int64_t)contact->axis, dest));
break;
default:
FAIL_RETURN(errctx, AKBASIC_ERR_BOUNDS,
"RCOLLISION: field %" PRId64 " is outside 0..7", field);
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_fn_rsprite(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
{
akbasic_Sprite *sprite = NULL;

View File

@@ -625,6 +625,61 @@ static akerr_ErrorContext AKERR_NOIGNORE *sync_proxy(akbasic_AkglSprites *state,
SUCCEED_RETURN(errctx);
}
/**
* @brief Keep @p contact for slot @p i if it is deeper than what is there.
*
* A sprite may be in several overlaps at once -- a ball touching two bricks at a
* corner -- and only one can be reported. The deepest is the one that has to be
* undone, so it is the one worth keeping.
*
* The normal comes out of libakgl pointing out of the *other* shape and toward
* this one, which is the direction a program wants: move along it by the depth
* and you are clear.
*/
static void record_contact(akbasic_AkglSprites *state, int i, int what, int other,
akgl_Contact *contact)
{
akbasic_Contact *dest = &state->contacts[i];
if ( state->hascontact[i] && dest->depth >= (double)contact->depth ) {
return;
}
dest->what = what;
dest->other = other;
dest->nx = (double)contact->nx;
dest->ny = (double)contact->ny;
dest->depth = (double)contact->depth;
dest->px = (double)contact->px;
dest->py = (double)contact->py;
/*
* The minimum translation axis, from the normal rather than from an overlap
* rectangle: whichever component is larger is the axis the shapes are least
* far through, and therefore the one to reverse. Computed here because doing
* it in BASIC means comparing two floats, which is exactly where this
* dialect's left-operand rule bites.
*/
dest->axis = ((contact->nx < 0.0f ? -contact->nx : contact->nx)
>= (contact->ny < 0.0f ? -contact->ny : contact->ny)) ? 1 : 2;
state->hascontact[i] = true;
}
static akerr_ErrorContext *spr_contact(akbasic_SpriteBackend *self, int n,
akbasic_Contact *dest, bool *found)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int i = 0;
PASS(errctx, slot_of(self, n, &state, &i));
FAIL_ZERO_RETURN(errctx, (dest != NULL && found != NULL), AKERR_NULLPOINTER,
"NULL argument in contact");
*found = state->hascontact[i];
if ( *found ) {
*dest = state->contacts[i];
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Run the scan if anything has changed, and leave both masks on the state.
*
@@ -690,6 +745,7 @@ static akerr_ErrorContext AKERR_NOIGNORE *run_scan(akbasic_AkglSprites *state)
}
state->lastmask = 0;
state->lastsolidmask = 0;
memset(state->hascontact, 0, sizeof(state->hascontact));
/*
* **Two phases, and the split is the whole performance story.**
@@ -756,6 +812,15 @@ static akerr_ErrorContext AKERR_NOIGNORE *run_scan(akbasic_AkglSprites *state)
if ( hit ) {
state->lastmask |= (uint16_t)(1u << i);
state->lastmask |= (uint16_t)(1u << j);
record_contact(state, i, 1, j + 1, &contact);
/*
* And the mirror for the other sprite. The normal points out of
* b toward a, so b's own copy has to be negated or both sprites
* would be told to move the same way.
*/
contact.nx = -contact.nx;
contact.ny = -contact.ny;
record_contact(state, j, 1, i + 1, &contact);
}
}
}
@@ -789,6 +854,7 @@ static akerr_ErrorContext AKERR_NOIGNORE *run_scan(akbasic_AkglSprites *state)
AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
if ( hit ) {
state->lastsolidmask |= (uint16_t)(1u << i);
record_contact(state, i, 2, j + 1, &contact);
}
}
}
@@ -959,6 +1025,7 @@ akerr_ErrorContext *akbasic_sprite_init_akgl(akbasic_SpriteBackend *obj, akbasic
obj->shape = spr_shape;
obj->solid = spr_solid;
obj->solids = spr_solids;
obj->contact = spr_contact;
SUCCEED_RETURN(errctx);
}

View File

@@ -129,6 +129,7 @@ static const akbasic_Verb VERBS[] = {
{ "PUDEF", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_pudef },
{ "QUIT", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_quit },
{ "RAD", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_rad },
{ "RCOLLISION", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_rcollision },
{ "READ", AKBASIC_TOK_COMMAND, -1, akbasic_parse_read, akbasic_cmd_read },
{ "RECORD", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_record },
{ "REM", AKBASIC_TOK_REM, -1, NULL, NULL },

View File

@@ -97,6 +97,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sprsav(struct akbasic_Runtime *ob
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_bump(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rspcolor(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rsppos(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rcollision(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rsphit(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_fn_rsprite(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest);

View File

@@ -1069,6 +1069,99 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_drawing_layer_persists(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief The contact says which way to push out and how far.
*
* The reason for going through libakgl's narrowphase at all. Four comparisons
* can say *that* two things overlap; only a narrowphase can say by how much and
* in which direction, and that is what a game needs to reverse a ball or stop a
* player at a wall.
*
* **The normal's sign is the assertion worth having.** It points out of the other
* shape and toward this one, so a program moves along it by the depth and is
* clear. Getting it backwards is the classic mistake and nothing else here would
* notice.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_contact_geometry(void)
{
PREPARE_ERROR(errctx);
akbasic_Contact contact;
uint16_t mask = 0;
bool found = false;
/*
* A 24x21 sprite at (100, 100), and a wall whose left edge is at 118. They
* overlap by six pixels along x and by far more along y, so the minimum
* translation axis is x and the sprite has to move left to clear it.
*/
PASS(errctx, solidmask_for("10 DIM P#(63)\n"
"20 FOR I# = 0 TO 62\n"
"30 P#(I#) = 255\n"
"40 NEXT I#\n"
"50 SPRSAV P#, 1\n"
"60 SPRITE 1, 1\n"
"70 SOLID 3, 118, 0, 300, 300\n"
"80 MOVSPR 1, 100, 100\n", &mask));
TEST_REQUIRE_INT(mask, 0x01);
memset(&contact, 0, sizeof(contact));
PASS(errctx, SPRITES.contact(&SPRITES, 1, &contact, &found));
TEST_REQUIRE(found, "sprite 1 overlapped a wall, so it should have a contact");
TEST_REQUIRE_INT(contact.what, 2);
TEST_REQUIRE_INT(contact.other, 3);
TEST_REQUIRE_INT(contact.axis, 1);
/* Six pixels of overlap: 100 + 24 = 124, less the wall's 118. */
TEST_REQUIRE(contact.depth > 5.5 && contact.depth < 6.5,
"the overlap is six pixels, got %f", contact.depth);
/* Out of the wall and toward the sprite is leftward, so negative x. */
TEST_REQUIRE(contact.nx < -0.5,
"the normal should point away from the wall, got (%f, %f)",
contact.nx, contact.ny);
stop_runtime();
/* The same overlap from above: the axis becomes y and the normal flips. */
PASS(errctx, solidmask_for("10 DIM P#(63)\n"
"20 FOR I# = 0 TO 62\n"
"30 P#(I#) = 255\n"
"40 NEXT I#\n"
"50 SPRSAV P#, 1\n"
"60 SPRITE 1, 1\n"
"70 SOLID 3, 0, 118, 300, 300\n"
"80 MOVSPR 1, 100, 100\n", &mask));
TEST_REQUIRE_INT(mask, 0x01);
memset(&contact, 0, sizeof(contact));
PASS(errctx, SPRITES.contact(&SPRITES, 1, &contact, &found));
TEST_REQUIRE(found, "sprite 1 should have a contact");
TEST_REQUIRE_INT(contact.axis, 2);
TEST_REQUIRE(contact.ny < -0.5,
"the normal should point up out of the wall, got (%f, %f)",
contact.nx, contact.ny);
stop_runtime();
/*
* Sprite against sprite, and **both** get a contact pointing the way *they*
* have to move. Sharing one normal would tell both to go the same direction,
* which is how two things end up stuck inside each other.
*/
PASS(errctx, two_sprites("90 MOVSPR 1, 100, 100\n"
"100 MOVSPR 2, 118, 100\n", &mask));
TEST_REQUIRE_INT(mask, 0x03);
memset(&contact, 0, sizeof(contact));
PASS(errctx, SPRITES.contact(&SPRITES, 1, &contact, &found));
TEST_REQUIRE(found, "sprite 1 should have a contact");
TEST_REQUIRE_INT(contact.what, 1);
TEST_REQUIRE_INT(contact.other, 2);
TEST_REQUIRE(contact.nx < -0.5, "sprite 1 is on the left, so it moves left");
memset(&contact, 0, sizeof(contact));
PASS(errctx, SPRITES.contact(&SPRITES, 2, &contact, &found));
TEST_REQUIRE(found, "sprite 2 should have a contact too");
TEST_REQUIRE_INT(contact.other, 1);
TEST_REQUIRE(contact.nx > 0.5, "sprite 2 is on the right, so it moves right");
stop_runtime();
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
@@ -1139,6 +1232,7 @@ int main(void)
CATCH(errctx, test_collision_cross_shape());
CATCH(errctx, test_sprite_shapes());
CATCH(errctx, test_static_geometry());
CATCH(errctx, test_contact_geometry());
CATCH(errctx, test_drawing_layer_persists());
} CLEANUP {
if ( font != NULL ) {

View File

@@ -57,6 +57,8 @@ typedef struct
/* Sprites */
uint16_t collisions; /* what the next collisions() call reports */
uint16_t solidcollisions; /* and what the next solids() call reports */
akbasic_Contact contact; /* what the next contact() call describes */
bool hascontact;
int patternbytes[AKBASIC_MAX_SPRITES]; /* bytes the last define() carried, per sprite */
} akbasic_MockDevice;
@@ -463,6 +465,22 @@ static akerr_ErrorContext *mock_spr_solid(akbasic_SpriteBackend *self, int id, b
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_spr_contact(akbasic_SpriteBackend *self, int n,
akbasic_Contact *dest, bool *found)
{
PREPARE_ERROR(errctx);
(void)self;
FAIL_ZERO_RETURN(errctx, (dest != NULL && found != NULL), AKERR_NULLPOINTER,
"NULL argument in contact");
*found = MOCK.hascontact;
if ( *found ) {
*dest = MOCK.contact;
dest->other = n;
}
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- fixture -- */
/** @brief Reset the recorder and populate all three vtables. */
@@ -523,6 +541,7 @@ static void mock_devices_init(void)
MOCK_SPRITES.solids = mock_spr_solids;
MOCK_SPRITES.shape = mock_spr_shape;
MOCK_SPRITES.solid = mock_spr_solid;
MOCK_SPRITES.contact = mock_spr_contact;
}
/** @brief Set what the next collisions() call will report. Bit n-1 is sprite n. */
@@ -539,6 +558,19 @@ static void mock_set_solid_collisions(uint16_t mask)
MOCK.solidcollisions = mask;
}
/** @brief Set the contact the next contact() call will report. */
__attribute__((unused))
static void mock_set_contact(int what, double nx, double ny, double depth, int axis)
{
memset(&MOCK.contact, 0, sizeof(MOCK.contact));
MOCK.contact.what = what;
MOCK.contact.nx = nx;
MOCK.contact.ny = ny;
MOCK.contact.depth = depth;
MOCK.contact.axis = axis;
MOCK.hascontact = (what != 0);
}
/** @brief Prime the input backend with keystrokes GET will drain in order. */
__attribute__((unused))
static void mock_push_keys(const char *keys)