Files
akbasic/src/sprite_akgl.c
Tachikoma 66d8670690 Collide sprites with rectangles that are not sprites
`SOLID id, x1, y1, x2, y2` registers static collision geometry; `SOLID id`
retires one and a bare `SOLID` retires them all, the way `TRAP`, `COLLISION` and
`DCLOSE` all read absence. `COLLISION 2` and `BUMP(2)` stop being refused and
mean *sprite met static geometry*.

**This is the thing eight sprite slots made impossible.** A wall of bricks wants
sixty, so until now a program could only collide with one by doing the
arithmetic itself against its own array -- which is exactly what both breakout
listings do, at about two hundred lines between them. A rectangle costs no sprite
slot.

The id is the **program's own number**, 1 to 64, not a minted handle. 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 onto `B#(I#)` with no lookup, and retiring a broken
brick is `SOLID I#`.

`COLLISION 2` was refused with "sprite-to-background collision needs the screen
read back every frame", which was true of the question a C128 asks -- a sprite
against the bitmap's set pixels. `SOLID` gives this interpreter a background made
of rectangles instead, which is the same question in a form it can answer. Same
move `SPRSAV` made when it learned to take an image path.
`AKBASIC_INTERRUPT_BACKGROUND` has been sitting in the interrupt table commented
"COLLISION 2 -- sprite met background; refused" the whole time. Its accumulator is
separate, so a sprite hitting a wall never sets a bit in `BUMP(1)`.

**There is no `akgl_CollisionWorld` here, and that is deliberate.** libakgl's
uniform grid keeps its cell heads, cell size and origin in file-scope statics, so
it is one index per process -- and `akgl_collision_world_init()` ends in a
`reset()` that memsets those heads *and* calls
`akgl_heap_init_collision_cells()`. An interpreter embedded in a game with its
own collision world would have destroyed every registration that game had made,
on the first `SOLID` a script ran. So the geometry is indexed by an ordinary
array here and pairs go straight to `akgl_collision_test()`, which needs no
world. At sixty-four rectangles that is the right answer anyway; libakgl's own
numbers put a naive sweep at 0.7% of a frame at sixty-four objects.

**The scan now short-circuits when nothing has moved**, and that is what makes
any of it affordable. Its inputs are the sprites' boxes, which slots are
collidable, and the static geometry; if none changed the answer cannot have. A
frame runs one full scan and 255 cached ones. Eight sprites against sixty-four
rectangles is five hundred and twelve tests -- fine once a frame, ruinous 256
times.

The benchmark was rewritten to say which path it is timing, because with the
cache in place a loop that only calls the scan measures the short circuit and
nothing else. Breakout now costs 590.6 ns for its one full scan plus 255 cached
at 40.0, which is 10.8 us against a 1.19 ms frame -- **0.91%, less than the 2.0%
it cost before any of this work**, with static geometry and contacts added on
top.

`NEW` retires the rectangles, where it cannot undefine a sprite pattern: there
*is* an entry point for this one, so leaving them would be a choice, and the
wrong one -- a rectangle is invisible, so one left behind by a deleted program is
an unexplainable collision in the next. `CLR` leaves them alone.

`tests/sprite_verbs.c` gains the whole second path against the mock and its
`COLLISION 2` case is rewritten: it pinned the refusal, and now pins that type 2
arms its own handler without disturbing type 1's. `tests/akgl_backends.c` gains
the end-to-end version, including a full sixty-four-rectangle wall so the proxy
budget is exercised at its ceiling and the pool has to come back intact, and the
sixty-fifth refused by name.

A bare `SOLID` needed `akbasic_parse_optional_arglist` rather than
`akbasic_parse_arglist`, which `DCLOSE` already uses for the same shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 10:25:35 -04:00

1016 lines
39 KiB
C

