joeyagi/agiGfx.c
2026-10-06 16:59:41 -05:00

696 lines
21 KiB
C

// The picture buffers and the screen objects drawn into them.
//
// Like Sierra's interpreter, JoeyAGI keeps one 160x168 picture buffer pair
// -- a colour and a priority byte per AGI pixel -- that holds the picture
// with the visible objects blitted into it. An object's blit saves the
// bytes under its cel, then draws the cel's pixels whose priority is at
// least the buffer's, writing the object's priority with the colour, so a
// lower object drawn later stays hidden behind it. Erasing puts the saved
// bytes back, so blits stack: the buffer is always erased in the reverse
// order it was drawn. Objects that are not updated (stop.update) are drawn
// first, sorted by depth, and the updated ones on top of them.
//
// Changed areas are copied to the stage, each AGI pixel two screen
// pixels wide, below the status line (configure.screen's picture row).
// display() text written over the picture lives only on the stage, so an
// object moving across it wipes it, as in the original.
#include "agi.h"
#include <stddef.h>
#include <string.h>
#include "joey/core.h"
#include "joey/draw.h"
#include "joey/input.h"
#include "joey/present.h"
#include "joey/sprite.h"
#include "joey/tile.h"
#include "surfaceInternal.h"
// Room for the save-unders of every drawn object: two bytes per pixel.
#define SAVE_ARENA_BYTES 65535u
// Areas waiting to be copied to the stage; more are merged into one.
#define DIRTY_MAX 24u
#define NIBBLE_SHIFT 4u
// A cel run byte: the colour in the high nibble, the length in the low.
#define RLE_COUNT_MASK 0x0Fu
// add.to.pic's margin box: margins 0..3 are control values; 4 and above
// mean no box.
#define MARGIN_NONE 4u
// shake.screen, measured on Sierra's interpreter: each beat shows the
// whole screen moved 8 pixels right and 4 down (the uncovered edges
// black) for 4 frames, then in place for 4; a shake is 4 beats per unit
// counted in 16 bits, so shake.screen(0) is 65536 beats.
#define SHAKE_DX 8
#define SHAKE_DY 4
#define SHAKE_PHASE_FRAMES 4u
#define SHAKE_BEATS_PER_UNIT 4u
#define MS_PER_SECOND 1000u
// A chunky surface's byte holds two pixels.
#define CHUNKY_PX_PER_BYTE (SURFACE_WIDTH / SURFACE_BYTES_PER_ROW)
typedef struct {
int16_t x;
int16_t y;
int16_t w;
int16_t h;
} GfxRectT;
// ----- Prototypes -----
static void blitObject(uint8_t n);
static void buildShifted(jlSurfaceT *shifted, const jlSurfaceT *normal);
static void drawCel(const AgiCelT *cel, bool mirror, int16_t left, int16_t top, uint8_t priority, bool keepPriority);
static void drawList(bool updating);
static uint8_t effectivePriority(int16_t x, int16_t y);
static void markDirty(int16_t x, int16_t y, int16_t w, int16_t h);
static void pushRect(const GfxRectT *r);
static void pushRuns(jlSurfaceT *stage, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t screenY);
static void restoreRect(const uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h);
static void saveRect(uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h);
static void shakePhase(const jlSurfaceT *show, uint32_t phaseMs);
static int16_t sortKey(const AgiObjectT *o);
static void unblitTop(void);
// ----- Module state -----
static uint8_t *gVis;
static uint8_t *gPri;
static uint8_t *gSaveArena;
static uint16_t gSaveTop;
// Objects in the order they were blitted; the first gStaticBlits of them
// are the not-updated batch.
static uint8_t gBlitStack[AGI_MAX_OBJECTS];
static uint8_t gBlitCount;
static uint8_t gStaticBlits;
static GfxRectT gDirty[DIRTY_MAX];
static uint8_t gDirtyCount;
static bool gPicShown;
// ----- Internal helpers (alphabetical) -----
static void blitObject(uint8_t n) {
AgiObjectT *o;
const AgiCelT *cel;
bool mirror;
int16_t left;
int16_t top;
uint16_t need;
o = &gAgi->objects[n];
cel = agiViewCel(o->view, o->loop, o->cel, &mirror);
if (cel == NULL || cel->width == 0u || cel->height == 0u) {
return;
}
left = o->x;
top = (int16_t)(o->y - (int16_t)cel->height + 1);
// Clip the save rectangle to the buffer.
o->blitX = left;
o->blitY = (top < 0) ? 0 : top;
o->blitW = cel->width;
if (left + (int16_t)cel->width > AGI_WIDTH) {
o->blitW = (uint8_t)(AGI_WIDTH - left);
}
o->blitH = (uint8_t)(o->y - o->blitY + 1);
if (left < 0 || left >= AGI_WIDTH || o->y < 0 || o->y > AGI_MAX_Y || o->blitW == 0u) {
return;
}
need = (uint16_t)((uint16_t)o->blitW * o->blitH * 2u);
if ((uint32_t)gSaveTop + need > SAVE_ARENA_BYTES) {
return;
}
o->saveUnder = &gSaveArena[gSaveTop];
saveRect(o->saveUnder, left, o->blitY, o->blitW, o->blitH);
gSaveTop = (uint16_t)(gSaveTop + need);
o->blitted = true;
gBlitStack[gBlitCount] = n;
gBlitCount++;
if ((o->flags & OBJ_FIX_PRIORITY) == 0u) {
o->priority = agiPriorityForY(o->y);
}
drawCel(cel, mirror, left, top, o->priority, false);
markDirty(o->blitX, o->blitY, o->blitW, o->blitH);
}
// The screen as a shake beat shows it: moved SHAKE_DX right and SHAKE_DY
// down over black. Chunky surfaces move their bytes; planar ones draw the
// screen as one big sprite (colour 0 is transparent, and the surface is
// black beneath).
static void buildShifted(jlSurfaceT *shifted, const jlSurfaceT *normal) {
jlSpriteT *sprite;
uint16_t y;
jlSurfaceCopy(shifted, normal);
jlSurfaceClear(shifted, AGI_COLOR_BLACK);
if (shifted->pixels != NULL && normal->pixels != NULL) {
for (y = SHAKE_DY; y < SURFACE_HEIGHT; y++) {
memcpy(&shifted->pixels[y * SURFACE_BYTES_PER_ROW + SHAKE_DX / CHUNKY_PX_PER_BYTE],
&normal->pixels[(uint16_t)(y - SHAKE_DY) * SURFACE_BYTES_PER_ROW],
SURFACE_BYTES_PER_ROW - SHAKE_DX / CHUNKY_PX_PER_BYTE);
}
return;
}
sprite = jlSpriteCreateFromSurface(normal, 0, 0, TILE_BLOCKS_PER_ROW, TILE_BLOCKS_PER_COL);
if (sprite != NULL) {
jlSpriteDraw(shifted, sprite, SHAKE_DX, SHAKE_DY);
jlSpriteDestroy(sprite);
}
}
// Draw a cel's opaque pixels into the buffers where its priority is at
// least the buffer's (control lines take the priority below them).
// keepPriority leaves the priority buffer alone (add.to.pic writes it).
static void drawCel(const AgiCelT *cel, bool mirror, int16_t left, int16_t top, uint8_t priority, bool keepPriority) {
const uint8_t *rle;
uint8_t row;
int16_t col;
int16_t y;
int16_t x;
uint8_t color;
uint8_t run;
uint16_t idx;
rle = cel->rle;
for (row = 0u; row < cel->height; row++) {
y = (int16_t)(top + row);
col = 0;
while (*rle != 0u) {
color = (uint8_t)(*rle >> NIBBLE_SHIFT);
run = (uint8_t)(*rle & RLE_COUNT_MASK);
rle++;
if (color == cel->transparent || y < 0 || y > AGI_MAX_Y) {
col = (int16_t)(col + run);
continue;
}
while (run > 0u) {
x = mirror ? (int16_t)(left + (int16_t)cel->width - 1 - col) : (int16_t)(left + col);
if (x >= 0 && x < AGI_WIDTH && col < (int16_t)cel->width) {
idx = (uint16_t)((uint16_t)y * AGI_WIDTH + (uint16_t)x);
if (priority >= effectivePriority(x, y)) {
gVis[idx] = color;
if (!keepPriority) {
gPri[idx] = priority;
}
}
}
col++;
run--;
}
}
rle++;
}
}
// Blit one batch, sorted by depth: the not-updated objects or the updated
// ones.
static void drawList(bool updating) {
uint8_t order[AGI_MAX_OBJECTS];
uint8_t count;
uint8_t i;
uint8_t j;
uint16_t want;
want = updating ? (uint16_t)(OBJ_ANIMATED | OBJ_DRAWN | OBJ_UPDATE) : (uint16_t)(OBJ_ANIMATED | OBJ_DRAWN);
count = 0u;
for (i = 0u; i < gAgi->objectCount; i++) {
const AgiObjectT *o = &gAgi->objects[i];
if ((o->flags & (OBJ_ANIMATED | OBJ_DRAWN | OBJ_UPDATE)) != want || o->blitted || o->view == NULL) {
continue;
}
// Insertion sort: by depth, then by object number.
j = count;
while (j > 0u && sortKey(&gAgi->objects[order[j - 1u]]) > sortKey(o)) {
order[j] = order[j - 1u];
j--;
}
order[j] = i;
count++;
}
for (i = 0u; i < count; i++) {
blitObject(order[i]);
}
}
// The priority a pixel has for drawing: control lines (0..3) take the
// priority of the first non-control pixel below them.
static uint8_t effectivePriority(int16_t x, int16_t y) {
uint8_t p;
for (;;) {
p = gPri[(uint16_t)y * AGI_WIDTH + (uint16_t)x];
if (p > AGI_CTL_WATER) {
return p;
}
y++;
if (y > AGI_MAX_Y) {
return AGI_PRIORITY_MAX;
}
}
}
static void markDirty(int16_t x, int16_t y, int16_t w, int16_t h) {
GfxRectT *r;
int16_t right;
int16_t bottom;
if (w <= 0 || h <= 0) {
return;
}
if (gDirtyCount < DIRTY_MAX) {
r = &gDirty[gDirtyCount];
gDirtyCount++;
r->x = x;
r->y = y;
r->w = w;
r->h = h;
return;
}
// Out of slots: grow the last one to cover this one too.
r = &gDirty[DIRTY_MAX - 1u];
right = (int16_t)(r->x + r->w);
bottom = (int16_t)(r->y + r->h);
if (x + w > right) {
right = (int16_t)(x + w);
}
if (y + h > bottom) {
bottom = (int16_t)(y + h);
}
if (x < r->x) {
r->x = x;
}
if (y < r->y) {
r->y = y;
}
r->w = (int16_t)(right - r->x);
r->h = (int16_t)(bottom - r->y);
}
// Copy a buffer rectangle to the stage, each AGI pixel two screen pixels:
// straight into a chunky stage's bytes, or as runs on a planar one.
static void pushRect(const GfxRectT *r) {
jlSurfaceT *stage;
int16_t x0;
int16_t y0;
int16_t x1;
int16_t y1;
int16_t x;
int16_t y;
int16_t screenY;
uint8_t c;
x0 = (r->x < 0) ? 0 : r->x;
y0 = (r->y < 0) ? 0 : r->y;
x1 = (int16_t)(r->x + r->w);
y1 = (int16_t)(r->y + r->h);
if (x1 > AGI_WIDTH) {
x1 = AGI_WIDTH;
}
if (y1 > AGI_HEIGHT) {
y1 = AGI_HEIGHT;
}
if (x0 >= x1 || y0 >= y1) {
return;
}
stage = jlStageGet();
screenY = (int16_t)((int16_t)gAgi->picRow * AGI_CELL_PX);
if (stage->pixels == NULL) {
pushRuns(stage, x0, y0, x1, y1, screenY);
return;
}
for (y = y0; y < y1; y++) {
const uint8_t *src = &gVis[(uint16_t)y * AGI_WIDTH];
uint8_t *dst = &stage->pixels[(uint16_t)(screenY + y) * SURFACE_BYTES_PER_ROW];
for (x = x0; x < x1; x++) {
c = src[x];
dst[x] = (uint8_t)((c << NIBBLE_SHIFT) | c);
}
}
surfaceMarkDirtyRect(stage, (int16_t)(x0 * AGI_SCREEN_PX_PER_AGI), (int16_t)(screenY + y0),
(int16_t)((x1 - x0) * AGI_SCREEN_PX_PER_AGI), (int16_t)(y1 - y0));
}
// Planar stages (Amiga, ST, X68000) have no bytes to write: each row goes
// out as runs of one colour through jlFillRect, which the ports do fast.
static void pushRuns(jlSurfaceT *stage, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t screenY) {
const uint8_t *src;
int16_t x;
int16_t y;
int16_t run;
uint8_t c;
for (y = y0; y < y1; y++) {
src = &gVis[(uint16_t)y * AGI_WIDTH];
x = x0;
while (x < x1) {
c = src[x];
run = 1;
while (x + run < x1 && src[x + run] == c) {
run++;
}
jlFillRect(stage, (int16_t)(x * AGI_SCREEN_PX_PER_AGI), (int16_t)(screenY + y), (uint16_t)(run * AGI_SCREEN_PX_PER_AGI), 1u, c);
x = (int16_t)(x + run);
}
}
}
// Put back a rectangle of both buffers that saveRect kept.
static void restoreRect(const uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h) {
uint8_t row;
uint16_t dst;
for (row = 0u; row < h; row++) {
dst = (uint16_t)((uint16_t)(y + row) * AGI_WIDTH + (uint16_t)x);
memcpy(&gVis[dst], save, w);
save += w;
memcpy(&gPri[dst], save, w);
save += w;
}
}
// Keep a rectangle of both buffers, a row of colours then a row of
// priorities at a time (2 * w * h bytes).
static void saveRect(uint8_t *save, int16_t x, int16_t y, uint8_t w, uint8_t h) {
uint8_t row;
uint16_t src;
for (row = 0u; row < h; row++) {
src = (uint16_t)((uint16_t)(y + row) * AGI_WIDTH + (uint16_t)x);
memcpy(save, &gVis[src], w);
save += w;
memcpy(save, &gPri[src], w);
save += w;
}
}
// One phase of a shake beat: `show` on the screen for phaseMs (NULL, for
// want of memory, is only the wait).
static void shakePhase(const jlSurfaceT *show, uint32_t phaseMs) {
uint32_t start;
if (show != NULL) {
jlSurfaceCopy(jlStageGet(), show);
}
start = jlMillisElapsed();
do {
agiIdle();
} while (jlMillisElapsed() - start < phaseMs);
}
// Objects sort by their baseline, or by the top of their band when their
// priority is fixed.
static int16_t sortKey(const AgiObjectT *o) {
if ((o->flags & OBJ_FIX_PRIORITY) != 0u) {
return agiYForPriority(o->priority);
}
return o->y;
}
static void unblitTop(void) {
AgiObjectT *o;
gBlitCount--;
o = &gAgi->objects[gBlitStack[gBlitCount]];
restoreRect(o->saveUnder, o->blitX, o->blitY, o->blitW, o->blitH);
gSaveTop = (uint16_t)(o->saveUnder - gSaveArena);
o->blitted = false;
markDirty(o->blitX, o->blitY, o->blitW, o->blitH);
}
// ----- Public API (alphabetical) -----
// add.to.pic: draw a cel into the picture itself with a fixed priority
// (0 means the priority of its baseline) and, for margins 0..3, a box of
// that control value along its base.
void gfxAddToPic(uint8_t viewId, uint8_t loop, uint8_t cel, int16_t x, int16_t y, uint8_t priority, uint8_t margin) {
const AgiViewT *view;
const AgiCelT *c;
bool mirror;
int16_t top;
int16_t boxTop;
int16_t i;
view = agiViewGet(viewId);
c = agiViewCel(view, loop, cel, &mirror);
if (c == NULL) {
return;
}
if (priority < AGI_PRIORITY_MIN) {
priority = agiPriorityForY(y);
}
gfxEraseAll();
top = (int16_t)(y - (int16_t)c->height + 1);
drawCel(c, mirror, x, top, priority, false);
// The margin box (measured): an outline of the control value, as wide
// as the cel, from its baseline up to the top of the baseline's band.
if (margin < MARGIN_NONE) {
boxTop = agiYForPriority(agiPriorityForY(y));
for (i = 0; i < (int16_t)c->width; i++) {
int16_t yy;
if (x + i < 0 || x + i > AGI_MAX_X) {
continue;
}
for (yy = boxTop; yy <= y; yy++) {
if (yy == y || yy == boxTop || i == 0 || i == (int16_t)c->width - 1) {
gPri[(uint16_t)yy * AGI_WIDTH + (uint16_t)(x + i)] = margin;
}
}
}
}
markDirty(x, top, c->width, c->height);
gfxRedrawAll();
}
// The priority or control value at an AGI pixel, for collision tests.
uint8_t gfxControlAt(int16_t x, int16_t y) {
if (x < 0 || x > AGI_MAX_X || y < 0 || y > AGI_MAX_Y) {
return AGI_PRIORITY_MAX;
}
return gPri[(uint16_t)y * AGI_WIDTH + (uint16_t)x];
}
// draw.pic (clear first) and overlay.pic: draw a picture into the buffers
// under the objects. The screen keeps the old picture until show.pic.
void gfxDrawPic(uint8_t picId, bool clearFirst) {
uint8_t *data;
uint16_t length;
data = agiResLoad(&gAgi->game, AGI_RES_PIC, picId, &length);
if (data == NULL) {
return;
}
gfxEraseAll();
if (clearFirst) {
memset(gVis, AGI_PIC_BG_COLOR, AGI_PIXELS);
memset(gPri, AGI_PIC_BG_PRIORITY, AGI_PIXELS);
}
(void)agiPicDecode(gVis, gPri, data, length);
jlFree(data);
gDirtyCount = 0u;
gPicShown = false;
drawList(false);
gStaticBlits = gBlitCount;
drawList(true);
gDirtyCount = 0u;
}
// Draw the objects that are drawn but not yet blitted (after a change),
// keeping the not-updated ones under the updated ones.
void gfxDrawStatic(void) {
drawList(false);
gStaticBlits = gBlitCount;
drawList(true);
}
void gfxEraseAll(void) {
while (gBlitCount > 0u) {
unblitTop();
}
gStaticBlits = 0u;
}
void gfxEraseUpdating(void) {
while (gBlitCount > gStaticBlits) {
unblitTop();
}
}
void gfxFree(void) {
jlFree(gVis);
jlFree(gPri);
jlFree(gSaveArena);
gVis = NULL;
gPri = NULL;
gSaveArena = NULL;
}
bool gfxInit(void) {
gVis = (uint8_t *)jlAlloc(AGI_PIXELS);
gPri = (uint8_t *)jlAlloc(AGI_PIXELS);
gSaveArena = (uint8_t *)jlAlloc(SAVE_ARENA_BYTES);
if (gVis == NULL || gPri == NULL || gSaveArena == NULL) {
gfxFree();
return false;
}
memset(gVis, AGI_COLOR_BLACK, AGI_PIXELS);
memset(gPri, AGI_PIC_BG_PRIORITY, AGI_PIXELS);
gSaveTop = 0u;
gBlitCount = 0u;
gStaticBlits = 0u;
gDirtyCount = 0u;
gPicShown = false;
return true;
}
// Copy every area that changed since the last call to the stage.
void gfxPresentDirty(void) {
uint8_t i;
if (gPicShown && !gAgi->textMode) {
for (i = 0u; i < gDirtyCount; i++) {
pushRect(&gDirty[i]);
}
}
gDirtyCount = 0u;
}
// After a change to which objects are drawn or updated: take them all off
// and put them back in the right order.
void gfxRedrawAll(void) {
gfxEraseAll();
gfxDrawStatic();
gfxPresentDirty();
}
void gfxRedrawUpdating(void) {
drawList(true);
}
// Copy the whole picture area to the stage (after text mode or a window).
void gfxRestorePicture(void) {
GfxRectT r;
if (!gPicShown || gAgi->textMode) {
return;
}
r.x = 0;
r.y = 0;
r.w = AGI_WIDTH;
r.h = AGI_HEIGHT;
pushRect(&r);
}
// shake.screen: the whole screen jumps and comes back, beat after beat,
// as long as the game waits (keys, sound and the clock carry on). Each
// phase lasts SHAKE_PHASE_FRAMES of this display's frames, counted from
// when it was put up, so a machine that takes longer than that to change
// the whole screen (the IIgs) shows each phase for as long as that takes.
// Without the memory for the two screens it is just as long a pause.
void gfxShakeScreen(uint8_t times) {
jlSurfaceT *normal;
jlSurfaceT *shifted;
uint16_t beats;
uint32_t phaseMs;
normal = jlSurfaceCreate();
shifted = jlSurfaceCreate();
if (normal == NULL || shifted == NULL) {
jlSurfaceDestroy(shifted);
jlSurfaceDestroy(normal);
normal = NULL;
shifted = NULL;
} else {
jlSurfaceCopy(normal, jlStageGet());
buildShifted(shifted, normal);
}
phaseMs = (uint32_t)SHAKE_PHASE_FRAMES * MS_PER_SECOND / jlFrameHz();
beats = (uint16_t)((uint16_t)times * SHAKE_BEATS_PER_UNIT);
do {
shakePhase(shifted, phaseMs);
shakePhase(normal, phaseMs);
beats--;
} while (beats != 0u);
jlSurfaceDestroy(shifted);
jlSurfaceDestroy(normal);
}
// show.obj: a view's first cel at the bottom middle of the picture, on
// the stage only: drawn into the buffers over everything, copied out and
// the buffers put back (the caller restores the screen after).
void gfxShowObjectCel(uint8_t viewId) {
const AgiCelT *cel;
bool mirror;
GfxRectT r;
uint8_t *save;
cel = agiViewCel(agiViewGet(viewId), 0u, 0u, &mirror);
if (cel == NULL || cel->width > AGI_WIDTH || cel->height > AGI_HEIGHT) {
return;
}
r.x = (int16_t)((AGI_MAX_X - (int16_t)cel->width) / 2);
r.y = (int16_t)(AGI_HEIGHT - (int16_t)cel->height);
r.w = cel->width;
r.h = cel->height;
if ((uint32_t)gSaveTop + 2u * (uint32_t)r.w * (uint32_t)r.h > SAVE_ARENA_BYTES) {
return;
}
save = &gSaveArena[gSaveTop];
saveRect(save, r.x, r.y, cel->width, cel->height);
drawCel(cel, mirror, r.x, r.y, AGI_PRIORITY_MAX, true);
pushRect(&r);
restoreRect(save, r.x, r.y, cel->width, cel->height);
}
void gfxShowPic(void) {
gPicShown = true;
gDirtyCount = 0u;
gfxRestorePicture();
}
// show.pri.screen: the priority buffer as colours until a key.
void gfxShowPriority(void) {
uint8_t *saved;
saved = gVis;
gVis = gPri;
gfxRestorePicture();
gVis = saved;
(void)inputWaitKey();
gfxRestorePicture();
}