joeylib2/docs/ProgramLayout.adoc

389 lines
12 KiB
Text

== Anatomy of a JoeyLib Program
Every JoeyLib program has the same four-part shape: configure and initialise,
set up the screen and load assets, run a frame loop, shut down. This chapter
walks through each part and ends with a complete annotated skeleton you can
copy.
=== Part 1: Configure and Initialise
[source,c]
----
jlConfigT config;
config.codegenBytes = 32UL * 1024; // sprite blitter cache
config.noAudio = false; // jlInit starts audio too
if (!jlInit(&config)) {
// jlLastError() explains why. There is no display yet, so on a
// real machine the only place to report this is stderr or a log.
return 1;
}
----
`codegenBytes` sizes the *codegen arena*: the scratch memory the library emits
native sprite blitter code into. Getting it wrong is the most common cause of
sprites drawing slowly, so it is worth understanding.
* `0` selects a per-platform default: 8 KB on the IIgs, 64 KB on the
Amiga, ST, and X68000, and 32 KB on DOS, DVX, and Win16. The other hosted
ports interpret every sprite and allocate no arena at all.
* The 68000 and x86 emitters produce code roughly four to eight times less
dense than the 65816 one, so the same sprite needs far more arena space on
those ports. A 32x32 sprite can need 8 to 16 KB there.
* Measure, do not guess: after compiling a sprite, `jlSpriteCompiledSize()`
reports its exact cost on the current platform.
* Every port takes as much of the request as the machine has, and `jlInit`
fails only if it can have none of it; `jlSpriteCodegenBytesTotal()` says
what it got. On the IIgs a compiled sprite cannot cross a 64 KB bank
boundary, so the arena is up to four Memory Manager blocks of at most
64 KB, each inside one bank: 256 KB at most (a bank that is refused is
tried again at 60 KB). Elsewhere it is one block, halved until the heap
has room, down to 4 KB.
If the arena is too small, sprites still draw: they fall back to an
interpreter that is *two to six times slower*, depending on the machine.
Nothing reports this as an error, so a program that never checks can be
silently crawling.
=== Part 2: The Stage, the Palette, and Assets
[source,c]
----
jlSurfaceT *stage = jlStageGet();
----
The *stage* is the library-owned back buffer. You never create or destroy it;
it is valid from `jlInit` to `jlShutdown`. All drawing goes here, and
`jlStagePresent()` copies it to the real display.
Set up colour before drawing:
[source,c]
----
uint16_t colors[JL_SURFACE_COLORS_PER_PALETTE];
// ... fill in 16 entries of $0RGB ...
jlPaletteSet(stage, 0, colors);
jlScbSetRange(stage, 0, JL_SURFACE_HEIGHT - 1, 0);
----
Each surface carries *16 palettes of 16 colours* plus a 200-entry table (one
per scanline) selecting which palette that line uses. That table is the *SCB*,
or scanline control byte table. Using one palette everywhere is the simple
case; assigning different palettes to different bands is how a JoeyLib program
shows more than 16 colours at once.
Load assets after init, because sprite loading needs the codegen arena:
[source,c]
----
jlSpriteT *cels[16];
uint16_t palette[JL_SURFACE_COLORS_PER_PALETTE];
uint16_t count = jlSpriteBankLoad("player.spr", cels, 16, palette);
uint16_t i;
for (i = 0; i < count; i++) {
(void)jlSpriteCompile(cels[i]); // compile NOW, not mid-frame
}
----
[IMPORTANT]
====
`jlSpriteDraw` does *not* compile a sprite on demand. An uncompiled sprite
draws through the interpreter every single time. Call `jlSpriteCompile` during
loading -- otherwise the first draw of each sprite either stalls a frame (if
you compile lazily yourself) or never gets fast at all.
====
=== Part 3: The Frame Loop
The library does not schedule anything. You own the loop. The canonical shape:
[source,c]
----
while (running) {
jlWaitVBL(); // pace to the display's refresh
jlInputPoll(); // refresh keyboard, mouse, joystick
jlAudioFrameTick(); // pump the audio engine
if (jlKeyPressed(JL_KEY_ESCAPE)) {
running = false;
}
updateGame(); // your logic
renderFrame(stage); // your drawing
jlStagePresent(); // make it visible
}
----
Four library calls, in that order, and everything else is yours.
`jlWaitVBL()` blocks until the next vertical blank. On the machine ports that
is the display's own; the hosted ports, and DVX in a window, have none, and
wait for the next tick of a virtual frame clock instead.
`jlInputPoll()` must be called exactly once per frame. The edge predicates
(`jlKeyPressed`, `jlKeyReleased`, and the mouse and joystick equivalents) are
defined relative to the *previous poll*, so calling it twice per frame makes
edges disappear, and not calling it means nothing ever changes.
`jlAudioFrameTick()` advances tracker music and keeps streaming sound effects
fed. Call it once per frame.
`jlStagePresent()` flips the dirty parts of the stage to the display. The
drawing primitives record which rows they touched, so presenting only copies
what actually changed. You do not need to tell it what you drew: every call
that changes the stage's pixels marks them, and the API has no other way to
write them. Palette and SCB changes reach the display at the next present
too.
==== Frame-Rate Independence
The targets do not share a refresh rate: 50 Hz (a PAL Amiga, IIgs, or ST),
55 Hz (X68000), 60 Hz (an NTSC Amiga, IIgs, or ST, and every hosted port's
virtual rate), and 70 Hz (VGA mode 13h, and DVX's virtual rate). A loop
that moves a sprite "two pixels per frame" runs 40 percent faster on DOS than
on a PAL Amiga.
So give every speed per second, never per frame, and work out each frame how
far that is. The millisecond clock does it best. `jlMillisElapsed()` reports
true elapsed wall-clock time, so a frame that took a long time to render
moves things as far as the time it took, rather than counting as one tick:
[source,c]
----
uint32_t now = jlMillisElapsed();
uint32_t elapsed = now - gLastMillis; // milliseconds since the last frame
gLastMillis = now;
----
A thing then moves its speed times `elapsed` / 1000. At 70 frames a second
that is often a fraction of a pixel, which only adds up if positions keep the
fraction, so keep them in fixed point (the skeleton below uses 1/256 of a
pixel) and draw at the whole-pixel part.
`jlFrameHz()` (50 to 70) is the other way: a per-frame step of speed /
`jlFrameHz()` is right while every frame takes one refresh, and falls behind
whenever one takes longer.
=== Part 4: Shut Down
[source,c]
----
jlShutdown();
return 0;
----
`jlShutdown` stops audio, restores the display mode, frees the library's allocations, and
un-hooks interrupt vectors. Sprites and surfaces you created are *not*
released with it: destroy them with `jlSpriteDestroy` and `jlSurfaceDestroy`
before you shut down. The buffers *you* allocated (including tile data you
handed to `jlSpriteCreate`, which is never copied) remain yours to free.
=== The Complete Skeleton
This compiles and runs on every target as-is.
[source,c]
----
// A minimal but complete JoeyLib program.
#include <stddef.h>
#include <joey/joey.h>
#define PLAYER_SIZE 16
#define PLAYER_SPEED 120 // pixels per second, the same on every port
#define SUBPIXEL_SHIFT 8 // positions are kept in 1/256 of a pixel
#define MAX_STEP_MILLIS 100 // after a stall, move no further than this
static jlSurfaceT *jlgStage;
static bool gRunning = true;
static int32_t gPlayerX = 160L << SUBPIXEL_SHIFT;
static int32_t gPlayerY = 100L << SUBPIXEL_SHIFT;
static uint32_t gLastMillis = 0;
static void buildPalette(void);
static int32_t clampPosition(int32_t position, int16_t limit);
static void handleInput(int32_t step);
static void renderFrame(void);
static int32_t stepForFrame(void);
static void buildPalette(void) {
uint16_t colors[JL_SURFACE_COLORS_PER_PALETTE];
uint8_t i;
for (i = 0; i < JL_SURFACE_COLORS_PER_PALETTE; i++) {
colors[i] = 0x000;
}
colors[1] = 0xFFF; // white
colors[2] = 0x0C0; // green
colors[3] = 0x00C; // blue
jlPaletteSet(jlgStage, 0, colors);
jlScbSetRange(jlgStage, 0, JL_SURFACE_HEIGHT - 1, 0);
}
// A position (in 1/256 of a pixel) kept between 0 and limit pixels.
static int32_t clampPosition(int32_t position, int16_t limit) {
int32_t max = (int32_t)limit << SUBPIXEL_SHIFT;
if (position < 0) {
return 0;
}
if (position > max) {
return max;
}
return position;
}
// Moves the player by step (1/256 of a pixel) in each direction held.
static void handleInput(int32_t step) {
if (jlKeyPressed(JL_KEY_ESCAPE)) {
gRunning = false;
}
if (jlKeyDown(JL_KEY_LEFT)) {
gPlayerX -= step;
}
if (jlKeyDown(JL_KEY_RIGHT)) {
gPlayerX += step;
}
if (jlKeyDown(JL_KEY_UP)) {
gPlayerY -= step;
}
if (jlKeyDown(JL_KEY_DOWN)) {
gPlayerY += step;
}
// A joystick, if one is attached, drives the same movement.
if (jlJoystickConnected(JL_JOYSTICK_0)) {
if (jlJoystickX(JL_JOYSTICK_0) < -64) {
gPlayerX -= step;
}
if (jlJoystickX(JL_JOYSTICK_0) > 64) {
gPlayerX += step;
}
}
// Keep the player on screen: the primitives would clip safely, but
// clamping keeps the object whole.
gPlayerX = clampPosition(gPlayerX, JL_SURFACE_WIDTH - PLAYER_SIZE);
gPlayerY = clampPosition(gPlayerY, JL_SURFACE_HEIGHT - PLAYER_SIZE);
}
static void renderFrame(void) {
jlSurfaceClear(jlgStage, 0);
jlDrawRect(jlgStage, 0, 0, JL_SURFACE_WIDTH, JL_SURFACE_HEIGHT, 1);
jlFillRect(jlgStage, (int16_t)(gPlayerX >> SUBPIXEL_SHIFT), (int16_t)(gPlayerY >> SUBPIXEL_SHIFT), PLAYER_SIZE, PLAYER_SIZE, 2);
}
// How far the player moves this frame, in 1/256 of a pixel: PLAYER_SPEED
// times the time since the last frame, whatever the port's frame rate.
static int32_t stepForFrame(void) {
uint32_t now = jlMillisElapsed();
uint32_t elapsed = now - gLastMillis;
gLastMillis = now;
if (elapsed > MAX_STEP_MILLIS) {
elapsed = MAX_STEP_MILLIS;
}
return (int32_t)((((uint32_t)PLAYER_SPEED * elapsed) << SUBPIXEL_SHIFT) / 1000u);
}
int main(void) {
jlConfigT config;
config.codegenBytes = 32UL * 1024;
config.noAudio = false;
if (!jlInit(&config)) {
return 1;
}
jlgStage = jlStageGet();
if (jlgStage == NULL) {
jlShutdown();
return 1;
}
buildPalette();
jlRandomSeed(12345); // deterministic on every port
gLastMillis = jlMillisElapsed();
while (gRunning) {
jlWaitVBL();
jlInputPoll();
jlAudioFrameTick();
handleInput(stepForFrame());
renderFrame();
jlStagePresent();
}
jlShutdown();
return 0;
}
----
=== Two Drawing Strategies
Once you have moving objects over a background, you need to erase them. There
are two patterns, and *mixing them in the same region corrupts the screen*.
==== Save-Under and Restore
Before drawing a sprite, capture what is underneath; next frame, put it back.
[source,c]
----
uint16_t backing[jlSpriteBackupLength(uint16_t, 2, 2)];
jlSpriteBackupT backup;
backup.bytes = (uint8_t *)backing;
backup.sizeBytes = sizeof(backing);
// each frame:
jlSpriteRestoreUnder(stage, &backup); // erase last frame's sprite
jlSpriteSaveAndDraw(stage, sprite, x, y, &backup);
----
Size the buffer with `jlSpriteBackupLength`, and declare it as `uint16_t` (or
otherwise 2-byte aligned): on the Amiga, ST, and X68000 an odd address sends
save and restore down a much slower path, and it crashes the trusted
variants.
==== The Clean-Buffer Pattern
Keep a pristine copy of the background, and restore rectangles from it.
[source,c]
----
jlSurfaceT *clean = jlSurfaceCreate();
// ... draw the static scene into clean, once ...
// each frame:
jlSurfaceCopyRect(stage, clean, x, y, w, h); // erase EVERY region first
// ... then draw ALL sprites ...
----
This scales better with many sprites, and it is the right choice when sprites
overlap. Erase every region first, then draw every sprite -- never interleave
erase and draw.
[WARNING]
====
`jlSurfaceCopyRect` widens the copied region outward to the 16-pixel group grid
on *every* port, so it can touch up to 15 pixels either side of the rectangle
you asked for. That means it can overwrite content just outside your rectangle,
and it silently invalidates any live `jlSpriteBackupT` whose recorded region it
touches. This is exactly why the two patterns must not be mixed in one region.
====