Files
akbasic/docs/08-sprites.md
Andrew Kesterson a9600c3fcc 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
2026-08-02 10:53:26 -04:00

336 lines
12 KiB
Markdown

# 8. Sprites
Eight sprites, numbered 1 to 8, as on a C128. They need the SDL build.
A sprite here is a real object in the host's graphics library, registered alongside
whatever the host's own game is drawing — so a game that embeds this interpreter can
see and manipulate a script's sprites.
## Giving a sprite a picture
`SPRSAV` does it, and it takes three kinds of source.
### From an image file
```basic requires=akgl setup=ship
10 SPRSAV "ship.png", 1
```
The most useful form, and not one a C128 has. The path is tried against the working
directory first and then against the directory the running program was loaded from — so
a `.bas` stored beside its artwork works wherever you launch it from. Anything the
image library can decode will do.
A sprite loaded this way takes **the image's own size**. It is not forced to 24 by 21.
### From a saved region
```basic requires=akgl
10 BOX 1, 0, 0, 24, 21
20 SSHAPE A$, 0, 0, 24, 21
30 SPRSAV A$, 1
```
Draw it with the graphics verbs, capture it, install it. The C128 documents `SPRSAV`'s
string as the `SSHAPE` data format at a fixed 24 by 21, so sharing the mechanism is
faithful rather than a shortcut.
### From data
```basic norun
10 DIM P#(63)
20 FOR I# = 0 TO 62
30 READ P#(I#)
40 NEXT I#
50 SPRSAV P#, 1
60 DATA 255, 129, 129, ...
```
63 bytes: three per row, twenty-one rows, most significant bit leftmost. This is the
form a type-in listing uses.
**It takes an integer array, not a string.** A C128 puts the raw bytes in a string; a
string here is NUL-terminated, so it cannot hold a zero byte and therefore cannot hold
a sprite. `DIM P#(63)` is exactly the right size.
Writing a sprite back out — `SPRSAV 1, A$` — is refused. That would be a disk
operation.
## Showing and moving
```basic requires=akgl screenshot=sprites
10 COLOR 1, 2
20 CIRCLE 1, 20, 20, 18, 18
30 PAINT 1, 20, 20
40 SSHAPE A$, 0, 0, 40, 40
50 GRAPHIC 1, 1
60 FOR N# = 1 TO 3
70 SPRSAV A$, N#
80 NEXT N#
90 SPRITE 1, 1
100 SPRITE 2, 1, 6
110 SPRITE 3, 1, 8, 0, 1, 1
120 MOVSPR 1, 30, 80
130 MOVSPR 2, 110, 80
140 MOVSPR 3, 190, 60
```
![](images/sprites.png)
Three sprites from one drawing: the first in the default colour, the second in colour
6, and the third in colour 8 with both expansion bits set, which is what makes it twice
the size. `GRAPHIC 1, 1` on line 50 wipes the drawing the sprites were captured from —
the picture would otherwise still show the original disc in the top-left corner.
**A sprite's colour multiplies the artwork rather than replacing it**, so a white disc
takes the colour cleanly and a coloured one comes out darker than you asked for.
`SPRITE n [,on] [,colour] [,priority] [,xexpand] [,yexpand] [,multicolour]`. Only the
number is required and **an argument you leave out is left alone**, so `SPRITE 1, 1`
turns sprite 1 on without disturbing its colour.
`MOVSPR` has four forms, and the punctuation is what tells them apart:
| Form | What it does |
|---|---|
| `MOVSPR 1, 100, 50` | put it at (100, 50) |
| `MOVSPR 1, +10, -20` | move it *by* that much |
| `MOVSPR 1, 10 ; 90` | move it 10 pixels along bearing 90 |
| `MOVSPR 1, 45 # 8` | set it moving along bearing 45 at speed 8 |
A bearing is degrees **clockwise from straight up**, so 0 is north and 90 is east. In
the polar form the distance comes first and the angle second — the opposite order from
the continuous form, which is a trap worth remembering.
The continuous form does not move anything on the statement itself. The sprite moves as
the program runs, paced by the host's clock. Speed 0 stops it.
Coordinates are the same window pixels the drawing verbs use, not the VIC-II's raster
coordinates — see Chapter 6, and `RGR(1)` and `RGR(2)` for how big the window is.
`SCALE` does not apply to them: a sprite is positioned in device pixels whatever the
drawing verbs are doing.
## Collision
```basic norun
10 COLLISION 1, BUMPED
20 REM ... main loop ...
90 GOTO 20
100 LABEL BUMPED
110 PRINT "HIT: " + BUMP(1)
120 RETURN
```
`COLLISION 1, target` calls a subroutine when two sprites overlap. The handler is
entered between lines and must end in `RETURN`, exactly like a `GOSUB` body. Omitting
the target disarms it.
`BUMP(1)` returns a bitmask of which sprites have collided — bit 0 is sprite 1 — and
**reading it clears it**, which is what makes "has anything hit me since I last looked"
answerable.
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 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
20 CIRCLE 1, 20, 20, 14, 14
30 PAINT 1, 20, 20
40 BOX 1, 0, 0, 40, 40
50 SSHAPE A$, 0, 0, 40, 40
60 GRAPHIC 1, 1
70 SPRSAV A$, 1
80 SPRSAV A$, 2
90 SPRITE 1, 1, 6
100 SPRITE 2, 1, 3
110 MOVSPR 1, 110, 55
120 MOVSPR 2, 145, 90
```
![](images/sprite-collision.png)
The artwork here includes a border around the whole 40 by 40 sprite, so each sprite
draws its own bounding box. Those boxes overlap at one corner and the two discs are
nowhere near each other — and `BUMP(1)` reports a collision.
## Giving a sprite a shape
That false positive is what `SPRHIT` is for. It says what part of a sprite collides,
rather than leaving it as the whole picture:
```basic norun
SPRHIT n, kind
SPRHIT n, kind, x1, y1, x2, y2
```
| `kind` | Is |
|---|---|
| 0 | nothing — the sprite stays on screen and stops colliding |
| 1 | a box |
| 2 | a circle, inscribed in the rectangle |
| 3 | a capsule, round ends left and right |
| 4 | a capsule, round ends top and bottom |
The rectangle is **two corners measured from the sprite's top-left**, in device pixels —
the same `x1, y1, x2, y2` that `BOX` and `SSHAPE` take, because a dialect with two
spellings for a rectangle is one nobody can write from memory. Leave it out and the shape
fits whatever the picture turned out to be, which is what a sprite loaded from a file
needs: `SPRSAV "ship.png", 1` takes the image's own size and the program never learns what
that was.
So the two discs above stop colliding as soon as they are discs:
```basic norun
90 SPRHIT 1, 2
100 SPRHIT 2, 2
```
and a ship whose art does not fill its frame can say so:
```basic norun
SPRHIT 1, 1, 4, 2, 20, 19
```
**A sprite nobody has shaped collides with its whole frame**, expansion bits included,
which is what every sprite did before `SPRHIT` existed. Adding the verb changed no existing
program.
`SPRHIT n, 0` is the ghost, the flashing invulnerable player and the pickup that has
already been taken: still drawn, no longer in the way. Hiding the sprite with `SPRITE n, 0`
also stops it colliding, and is what you want when it should not be seen either.
`RSPHIT(n, f)` reads it back, in `SPRHIT`'s own argument order — 0 the kind, then 1 to 4
for the two corners. It needs no sprite device, the way `RSPPOS` does not.
## Colliding with things that are not sprites
There are eight sprites. A wall of bricks wants sixty, and until now the only way to
collide with one was to do the arithmetic yourself against your own array.
`SOLID` registers a rectangle the interpreter will collide sprites against, and it costs
no sprite slot:
```basic norun
SOLID id, x1, y1, x2, y2 register or replace rectangle id
SOLID id retire it
SOLID retire them all
```
`id` is 1 to 64 and it is **your own number**. That is the whole trick for "which brick did
I hit": the id comes back out again, so a wall built as `SOLID I#, ...` maps straight onto
`B#(I#)` with no lookup. Retiring a broken brick is the same verb, `SOLID I#`.
Rectangles are in window pixels — the coordinates `BOX` draws in and `MOVSPR` positions in,
with `SCALE` not applying, exactly as for a sprite. So a brick drawn with
`BOX 1, X#, Y#, X# + 24, Y# + 10` is registered with the same four expressions.
The bare form retiring everything is what a level change wants, and it follows `TRAP`,
`COLLISION` and `DCLOSE`, which all read absence as "off".
**`COLLISION 2` is how you hear about it.** On a C128 type 2 is a sprite against the set
pixels of the bitmap screen; here it is a sprite against the rectangles `SOLID` registered,
which is the same question in a form this interpreter can answer. `BUMP(2)` is its mask,
and it is a **separate** accumulator from `BUMP(1)` — a sprite hitting a wall never sets a
bit in the sprite-to-sprite mask, so a program that only cares about one of them is not
disturbed by the other.
```basic norun
10 COLLISION 2, WALL
20 FOR I# = 1 TO 60
30 SOLID I#, BX#(I#), BY#(I#), BX#(I#) + 24, BY#(I#) + 10
40 NEXT I#
...
100 LABEL WALL
110 M# = BUMP(2)
120 IF (M# AND 1) = 0 THEN RETURN
130 REM ... the ball hit something ...
140 RETURN
```
Sixty-four is the ceiling, and it is a real budget rather than a round number: these come
out of a pool of collision proxies shared with whatever game this interpreter is embedded
in, and eight sprites plus sixty-four rectangles is most of akbasic's share of it. The
sixty-fifth is refused by name.
`NEW` retires them all. A rectangle is invisible, so one left behind by a deleted program
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 |
|---|---|
| `RSPPOS(n, 0)` | x |
| `RSPPOS(n, 1)` | y |
| `RSPPOS(n, 2)` | speed |
| `RSPRITE(n, f)` | one of `SPRITE`'s settings, in `SPRITE`'s own argument order |
| `RSPCOLOR(1)` or `RSPCOLOR(2)` | one of the shared multicolour registers |
These read the interpreter's own state rather than asking the device, so they work
even with no device attached.
## SPRDEF
Not implemented, and deliberately. It is an interactive full-screen sprite editor
driven by single keystrokes, not something a program can call — and this interpreter
does not own the screen it would take over. The three `SPRSAV` forms replace it.