/**
* @file sprite_akgl.c
* @brief Wires the sprite backend record to libakgl's actors.
*
* Each of BASIC's eight sprites becomes a full libakgl object graph -- a
* spritesheet holding one texture, a sprite cutting one frame from it, a
* character mapping state 0 to that sprite, and an actor instantiating the
* character -- so a host game sees BASIC's sprites in libakgl's actor registry
* alongside its own and can do anything to them it can do to an actor.
*
* **None of that comes from JSON.** akgl_sprite_load_json() is a thin wrapper
* over the same four initializers plus writes to public struct fields, and every
* field it fills from a document -- frame list, animation speed, loop flags,
* state-to-sprite map -- is something a Commodore sprite does not have. The one
* exception is the image, and that is exactly where this file *does* call
* libakgl: `SPRSAV "ship.png", 1` resolves through akgl_path_relative() and
* loads through akgl_spritesheet_initialize(), the same two calls
* akgl_sprite_load_json_spritesheet() makes.
*
* Every actor gets a renderfunc of its own rather than akgl_actor_render(). It
* was two reasons, both filed upstream, and **libakgl 0.5.0 fixed one of them**:
* the default used to compute its destination height from the sprite's *width*,
* drawing a 24x21 Commodore sprite as a 24x24 square, and it now uses the
* height. That was libakgl defect 26.
*
* The remaining reason is the one that still requires this file: an akgl_Actor
* carries a single scalar `scale` applied to both axes, which cannot express
* SPRITE's separate x- and y-expand bits. libakgl records that as an open item
* and names this interpreter as the caller that needs it; the shape it takes --
* scale_x/scale_y beside scale, or replacing scale outright -- is a design
* decision over there rather than a patch. Until it lands, installing a function
* pointer is libakgl's own extension point for exactly this, so nothing is
* forked.
*/
#include <errno.h>
#include <string.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
#include <akgl/util.h>
#include <akbasic/akgl.h>
#include <akbasic/error.h>
/** @brief The colour conversion, same as src/graphics_akgl.c's. */
static SDL_Color to_sdl(akbasic_Color color)
{
SDL_Color out;
out.r = color.r;
out.g = color.g;
out.b = color.b;
out.a = color.a;
return out;
}
/** @brief Recover the backend's own state, or say that it has none. */
static akerr_ErrorContext *state_of(akbasic_SpriteBackend *self, akbasic_AkglSprites **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in akgl sprite backend");
*dest = (akbasic_AkglSprites *)self->self;
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER,
"akgl sprite backend has no state");
SUCCEED_RETURN(errctx);
}
/** @brief Recover the state and turn a BASIC sprite number into a slot index. */
static akerr_ErrorContext *slot_of(akbasic_SpriteBackend *self, int n, akbasic_AkglSprites **state, int *index)
{
PREPARE_ERROR(errctx);
PASS(errctx, state_of(self, state));
FAIL_ZERO_RETURN(errctx, (n >= 1 && n <= AKBASIC_MAX_SPRITES), AKBASIC_ERR_BOUNDS,
"Sprite %d is outside 1..%d", n, AKBASIC_MAX_SPRITES);
*index = n - 1;
SUCCEED_RETURN(errctx);
}
/**
* @brief Draw one BASIC sprite, in place of akgl_actor_render().
*
* Differs from the default in exactly one way now, noted at the top of this file:
* the two expansion bits scale the axes independently, where an actor's single
* `scale` cannot. Taking the destination height from the sprite's height used to
* be the second difference and is the default's behaviour as of libakgl 0.5.0 --
* the line below is no longer a correction, only agreement.
*
* Like the default, it skips rather than reports: an actor with no image, one
* that is hidden, one whose slot has been reused. A frame is not the place to
* fail.
*/
static akerr_ErrorContext *spr_render_actor(akgl_Actor *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
akgl_Sprite *sprite = NULL;
SDL_FRect src;
SDL_FRect dest;
int i = 0;
int found = -1;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL actor in sprite render");
if ( !obj->visible || obj->basechar == NULL ) {
SUCCEED_RETURN(errctx);
}
state = (akbasic_AkglSprites *)obj->actorData;
FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER,
"Sprite actor \"%s\" carries no backend state", obj->name);
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( state->actors[i] == obj ) {
found = i;
break;
}
}
if ( found < 0 ) {
SUCCEED_RETURN(errctx);
}
sprite = state->sprites[found];
if ( sprite == NULL || sprite->sheet == NULL || sprite->sheet->texture == NULL ) {
SUCCEED_RETURN(errctx);
}
PASS(errctx, akgl_spritesheet_coords_for_frame(sprite, &src, 0));
dest.x = obj->x - (akgl_camera != NULL ? akgl_camera->x : 0.0f);
dest.y = obj->y - (akgl_camera != NULL ? akgl_camera->y : 0.0f);
dest.w = (float32_t)sprite->width * (state->xexpand[found] ? 2.0f : 1.0f);
dest.h = (float32_t)sprite->height * (state->yexpand[found] ? 2.0f : 1.0f);
PASS(errctx, state->renderer->draw_texture(state->renderer, sprite->sheet->texture,
&src, &dest, 0, NULL, SDL_FLIP_NONE));
SUCCEED_RETURN(errctx);
}
/** @brief Tear down slot @p i, releasing its texture with it. */
static akerr_ErrorContext *release_slot(akbasic_AkglSprites *state, int i)
{
PREPARE_ERROR(errctx);
/*
* Actor first, then character, sprite and sheet. Each borrows the one below
* it without taking a reference, so releasing in the other order leaves a
* live object pointing at a zeroed pool slot.
*/
if ( state->actors[i] != NULL ) {
PASS(errctx, akgl_heap_release_actor(state->actors[i]));
state->actors[i] = NULL;
}
if ( state->characters[i] != NULL ) {
PASS(errctx, akgl_heap_release_character(state->characters[i]));
state->characters[i] = NULL;
}
if ( state->sprites[i] != NULL ) {
PASS(errctx, akgl_heap_release_sprite(state->sprites[i]));
state->sprites[i] = NULL;
}
if ( state->sheets[i] != NULL ) {
/* This is what destroys the texture: see akgl_heap_release_spritesheet. */
PASS(errctx, akgl_heap_release_spritesheet(state->sheets[i]));
state->sheets[i] = NULL;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Build slot @p i's object graph around a sheet that is already loaded.
*
* The names are fixed 128-byte buffers, not string literals, because
* akgl_sprite_initialize() and akgl_actor_initialize() memcpy a whole
* AKGL_*_MAX_NAME_LENGTH from whatever they are handed.
*/
static akerr_ErrorContext *build_slot(akbasic_AkglSprites *state, int i, int width, int height)
{
PREPARE_ERROR(errctx);
char spritename[AKGL_SPRITE_MAX_NAME_LENGTH];
char charname[AKGL_SPRITE_MAX_NAME_LENGTH];
char actorname[AKGL_ACTOR_MAX_NAME_LENGTH];
float32_t oldx = 0.0f;
float32_t oldy = 0.0f;
bool oldvisible = false;
if ( state->actors[i] != NULL ) {
oldx = state->actors[i]->x;
oldy = state->actors[i]->y;
oldvisible = state->actors[i]->visible;
}
memset(spritename, 0, sizeof(spritename));
memset(charname, 0, sizeof(charname));
memset(actorname, 0, sizeof(actorname));
SDL_snprintf(spritename, sizeof(spritename), "akbasic:sprite:%d", i + 1);
SDL_snprintf(charname, sizeof(charname), "akbasic:character:%d", i + 1);
SDL_snprintf(actorname, sizeof(actorname), "akbasic:actor:%d", i + 1);
PASS(errctx, akgl_heap_next_sprite(&state->sprites[i]));
PASS(errctx, akgl_sprite_initialize(state->sprites[i], spritename, state->sheets[i]));
state->sprites[i]->frames = 1;
state->sprites[i]->frameids[0] = 0;
state->sprites[i]->width = (uint32_t)width;
state->sprites[i]->height = (uint32_t)height;
PASS(errctx, akgl_heap_next_character(&state->characters[i]));
PASS(errctx, akgl_character_initialize(state->characters[i], charname));
PASS(errctx, akgl_character_sprite_add(state->characters[i], state->sprites[i], 0));
PASS(errctx, akgl_heap_next_actor(&state->actors[i]));
PASS(errctx, akgl_actor_initialize(state->actors[i], actorname));
PASS(errctx, akgl_actor_set_character(state->actors[i], charname));
state->actors[i]->state = 0;
state->actors[i]->actorData = state;
state->actors[i]->renderfunc = spr_render_actor;
/*
* Position and visibility survive a redefinition. SPRSAV changes what a
* sprite looks like, not where it is or whether it is on -- a program that
* animates by swapping patterns in a loop would otherwise have to re-issue
* SPRITE and MOVSPR after every frame.
*/
state->actors[i]->x = oldx;
state->actors[i]->y = oldy;
state->actors[i]->visible = oldvisible;
SUCCEED_RETURN(errctx);
}
/** @brief Take ownership of @p surface as slot @p i's image, replacing whatever was there. */
static akerr_ErrorContext *install_surface(akbasic_AkglSprites *state, int i, SDL_Surface *surface)
{
PREPARE_ERROR(errctx);
SDL_Texture *texture = NULL;
char sheetname[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
int width = 0;
int height = 0;
FAIL_ZERO_RETURN(errctx, (surface != NULL), AKERR_NULLPOINTER, "NULL surface for a sprite");
width = surface->w;
height = surface->h;
texture = SDL_CreateTextureFromSurface(state->renderer->sdl_renderer, surface);
FAIL_ZERO_RETURN(errctx, (texture != NULL), AKGL_ERR_SDL,
"Could not upload a sprite pattern: %s", SDL_GetError());
/*
* Nearest-neighbour. A sprite is a 24x21 bitmap and an expanded one is drawn
* at twice the size; smoothing it would turn a pixel into a smudge, which is
* not what anybody typing SPRSAV into a BASIC has in mind.
*/
SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST);
PASS(errctx, release_slot(state, i));
/*
* The sheet is assembled rather than initialized: akgl_spritesheet_initialize
* loads an image from a path, and this pattern came from bytes or from a
* saved region. Everything about it still matches what that function
* produces, so akgl_heap_release_spritesheet() tears it down the same way --
* which is what makes the texture's ownership one rule rather than two.
*/
PASS(errctx, akgl_heap_next_spritesheet(&state->sheets[i]));
memset(state->sheets[i], 0, sizeof(*state->sheets[i]));
memset(sheetname, 0, sizeof(sheetname));
SDL_snprintf(sheetname, sizeof(sheetname), "akbasic:sheet:%d", i + 1);
SDL_strlcpy(state->sheets[i]->name, sheetname, sizeof(state->sheets[i]->name));
state->sheets[i]->texture = texture;
state->sheets[i]->refcount = 1;
SDL_SetPointerProperty(AKGL_REGISTRY_SPRITESHEET, state->sheets[i]->name, state->sheets[i]);
PASS(errctx, build_slot(state, i, width, height));
SUCCEED_RETURN(errctx);
}
/** @brief Write one unpacked pattern into @p surface, a set bit in @p fg and a clear one in @p bg. */
static akerr_ErrorContext *paint_pattern(SDL_Surface *surface, const uint8_t *pixels, akbasic_Color fg, akbasic_Color bg)
{
PREPARE_ERROR(errctx);
int x = 0;
int y = 0;
for ( y = 0; y < AKBASIC_SPRITE_HEIGHT; y++ ) {
for ( x = 0; x < AKBASIC_SPRITE_WIDTH; x++ ) {
akbasic_Color c = (pixels[(y * AKBASIC_SPRITE_WIDTH) + x] != 0 ? fg : bg);
FAIL_ZERO_RETURN(errctx,
SDL_WriteSurfacePixel(surface, x, y, c.r, c.g, c.b, c.a),
AKGL_ERR_SDL,
"Could not write sprite pixel %d,%d: %s", x, y, SDL_GetError());
}
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_define(akbasic_SpriteBackend *self, int n, const uint8_t *pattern, int bytes, akbasic_Color fg, akbasic_Color bg)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
SDL_Surface *surface = NULL;
uint8_t pixels[AKBASIC_SPRITE_WIDTH * AKBASIC_SPRITE_HEIGHT];
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
PASS(errctx, akbasic_sprite_unpack(pattern, bytes, pixels));
surface = SDL_CreateSurface(AKBASIC_SPRITE_WIDTH, AKBASIC_SPRITE_HEIGHT,
SDL_PIXELFORMAT_RGBA32);
FAIL_ZERO_RETURN(errctx, (surface != NULL), AKGL_ERR_SDL,
"Could not create a sprite surface: %s", SDL_GetError());
ATTEMPT {
/*
* The pixel loop is its own function so that it can be reached with a
* single CATCH. Written inline it would need a FAIL inside two nested
* loops inside this ATTEMPT, and the _BREAK forms expand to a C break
* that escapes only the inner loop.
*/
CATCH(errctx, paint_pattern(surface, pixels, fg, bg));
CATCH(errctx, install_surface(state, index, surface));
} CLEANUP {
SDL_DestroySurface(surface);
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_define_shape(akbasic_SpriteBackend *self, int n, int handle)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
FAIL_ZERO_RETURN(errctx, (state->graphics != NULL), AKBASIC_ERR_DEVICE,
"SPRSAV from a saved shape needs a graphics backend, and this host supplied none");
FAIL_ZERO_RETURN(errctx, (handle >= 0 && handle < state->graphics->shapecount),
AKBASIC_ERR_VALUE,
"Shape handle %d names no saved region", handle);
FAIL_ZERO_RETURN(errctx, (state->graphics->shapes[handle] != NULL), AKBASIC_ERR_VALUE,
"Shape handle %d has been released", handle);
PASS(errctx, install_surface(state, index, state->graphics->shapes[handle]));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_define_file(akbasic_SpriteBackend *self, int n, const char *path, const char *root)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
akgl_String *resolved = NULL;
int index = 0;
bool missing = false;
PASS(errctx, slot_of(self, n, &state, &index));
FAIL_ZERO_RETURN(errctx, (path != NULL && path[0] != '\0'), AKBASIC_ERR_VALUE,
"SPRSAV was given an empty file name");
PASS(errctx, akgl_heap_next_string(&resolved));
ATTEMPT {
CATCH(errctx, akgl_string_initialize(resolved, NULL));
/*
* The same resolution a sprite document gets for the spritesheet it
* names: the working directory first, then the directory the file doing
* the naming lives in -- here, the directory the BASIC program was
* loaded from. A program stored beside its art therefore works whether
* it was launched from its own directory or from anywhere else.
*/
CATCH(errctx, akgl_path_relative((char *)(root != NULL ? root : "."),
(char *)path, resolved));
CATCH(errctx, release_slot(state, index));
CATCH(errctx, akgl_heap_next_spritesheet(&state->sheets[index]));
/*
* And the same load. The two frame-size arguments are dead upstream --
* akgl_spritesheet_initialize does not write them -- and a Commodore
* sprite has no frame grid anyway, so the image is one whole frame.
*/
CATCH(errctx, akgl_spritesheet_initialize(state->sheets[index], 0, 0,
(char *)&resolved->data));
FAIL_ZERO_BREAK(errctx, (state->sheets[index]->texture != NULL), AKGL_ERR_SDL,
"Loaded %s but it produced no texture", path);
SDL_SetTextureScaleMode(state->sheets[index]->texture, SDL_SCALEMODE_NEAREST);
/*
* The image's own size, not 24x21. A Commodore sprite is 24x21 because
* that is what the hardware could address; there is no reason to throw
* away art that is not sprite-shaped on a machine with no such limit.
*/
CATCH(errctx, build_slot(state, index,
state->sheets[index]->texture->w,
state->sheets[index]->texture->h));
} CLEANUP {
IGNORE(akgl_heap_release_string(resolved));
} PROCESS(errctx) {
} HANDLE(errctx, ENOENT) {
/*
* Recorded rather than raised here. Returning from inside a HANDLE block
* leaves FINISH's bookkeeping undone, so the flag is set and the new
* error is raised below where an ordinary return is safe.
*/
missing = true;
} FINISH(errctx, true);
FAIL_NONZERO_RETURN(errctx, missing, AKBASIC_ERR_VALUE, "No such sprite image: %s", path);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_show(akbasic_SpriteBackend *self, int n, bool visible)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
if ( state->actors[index] != NULL ) {
state->actors[index]->visible = visible;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_move(akbasic_SpriteBackend *self, int n, double x, double y)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
if ( state->actors[index] != NULL ) {
state->actors[index]->x = (float32_t)x;
state->actors[index]->y = (float32_t)y;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_configure(akbasic_SpriteBackend *self, int n, akbasic_Color color, bool xexpand, bool yexpand, bool behind)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
SDL_Color sdl = to_sdl(color);
int index = 0;
PASS(errctx, slot_of(self, n, &state, &index));
state->xexpand[index] = xexpand;
state->yexpand[index] = yexpand;
/*
* Colour is a texture modulation rather than a repaint: a sprite defined
* from a pattern was drawn in the colour SPRSAV was given, and SPRITE's
* colour argument tints it. For a sprite loaded from an image that means a
* white image takes the colour and a coloured one is shaded by it, which is
* what texture modulation does everywhere else.
*/
if ( state->sheets[index] != NULL && state->sheets[index]->texture != NULL ) {
SDL_SetTextureColorMod(state->sheets[index]->texture, sdl.r, sdl.g, sdl.b);
}
/*
* Priority is not honoured. A C128 draws a low-priority sprite behind the
* bitmap screen; here the text layer and the drawing surface are the same
* render target and sprites are composited on top of it every frame, so
* there is nothing to go behind. Recorded in TODO.md section 5.
*/
(void)behind;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_shared_colors(akbasic_SpriteBackend *self, akbasic_Color c1, akbasic_Color c2)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
PASS(errctx, state_of(self, &state));
/*
* Nothing to do yet, and saying so is better than pretending. Multicolour
* mode packs two bitmap bits per pixel and selects between the sprite's own
* colour and these two shared registers; SPRSAV here takes one bit per pixel,
* so there is no second bit to select with. The state is kept by the
* interpreter, RSPCOLOR reads it back, and the day a multicolour pattern
* format exists this is where it lands.
*/
(void)c1;
(void)c2;
SUCCEED_RETURN(errctx);
}
/**
* @brief Is slot @p i a thing that can be collided with at all?
*
* The three guards the pair loop used to repeat inline. A slot with no actor was
* never defined; a slot with no sprite has no frame and therefore no size; and an
* invisible sprite is out of the world entirely, which is the cheapest way a
* program has of switching one off without forgetting where it was.
*/
static bool slot_collidable(akbasic_AkglSprites *state, int i)
{
return (state->actors[i] != NULL
&& state->sprites[i] != NULL
&& state->actors[i]->visible
&& state->shapekind[i] != AKBASIC_SHAPE_NONE);
}
/**
* @brief Point slot @p i's proxy at where the sprite is and what size it is now.
*
* The shape is rebuilt every scan rather than cached against a dirty flag. It is
* derived from the sprite's frame and the two expansion bits, both of which a
* program may change at any statement, and building a box is a handful of
* arithmetic -- cheaper than being wrong about what invalidates it.
*
* **The mask override is the load-bearing line.** akgl_collision_shape_box()
* leaves a shape on the ACTOR layer responding to STATIC only, so that giving a
* town full of NPCs hitboxes does not make them shove each other around. That is
* the right default for a tile game and exactly wrong here: BASIC's collision is
* sprite-against-sprite first, and taking the library's default would make
* BUMP(1) report nothing at all, silently.
*/
/**
* @brief Record the shape SPRHIT asked for. The next scan builds it.
*
* Nothing is built here, and the reason is worth stating: a frame-fitting shape
* depends on the picture and the expansion bits, both of which may change after
* this call and before the next scan, so deriving it now would be deriving it
* from the wrong numbers. Zeroing the synced rectangle is what makes the next
* scan rebuild rather than recognise the position as unchanged and skip.
*/
static akerr_ErrorContext *spr_shape(akbasic_SpriteBackend *self, int n, int kind,
double x1, double y1, double x2, double y2)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int i = 0;
PASS(errctx, slot_of(self, n, &state, &i));
state->shapekind[i] = kind;
state->shapex1[i] = (float32_t)x1;
state->shapey1[i] = (float32_t)y1;
state->shapex2[i] = (float32_t)x2;
state->shapey2[i] = (float32_t)y2;
state->shapeexplicit[i] = (x2 > x1 && y2 > y1);
memset(&state->syncedbox[i], 0, sizeof(state->syncedbox[i]));
SUCCEED_RETURN(errctx);
}
/**
* @brief The rectangle slot @p i collides with, in device pixels.
*
* Either what SPRHIT was given, offset to where the sprite is, or the whole
* drawn frame -- which is what a sprite nobody has shaped has always collided
* with, and is the default this must keep reproducing exactly.
*/
static void collision_box(akbasic_AkglSprites *state, int i, SDL_FRect *dest)
{
dest->x = state->actors[i]->x;
dest->y = state->actors[i]->y;
if ( state->shapeexplicit[i] ) {
dest->x += state->shapex1[i];
dest->y += state->shapey1[i];
dest->w = state->shapex2[i] - state->shapex1[i];
dest->h = state->shapey2[i] - state->shapey1[i];
return;
}
dest->w = (float32_t)state->sprites[i]->width * (state->xexpand[i] ? 2.0f : 1.0f);
dest->h = (float32_t)state->sprites[i]->height * (state->yexpand[i] ? 2.0f : 1.0f);
}
static akerr_ErrorContext AKERR_NOIGNORE *sync_proxy(akbasic_AkglSprites *state, int i,
SDL_FRect *box)
{
PREPARE_ERROR(errctx);
SDL_FRect body;
/*
* **Nothing to do if nothing moved.** The scan runs at the top of every
* interpreter step -- up to 256 times a rendered frame -- and a sprite moves
* at most once in that time, so the overwhelming majority of syncs would
* rewrite a proxy with what it already holds. Four float comparisons to find
* that out are far cheaper than the shape build and the sync they skip.
*
* Compared against the last synced rectangle rather than tracked with a dirty
* flag a verb sets. A flag would be smaller and would be wrong: this file's
* own header says a host game sees BASIC's sprites in libakgl's actor
* registry "and can do anything to them it can do to an actor", which
* includes moving one behind the interpreter's back. Comparing the answer
* cannot miss that; flagging the question can.
*/
if ( state->syncedbox[i].x == box->x && state->syncedbox[i].y == box->y
&& state->syncedbox[i].w == box->w && state->syncedbox[i].h == box->h ) {
SUCCEED_RETURN(errctx);
}
body.x = 0.0f;
body.y = 0.0f;
body.w = box->w;
body.h = box->h;
/*
* A zero-sized frame would be refused by the shape builder, and a sprite can
* legitimately have one before its sheet is loaded. Treat it as not
* collidable rather than raising: the program has not done anything wrong.
*/
if ( body.w <= 0.0f || body.h <= 0.0f ) {
SUCCEED_RETURN(errctx);
}
switch ( state->shapekind[i] ) {
case AKBASIC_SHAPE_CIRCLE:
/*
* Inscribed, and in the *smaller* of the two half-extents. A circle that
* fitted the larger one would poke out of the rectangle the program
* asked for, which is the opposite of what "give this sprite a circle"
* means to somebody looking at a disc drawn inside a square frame.
*/
PASS(errctx, akgl_collision_shape_circle(&state->shapes[i],
body.w / 2.0f, body.h / 2.0f,
(body.w < body.h ? body.w : body.h) / 2.0f,
0.0f));
break;
case AKBASIC_SHAPE_CAPSULE_X:
case AKBASIC_SHAPE_CAPSULE_Y:
PASS(errctx, akgl_collision_shape_capsule(&state->shapes[i], &body,
(uint8_t)state->shapekind[i], 0.0f));
break;
default:
PASS(errctx, akgl_collision_shape_box(&state->shapes[i], &body, 0.0f));
break;
}
state->shapes[i].collidemask = AKGL_COLLISION_LAYER_ACTOR | AKGL_COLLISION_LAYER_STATIC;
PASS(errctx, akgl_collision_proxy_sync(state->proxies[i], &state->shapes[i],
box->x, box->y, 0.0f));
state->syncedbox[i] = *box;
SUCCEED_RETURN(errctx);
}
/**
* @brief Run the scan if anything has changed, and leave both masks on the state.
*
* One worker behind two entry points, because both masks come out of the same
* pass and computing them separately would be computing the sprite boxes twice
* for one answer. `spr_collisions()` reports the sprite-against-sprite mask and
* `spr_solids()` the sprite-against-static one; either may be called first, and
* the cache below is what makes that true rather than merely usually true.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_scan(akbasic_AkglSprites *state)
{
PREPARE_ERROR(errctx);
akgl_Contact contact;
SDL_FRect box[AKBASIC_MAX_SPRITES];
bool live[AKBASIC_MAX_SPRITES];
bool synced[AKBASIC_MAX_SPRITES];
bool hit = false;
int i = 0;
int j = 0;
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
live[i] = slot_collidable(state, i);
synced[i] = false;
memset(&box[i], 0, sizeof(box[i]));
if ( !live[i] ) {
continue;
}
collision_box(state, i, &box[i]);
}
/*
* **Nothing moved, so nothing can have changed.**
*
* The scan's inputs are exactly these boxes and which slots are collidable;
* static geometry invalidates the cache where it is registered. So if all
* eight match what the last scan saw, the answer is the answer it gave, and
* eight rectangle comparisons are far cheaper than recomputing it.
*
* This is what makes static geometry affordable. The scan runs at the top of
* every interpreter step -- up to 256 times a rendered frame -- and eight
* sprites against sixty-four rectangles is five hundred and twelve tests.
* That is fine once a frame and ruinous 256 times, and a sprite moves at
* most once in that window.
*
* It changes nothing a program can observe: the same mask comes back, so
* BUMP accumulates the same bits and the interrupt is raised on the same
* steps.
*/
if ( state->lastvalid ) {
bool same = true;
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( live[i] != state->lastlive[i]
|| box[i].x != state->lastbox[i].x || box[i].y != state->lastbox[i].y
|| box[i].w != state->lastbox[i].w || box[i].h != state->lastbox[i].h ) {
same = false;
break;
}
}
if ( same ) {
SUCCEED_RETURN(errctx);
}
}
state->lastmask = 0;
state->lastsolidmask = 0;
/*
* **Two phases, and the split is the whole performance story.**
*
* The obvious implementation -- sync all eight proxies, then run
* akgl_collision_test() on all twenty-eight pairs -- was measured at 984 ns a
* scan against 96 ns for the four-comparison loop it replaced. At 256 scans a
* frame that is 21% of a frame, and it bought nothing: the mask came back
* bit-identical and the contact was thrown away.
*
* So: reject on the bounding boxes first, in the four comparisons that were
* always here, and pay for the narrowphase only on a pair that survives.
* Nearly every scan of a real game rejects all twenty-eight, and a pair that
* does overlap is worth an exact answer. This is not a workaround for a slow
* library -- it is what a broad phase *is*, and akgl_collision_test() does the
* same thing internally with the proxies' own bounds before it commits to a
* solver.
*
* **The narrowphase still decides.** The box test only says "maybe"; the bit
* is set by what the library answers. That matters for a shape that is not a
* box -- a circle inscribed in a frame overlaps a smaller region than the
* frame does -- so the prefilter can over-report and must never be the final
* word. Today every shape is the whole frame and the two always agree, which
* is exactly why this is the moment to get the ordering right rather than
* the moment it starts to matter.
*
* AKGL_COLLISION_TEST_PLANAR because a Commodore sprite has no z. Without it
* the extrusion the shape builder chose takes part in the test and the
* minimum axis can come back as a depth along z, which no BASIC program can
* act on.
*
* Still box against box, not pixel against pixel: a C128's VIC-II collides on
* set pixels, so two sprites whose boxes overlap but whose art does not are
* reported as colliding here and would not be there. TODO.md section 5.
*/
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( !live[i] ) {
continue;
}
for ( j = i + 1; j < AKBASIC_MAX_SPRITES; j++ ) {
if ( !live[j] ) {
continue;
}
if ( !(box[i].x < box[j].x + box[j].w && box[j].x < box[i].x + box[i].w
&& box[i].y < box[j].y + box[j].h && box[j].y < box[i].y + box[i].h) ) {
continue;
}
/*
* PASS rather than CATCH throughout: this is a loop, and CATCH
* expands to a break, which would leave the remaining pairs untested
* and the mask half built.
*/
if ( !synced[i] ) {
PASS(errctx, sync_proxy(state, i, &box[i]));
synced[i] = true;
}
if ( !synced[j] ) {
PASS(errctx, sync_proxy(state, j, &box[j]));
synced[j] = true;
}
hit = false;
PASS(errctx, akgl_collision_test(state->proxies[i], state->proxies[j],
AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
if ( hit ) {
state->lastmask |= (uint16_t)(1u << i);
state->lastmask |= (uint16_t)(1u << j);
}
}
}
/*
* Every live sprite against every registered rectangle, same shape: reject
* on the boxes, and only then ask the library. A wall is motionless, so its
* proxy was synced when SOLID registered it and there is nothing to refresh
* here.
*/
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( !live[i] ) {
continue;
}
for ( j = 0; j < AKBASIC_MAX_SOLIDS; j++ ) {
if ( !state->solidactive[j] ) {
continue;
}
if ( !(box[i].x < state->solidbox[j].x + state->solidbox[j].w
&& state->solidbox[j].x < box[i].x + box[i].w
&& box[i].y < state->solidbox[j].y + state->solidbox[j].h
&& state->solidbox[j].y < box[i].y + box[i].h) ) {
continue;
}
if ( !synced[i] ) {
PASS(errctx, sync_proxy(state, i, &box[i]));
synced[i] = true;
}
hit = false;
PASS(errctx, akgl_collision_test(state->proxies[i], state->solidproxies[j],
AKGL_COLLISION_TEST_PLANAR, &contact, &hit));
if ( hit ) {
state->lastsolidmask |= (uint16_t)(1u << i);
}
}
}
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
state->lastbox[i] = box[i];
state->lastlive[i] = live[i];
}
state->lastvalid = true;
SUCCEED_RETURN(errctx);
}
/**
* @brief Register, replace or retire static rectangle @p id.
*
* A proxy with a **NULL owner**: there is no actor behind a wall, and libakgl's
* proxy carries the owner only so a resolver can push something. Nothing here
* resolves anything, so the field stays empty and the shape is what matters.
*
* Layers are the other half. A wall sits on #AKGL_COLLISION_LAYER_STATIC and
* responds to nothing, which is the asymmetry libakgl's masks exist for: the
* sprite's own `collidemask` includes STATIC, so a sprite finds a wall and two
* walls never test against each other. Sixty-four motionless rectangles
* therefore cost nothing per scan beyond the boxes a sprite is compared to.
*/
static akerr_ErrorContext *spr_solid(akbasic_SpriteBackend *self, int id, bool define,
double x1, double y1, double x2, double y2)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
SDL_FRect body;
int i = 0;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (id >= 1 && id <= AKBASIC_MAX_SOLIDS), AKBASIC_ERR_BOUNDS,
"Static shape %d is outside 1..%d", id, AKBASIC_MAX_SOLIDS);
i = id - 1;
/*
* Anything that changes the geometry invalidates the cached answer. Cheapest
* possible correctness: the scan's own change detection watches the sprites,
* and this is the one input it cannot see moving.
*/
state->lastvalid = false;
if ( !define ) {
if ( state->solidproxies[i] != NULL ) {
PASS(errctx, akgl_heap_release_collision_proxy(state->solidproxies[i]));
state->solidproxies[i] = NULL;
}
state->solidactive[i] = false;
SUCCEED_RETURN(errctx);
}
body.x = 0.0f;
body.y = 0.0f;
body.w = (float32_t)(x2 - x1);
body.h = (float32_t)(y2 - y1);
PASS(errctx, akgl_collision_shape_box(&state->solidshapes[i], &body, 0.0f));
state->solidshapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;
state->solidshapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
state->solidshapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
if ( state->solidproxies[i] == NULL ) {
/* Adjacent, with nothing between: the slot is free until initialize takes it. */
PASS(errctx, akgl_heap_next_collision_proxy(&state->solidproxies[i]));
PASS(errctx, akgl_collision_proxy_initialize(state->solidproxies[i], NULL,
&state->solidshapes[i],
(float32_t)x1, (float32_t)y1, 0.0f));
} else {
PASS(errctx, akgl_collision_proxy_sync(state->solidproxies[i], &state->solidshapes[i],
(float32_t)x1, (float32_t)y1, 0.0f));
}
state->solidbox[i].x = (float32_t)x1;
state->solidbox[i].y = (float32_t)y1;
state->solidbox[i].w = body.w;
state->solidbox[i].h = body.h;
state->solidactive[i] = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_collisions(akbasic_SpriteBackend *self, uint16_t *mask)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (mask != NULL), AKERR_NULLPOINTER, "NULL mask in collisions");
PASS(errctx, run_scan(state));
*mask = state->lastmask;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *spr_solids(akbasic_SpriteBackend *self, uint16_t *mask)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (mask != NULL), AKERR_NULLPOINTER, "NULL mask in solids");
PASS(errctx, run_scan(state));
*mask = state->lastsolidmask;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sprite_init_akgl(akbasic_SpriteBackend *obj, akbasic_AkglSprites *state, akgl_RenderBackend *renderer, akbasic_AkglGraphics *graphics)
{
PREPARE_ERROR(errctx);
int i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL && renderer != NULL),
AKERR_NULLPOINTER, "NULL argument in sprite_init_akgl");
/* Before anything else in libakgl, so every AKGL_ERR_* has a name to print. */
PASS(errctx, akgl_error_init());
memset(state, 0, sizeof(*state));
state->renderer = renderer;
state->graphics = graphics;
/*
* The eight collision proxies, claimed here and held for the life of the
* backend.
*
* Up front rather than on demand because the pool is shared: a host game
* embedding this interpreter draws its own shaped actors from the same
* AKGL_MAX_HEAP_COLLISION_PROXY. Claiming late would mean a collision scan
* discovering mid-game that a host had taken the last slot, and reporting it
* against whichever BASIC line was unlucky. Claiming here makes it an
* initialization failure that names the pool, before a program has run.
*
* A default box shape so a proxy is never initialized from an uninitialized
* shape -- libakgl fixed exactly that defect in 3a6569e, where filing a proxy
* under half-extents that had not been written yet was invisible to its
* tests and visible only to a memory checker. Real geometry arrives on the
* first sync_proxy(); this is only ever a placeholder.
*/
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
SDL_FRect placeholder = { 0.0f, 0.0f, 1.0f, 1.0f };
/*
* A box fitting the frame, which is what every sprite collided with
* before SPRHIT existed and is what one nobody has shaped must go on
* collided with.
*/
state->shapekind[i] = AKBASIC_SHAPE_BOX;
PASS(errctx, akgl_collision_shape_box(&state->shapes[i], &placeholder, 0.0f));
/*
* Acquire and initialize adjacent, with nothing between them: the slot
* is free until initialize takes the reference, and libakgl's heap says
* so where next_collision_proxy is declared.
*/
PASS(errctx, akgl_heap_next_collision_proxy(&state->proxies[i]));
PASS(errctx, akgl_collision_proxy_initialize(state->proxies[i], NULL,
&state->shapes[i], 0.0f, 0.0f, 0.0f));
}
memset(obj, 0, sizeof(*obj));
obj->self = state;
obj->define = spr_define;
obj->define_shape = spr_define_shape;
obj->define_file = spr_define_file;
obj->show = spr_show;
obj->move = spr_move;
obj->configure = spr_configure;
obj->shared_colors = spr_shared_colors;
obj->collisions = spr_collisions;
obj->shape = spr_shape;
obj->solid = spr_solid;
obj->solids = spr_solids;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sprite_akgl_render(akbasic_SpriteBackend *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglSprites *state = NULL;
int i = 0;
PASS(errctx, state_of(obj, &state));
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
if ( state->actors[i] == NULL ) {
continue;
}
/*
* PASS rather than CATCH: this is a loop, and CATCH expands to a break.
* Through the actor's own renderfunc rather than calling spr_render_actor
* directly, so a host that has replaced it gets what it asked for.
*/
PASS(errctx, state->actors[i]->renderfunc(state->actors[i]));
}
SUCCEED_RETURN(errctx);
}
void akbasic_sprite_akgl_shutdown(akbasic_SpriteBackend *obj)
{
akbasic_AkglSprites *state = NULL;
int i = 0;
if ( obj == NULL || obj->self == NULL ) {
return;
}
state = (akbasic_AkglSprites *)obj->self;
for ( i = 0; i < AKBASIC_MAX_SPRITES; i++ ) {
IGNORE(release_slot(state, i));
/*
* The proxies go back too. They are not registered with any partitioner,
* so there is nothing to unlink first -- which is the one ordering trap
* here, and the reason worth writing down: releasing a proxy that *is*
* registered leaves stale cell entries holding a pool index, and the
* damage only shows when the slot is handed out again.
*/
if ( state->proxies[i] != NULL ) {
IGNORE(akgl_heap_release_collision_proxy(state->proxies[i]));
state->proxies[i] = NULL;
}
}
for ( i = 0; i < AKBASIC_MAX_SOLIDS; i++ ) {
if ( state->solidproxies[i] != NULL ) {
IGNORE(akgl_heap_release_collision_proxy(state->solidproxies[i]));
state->solidproxies[i] = NULL;
}
}
}