// Palette Exposure Fade: two ways to fade the same picture, side by side.
// @description The plain palette fade and the camera-style exposure fade side by side, running on one shared palette.
//
// Part of the BLIT386 series.
//
// Prerequisites:
// Basics https://demos.blit386.dev/basics
// Sprites https://demos.blit386.dev/sprites
// Palette Presets https://demos.blit386.dev/palette-presets
// Palette Fade https://demos.blit386.dev/palette-fade
// (guides: https://blit386.dev/docs/guides/palette-presets,
// https://blit386.dev/docs/guides/palette#runtime-palette-effects)
//
// Live version: https://demos.blit386.dev/palette-exposure-fade
//
// TWO KINDS OF FADE
//
// In the Palette Fade demo we used BT.paletteFade(). It mixes the numbers a color
// is stored as. Every color gets dimmer by the same share at the same moment, so
// the whole picture sinks into gray together. That is what film editors do AFTER
// a movie is shot, on a computer.
//
// A real camera does something different. It has a hole called an iris, and
// closing it lets in less LIGHT. The picture keeps looking bright for a while,
// then falls away quickly near the end. Bright things like a fireball hang on far
// longer than the dark rocks around it, which go black almost at once.
//
// BT.paletteFadeExposure() copies the camera. It dims light instead of stored
// numbers, and it gives each color its own start time based on how bright that
// color is. Bright colors leave first and arrive last; dark colors leave last and
// arrive first.
//
// WHY FADING UP LOOKS DIFFERENT FROM FADING DOWN
//
// The two directions are not simple reverses of each other, and that is on
// purpose. Every color's start time is set by how bright its LIT end is - but
// which end counts as "lit" depends on which way the fade is going:
//
// Fading up - timed by where each color is headed. A color heading toward
// white starts rising almost at once and arrives early. A color
// heading toward near-black waits, then rushes to catch up.
// Fading down - timed by where each color started. A color that started bright
// keeps its brightness for a while before it begins to dim. A
// color that started dark begins dropping right away.
//
// Same rule both times - "the brightest end of the trip gets the head start" -
// just pointed at a different end of the trip. That is why, on the way up, the
// shadows are the ones that wait; on the way down, the fireball is the one that
// waits instead.
//
// WHY THE SCREEN IS SPLIT
//
// Both halves show the SAME picture with the SAME colors, start at the SAME
// moment, and take the SAME two seconds. The only thing that differs is which
// fade drives them:
//
// Left - BT.paletteFadeRange(), the plain "mix the numbers" fade
// Right - BT.paletteFadeExposure(), the camera-style fade
//
// So anything you see differing between the halves comes from the curve alone.
//
// ONE PICTURE, DRAWN TWICE
//
// The picture is a real image file: public/sprites/mushroom-cloud.png, a 231x240
// painting of an explosion lighting up a canyon. It is drawn with exactly twelve
// different colors, and it happens to hold both extremes this demo needs - a
// blazing white-hot cloud at the top and near-black rock in the shadows.
//
// Getting it onto the screen takes three steps:
//
// 1. SpriteSheet.loadColorsIntoPalette() walks every pixel of the PNG, collects
// the colors it finds, and writes them into palette slots 1..12, darkest
// first. It hands the same list back to us as an array.
// 2. We copy that same list of twelve colors into slots 16..27 as well, so the
// palette now holds the picture's colors twice over, in two separate places.
// 3. sheet.indexize() rewrites the image itself: every pixel stops storing a
// color and starts storing a slot NUMBER, from 1 to 12.
//
// After that, one BT.drawSprite() call draws the image as it is, reading slots
// 1..12. A second call draws the very same image with a palette offset of 15,
// which adds 15 to every pixel's slot number as it is drawn - so the identical
// pixels read slots 16..27 instead. Same picture, second set of colors, and no
// second copy of the image in memory. We met that trick in the Sprite Effects
// demo: https://demos.blit386.dev/sprite-effects
//
// HOW THE TWO FADES STAY OUT OF EACH OTHER'S WAY
//
// A palette is one long list of 256 color slots, and a fade writes into that
// list. If both fades wrote into the same slots they would fight over them, and
// the last one to run each frame would win. So each half owns its own slots:
//
// Slots 1..12 - the right half's picture (the exposure fade)
// Slots 16..27 - the left half's picture (the plain fade)
// Slots 240..251 - the shared UI panel colors (neither fade touches these)
//
// BT.paletteFadeRange(start, end, ...) already takes a slot range, so the left
// half is easy. For the right half we use a trick the engine supports on
// purpose: BT.paletteFadeExposure() leaves alone any slot past the END of the
// target palette you hand it. Our exposure target is only 16 slots long, so the
// fade can only ever reach slots 1..15 and never disturbs the left half or the UI.
//
// THE PALETTE GRID
//
// The engine overlay is open from the first frame, with its palette grid switched
// on, so you can watch the slots themselves rather than only the pictures. Each
// small square is one of the 256 slots, laid out fifteen to a row. The first row
// holds the exposure fade's slots 1..12 and the second row holds the plain fade's
// 16..27, one group directly above the other, so during a fade you can see the
// first group's bright slots run ahead of the second group while its dark slots
// lag behind - the whole point of the effect, as raw numbers.
//
// Press ~ (or tap the symbol in the bottom-left corner) to close the overlay and
// watch just the pictures.
//
// WHAT YOU WILL SEE:
// An explosion over a canyon, drawn twice. The cycle repeats forever:
// 1. Fade up from black - 2 seconds
// 2. Hold, fully lit - 1.5 seconds
// 3. Fade down to black - 2 seconds
// 4. Hold, dark - 1 second
// On the way up, the right fireball lights before the left one and the right
// shadows stay black longer. On the way down, the right fireball is still
// glowing after the left one has gone gray, and the right shadows die first.
// Drag the "Highlight lead" slider to change how strong that difference is.
// At 0 the right half behaves like a plain fade in light; higher is more
// cinematic.
import { function bootstrap(DemoClass: DemoConstructor, options?: BootstrapOptions): Promise<boolean>One-liner bootstrap function for BLIT386 demos.
Handles canvas retrieval and engine initialization. Backend selection
(WebGPU or software fallback) is managed internally by BTAPI.
This function provides a streamlined way to start a demo with sensible defaults
while allowing customization through options.bootstrap, const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT, class Color32Mutable 32-bit RGBA color value with 8-bit channels.Color32, class Rect2iInteger rectangle for pixel-perfect bounds and regions.
Used throughout the engine for sprite regions, display-space bounds, and
zero-allocation geometry helpers. Both convenience getters and allocation-free
`*To()` helpers are provided so callers can choose between readability and
hot-path efficiency.Rect2i, class SpriteSheetSprite-sheet wrapper around a loaded image asset.
The class keeps the original image available for CPU-side inspection while
lazily creating and caching a GPU texture for rendering. When possible,
`load()` also pre-decodes the source into an `ImageBitmap` so texture uploads
preserve pixel-art alpha and color values more reliably.
After calling `indexize()`, the sheet stores palette indices rather than RGBA
data. The GPU texture becomes an `r8uint` format uploaded via `writeTexture`.
The original RGBA bytes are retained so `reindexize()` can re-convert without
reloading the image.SpriteSheet, class Vector2iInteger 2D vector for pixel-perfect positioning.
Used for points, sizes, directions, and camera offsets throughout the engine.
The API includes both allocation-free `*To()` / `*InPlace()` variants and
convenience methods that return new vectors.Vector2i } from 'blit386';
import {
import applyThemeapplyTheme,
import THEME_DEFAULT_START_SLOTTHEME_DEFAULT_START_SLOT,
import THEME_PANEL_OFFSETTHEME_PANEL_OFFSET,
import THEME_TEXT_OFFSETTHEME_TEXT_OFFSET,
import uiui,
import UI_ANCHORSUI_ANCHORS,
} from './shared/ui.js';
/** @typedef {import('blit386').IBTDemo} IBTDemo */
/** @typedef {import('blit386').HardwareSettings} HardwareSettings */
/** @typedef {import('blit386').Palette} Palette */
// The engine runs this many update ticks every second.
const const TICKS_PER_SECOND: 60TICKS_PER_SECOND = 60;
// How long each step of the cycle lasts, counted in ticks.
const const FADE_TICKS: numberFADE_TICKS = 2 * const TICKS_PER_SECOND: 60TICKS_PER_SECOND; // 2 seconds
const const HOLD_LIT_TICKS: 90HOLD_LIT_TICKS = 90; // 1.5 seconds
const const HOLD_DARK_TICKS: 60HOLD_DARK_TICKS = 60; // 1 second
// The fade duration again, but in milliseconds, because the fade calls want
// milliseconds rather than ticks.
const const FADE_MS: numberFADE_MS = (const FADE_TICKS: numberFADE_TICKS / const TICKS_PER_SECOND: 60TICKS_PER_SECOND) * 1000;
// The cycle as a simple map: each step says how long it lasts and what comes next.
const const PHASE_TRANSITIONS: {
'fade-in': {
duration: number;
next: string;
};
lit: {
duration: number;
next: string;
};
'fade-out': {
duration: number;
next: string;
};
dark: {
duration: number;
next: string;
};
}
PHASE_TRANSITIONS = {
'fade-in': { duration: numberduration: const FADE_TICKS: numberFADE_TICKS, next: stringnext: 'lit' },
lit: {
duration: number;
next: string;
}
lit: { duration: numberduration: const HOLD_LIT_TICKS: 90HOLD_LIT_TICKS, next: stringnext: 'fade-out' },
'fade-out': { duration: numberduration: const FADE_TICKS: numberFADE_TICKS, next: stringnext: 'dark' },
dark: {
duration: number;
next: string;
}
dark: { duration: numberduration: const HOLD_DARK_TICKS: 60HOLD_DARK_TICKS, next: stringnext: 'fade-in' },
};
// The picture both halves draw. A PNG painted with exactly twelve colors.
const const SPRITE_URL: "/sprites/mushroom-cloud.png"SPRITE_URL = '/sprites/mushroom-cloud.png';
// The right half's slots. Slot 0 is always transparent, so scene colors start at 1.
const const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT = 1;
// The left half's slots. Just above the exposure target's 16 slots - the exposure
// fade's own reach stops at slot 15, so this is out of its way.
const const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT = 16;
// The exposure fade's target palette is deliberately small. 16 is the smallest
// legal palette size that still holds our twelve scene colors, and its size is
// what fences the fade off from every slot above it.
const const EXPOSURE_TARGET_SIZE: 16EXPOSURE_TARGET_SIZE = 16;
// Readable names for each step of the cycle, shown in the engine overlay above the FPS bar.
const const PHASE_LABELS: {
'fade-in': string;
lit: string;
'fade-out': string;
dark: string;
}
PHASE_LABELS = {
'fade-in': 'Fading up',
lit: stringlit: 'Lit',
'fade-out': 'Fading down',
dark: stringdark: 'Dark',
};
// Screen geometry. The display is 480x480, so each half of the split is 240 wide.
//
// The vertical numbers are chosen around the engine overlay, which is open from the
// first frame here: it draws a few rows of text across the top and the palette grid
// across the bottom, and it draws AFTER the demo, so whatever it covers is lost.
// Everything this demo draws therefore lives in the free band between the two.
const const HALF_WIDTH: 240HALF_WIDTH = 240;
const const TITLE_Y: 44TITLE_Y = 44;
const const SCENE_TOP: 62SCENE_TOP = 62;
// The picture's own height in pixels - mushroom-cloud.png is 231x240, and the rows
// below it are stacked from this number. init() checks the loaded image against it
// and complains in the console if the art is ever swapped for a different size.
const const SCENE_HEIGHT: 240SCENE_HEIGHT = 240;
// The strip of color swatches under each picture, four pixels below it.
const const RAMP_TOP: numberRAMP_TOP = const SCENE_TOP: 62SCENE_TOP + const SCENE_HEIGHT: 240SCENE_HEIGHT + 4;
const const RAMP_HEIGHT: 22RAMP_HEIGHT = 22;
// Top of the demo's own control panel, just under the swatch strip and clear of the
// overlay's palette band. Pinned rather than anchored to the bottom of the screen,
// which is where the overlay lives.
const const PANEL_Y: numberPANEL_Y = const RAMP_TOP: numberRAMP_TOP + const RAMP_HEIGHT: 22RAMP_HEIGHT + 6;
/**
* Copies a list of colors into a palette, starting at `firstSlot`.
*
* @param {Palette} palette - Palette to fill.
* @param {number} firstSlot - Slot that receives the first color of the list.
* @param {readonly Color32[]} colors - Colors to write, in order.
*/
function function fillColors(palette: Palette, firstSlot: number, colors: readonly Color32[]): voidCopies a list of colors into a palette, starting at `firstSlot`.fillColors(palette: Palette- Palette to fill.palette, firstSlot: number- Slot that receives the first color of the list.firstSlot, colors: {}- Colors to write, in order.colors) {
for (let let i: numberi = 0; let i: numberi < colors: {}- Colors to write, in order.colors.length; let i: numberi++) {
palette: Palette- Palette to fill.palette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(firstSlot: number- Slot that receives the first color of the list.firstSlot + let i: numberi, colors: {}- Colors to write, in order.colors[let i: numberi]);
}
}
/**
* Writes plain black into a run of slots, for the "faded out" target.
*
* @param {Palette} palette - Palette to fill.
* @param {number} firstSlot - First slot of the run.
* @param {number} count - How many slots to blacken.
*/
function function fillBlack(palette: Palette, firstSlot: number, count: number): voidWrites plain black into a run of slots, for the "faded out" target.fillBlack(palette: Palette- Palette to fill.palette, firstSlot: number- First slot of the run.firstSlot, count: number- How many slots to blacken.count) {
for (let let i: numberi = 0; let i: numberi < count: number- How many slots to blacken.count; let i: numberi++) {
palette: Palette- Palette to fill.palette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(firstSlot: number- First slot of the run.firstSlot + let i: numberi, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(0, 0, 0));
}
}
/**
* Shows the difference between BT.paletteFade and BT.paletteFadeExposure by
* running both on the same picture at the same time, on separate palette slots.
*
* @implements {IBTDemo}
*/
class class DemoShows the difference between BT.paletteFade and BT.paletteFadeExposure by
running both on the same picture at the same time, on separate palette slots.Demo {
/** @type {Palette | null} */
Demo.palette: Palette | nullpalette = null;
// The picture, and the rectangle covering all of it.
/** @type {SpriteSheet | null} */
Demo.sheet: SpriteSheet | nullsheet = null;
/** @type {Rect2i | null} */
Demo.spriteRect: Rect2i | nullspriteRect = null;
// The colors found inside the PNG, darkest first, and how many there were.
/** @type {Color32[]} */
Demo.sceneColors: {}sceneColors = [];
Demo.colorCount: numbercolorCount = 0;
// How far in from the left edge of a half the picture sits, so it ends up centered.
Demo.sceneX: numbersceneX = 0;
// The four fade destinations, built once in init(). Each fade needs a palette
// holding the colors it should arrive at.
/** @type {Palette | null} */
Demo.expLit: Palette | nullexpLit = null;
/** @type {Palette | null} */
Demo.expDark: Palette | nullexpDark = null;
/** @type {Palette | null} */
Demo.plainLit: Palette | nullplainLit = null;
/** @type {Palette | null} */
Demo.plainDark: Palette | nullplainDark = null;
// Palette slots of the shared UI colors, filled by applyTheme() in init().
Demo.theme: nulltheme = null;
// Where we are in the fade-in / hold / fade-out / hold cycle.
Demo.phase: stringphase = 'dark';
// The tick the current step of the cycle started on.
Demo.phaseStartTick: numberphaseStartTick = 0;
// Whether this step has already fired its fades. Without this the fades would
// restart on every single frame and never get anywhere.
Demo.effectTriggered: booleaneffectTriggered = false;
// How strongly the exposure fade separates bright colors from dark ones.
// 0 makes the right half behave like a plain fade in light; higher is more
// cinematic. The slider in the UI panel writes to this.
Demo.highlightLead: numberhighlightLead = 0.5;
// Reused every frame so the engine overlay row does not allocate a new object.
Demo.overlayRowData: {}overlayRowData = [{ leftText: stringleftText: 'Dark' }];
/**
* Opens the engine overlay with its palette grid already switched on.
*
* The grid draws all 256 palette slots as small swatches, so you can watch the
* two fades move their own slots at their own pace instead of inferring it from
* the picture. Slots 1..12 are the exposure fade and 16..27 the plain fade, so
* during a fade the first group visibly runs ahead of the second on the way up
* and behind it on the way down.
*
* The overlay starts open here because that grid is half the point of the demo.
* Press ~ (or tap the symbol in the bottom-left corner) to close it and watch
* the pictures on their own.
*
* @returns {Partial<HardwareSettings>}
*/
Demo.configure(): Partial<HardwareSettings>Opens the engine overlay with its palette grid already switched on.
The grid draws all 256 palette slots as small swatches, so you can watch the
two fades move their own slots at their own pace instead of inferring it from
the picture. Slots 1..12 are the exposure fade and 16..27 the plain fade, so
during a fade the first group visibly runs ahead of the second on the way up
and behind it on the way down.
The overlay starts open here because that grid is half the point of the demo.
Press ~ (or tap the symbol in the bottom-left corner) to close it and watch
the pictures on their own.configure() {
return {
// A square screen: two 240-wide pictures side by side, with enough height
// left over for the overlay's palette grid underneath them.
displaySize: Vector2idisplaySize: new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(480, 480),
maxCanvasSize: Vector2imaxCanvasSize: new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(960, 960),
isOverlayPaletteEnabled: booleanisOverlayPaletteEnabled: true,
isOverlayVisibleAtStart: booleanisOverlayVisibleAtStart: true,
overlayPaletteRowsVisible: numberoverlayPaletteRowsVisible: 2,
overlayPaletteColumns: numberoverlayPaletteColumns: 15,
// The overlay defaults to drawing itself with slots 1 and 2 - which in
// this demo are two of the picture's colors, and fade to black along with
// everything else. So point it at the shared UI theme colors instead,
// which neither fade can reach. configure() runs before init(), so the
// slot numbers are derived here rather than read from this.theme.
overlayStyle: {
barPaletteIndex: any;
textPaletteIndex: any;
gapPaletteIndex: any;
}
overlayStyle: {
barPaletteIndex: anybarPaletteIndex: import THEME_DEFAULT_START_SLOTTHEME_DEFAULT_START_SLOT + import THEME_PANEL_OFFSETTHEME_PANEL_OFFSET,
textPaletteIndex: anytextPaletteIndex: import THEME_DEFAULT_START_SLOTTHEME_DEFAULT_START_SLOT + import THEME_TEXT_OFFSETTHEME_TEXT_OFFSET,
gapPaletteIndex: anygapPaletteIndex: import THEME_DEFAULT_START_SLOTTHEME_DEFAULT_START_SLOT + import THEME_PANEL_OFFSETTHEME_PANEL_OFFSET,
},
};
}
/**
* Loads the picture, builds the palette and the four fade targets, and starts
* the cycle.
*
* @returns {Promise<boolean>}
*/
async Demo.init(): Promise<boolean>Loads the picture, builds the palette and the four fade targets, and starts
the cycle.init() {
// The live palette both halves draw from.
this.Demo.palette: Palette | nullpalette = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(256);
// Install the shared UI colors in slots 240..251 before activating the
// palette. Neither fade can reach that high, so the panel stays readable
// the whole way through.
this.Demo.theme: nulltheme = import applyThemeapplyTheme(this.Demo.palette: Palettepalette);
// The exposure fade's targets are small on purpose - see the header comment.
// Reading the PNG's colors straight into this one kills two birds: it becomes
// the "fully lit" target for the right half, AND the palette we hand to
// indexize() below, which is what decides that pixel colors become slots
// 1..12 rather than any other run of numbers.
this.Demo.expLit: Palette | nullexpLit = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(const EXPOSURE_TARGET_SIZE: 16EXPOSURE_TARGET_SIZE);
try {
this.Demo.sceneColors: {}sceneColors = await class SpriteSheetSprite-sheet wrapper around a loaded image asset.
The class keeps the original image available for CPU-side inspection while
lazily creating and caching a GPU texture for rendering. When possible,
`load()` also pre-decodes the source into an `ImageBitmap` so texture uploads
preserve pixel-art alpha and color values more reliably.
After calling `indexize()`, the sheet stores palette indices rather than RGBA
data. The GPU texture becomes an `r8uint` format uploaded via `writeTexture`.
The original RGBA bytes are retained so `reindexize()` can re-convert without
reloading the image.SpriteSheet.SpriteSheet.loadColorsIntoPalette(url: string, palette: Palette, startSlot: number, options?: {
sort?: "luminance" | "none";
}): Promise<Color32[]>
Walks a PNG's pixels and registers every unique opaque color into the
supplied palette starting at `startSlot`.
Pixels with alpha 0 are skipped - they map to the engine's transparent
sentinel slot 0 at draw time. Opaque pixels are deduplicated on RGB and
stored with alpha forced to 255, matching the lookup performed by
`indexize()` so a subsequent `sheet.indexize(palette)` call resolves
without throwing on missing colors.
By default colors are sorted darkest-first by perceived luminance
(
{@link
Color32.luminance
}
); pass `{ sort: 'none' }` to keep the
row-major scan order of the source image.
Image loading goes through
{@link
AssetLoader.loadImage
}
, so the call
shares cache and in-flight deduplication with
{@link
SpriteSheet.load
}
.
The destination range is validated before any write, so the palette is
never left partially mutated: if the collected colors would not fit
(`startSlot < 1` or `startSlot + count > palette.size`), the method
throws without touching any slot.loadColorsIntoPalette(const SPRITE_URL: "/sprites/mushroom-cloud.png"SPRITE_URL, this.Demo.expLit: PaletteexpLit, const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT);
} catch (function (local var) error: unknownerror) {
console.error('[PaletteExposureFadeDemo] Failed to read sprite colors:', function (local var) error: unknownerror);
return false;
}
this.Demo.colorCount: numbercolorCount = this.Demo.sceneColors: {}sceneColors.length;
console.log(`[PaletteExposureFadeDemo] Found ${this.Demo.colorCount: numbercolorCount} unique colors in ${const SPRITE_URL: "/sprites/mushroom-cloud.png"SPRITE_URL}`);
// Slot 0 is reserved for transparency, so the 16-slot exposure target has
// room for 15 colors at most. Any more and the picture's colors would spill
// into the left half's slots and the two fades would fight over them.
const const maxColors: numbermaxColors = const EXPOSURE_TARGET_SIZE: 16EXPOSURE_TARGET_SIZE - const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT;
if (this.Demo.colorCount: numbercolorCount > const maxColors: numbermaxColors) {
console.error(
`[PaletteExposureFadeDemo] ${const SPRITE_URL: "/sprites/mushroom-cloud.png"SPRITE_URL} uses ${this.Demo.colorCount: numbercolorCount} colors, but only ${const maxColors: numbermaxColors} fit.`,
);
return false;
}
// The same colors again, as the left half's "fully lit" target. The plain
// fade takes an explicit slot range, so its targets are full size.
this.Demo.plainLit: Palette | nullplainLit = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(256);
function fillColors(palette: Palette, firstSlot: number, colors: readonly Color32[]): voidCopies a list of colors into a palette, starting at `firstSlot`.fillColors(this.Demo.plainLit: PaletteplainLit, const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT, this.Demo.sceneColors: {}sceneColors);
// The two "faded out" targets: the same runs of slots, but black.
this.Demo.expDark: Palette | nullexpDark = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(const EXPOSURE_TARGET_SIZE: 16EXPOSURE_TARGET_SIZE);
function fillBlack(palette: Palette, firstSlot: number, count: number): voidWrites plain black into a run of slots, for the "faded out" target.fillBlack(this.Demo.expDark: PaletteexpDark, const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT, this.Demo.colorCount: numbercolorCount);
this.Demo.plainDark: Palette | nullplainDark = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(256);
function fillBlack(palette: Palette, firstSlot: number, count: number): voidWrites plain black into a run of slots, for the "faded out" target.fillBlack(this.Demo.plainDark: PaletteplainDark, const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT, this.Demo.colorCount: numbercolorCount);
// Now the image itself. This second read costs nothing extra: the engine's
// asset loader caches the decoded PNG, so it is the very same image the
// color scan above walked.
try {
this.Demo.sheet: SpriteSheet | nullsheet = await class SpriteSheetSprite-sheet wrapper around a loaded image asset.
The class keeps the original image available for CPU-side inspection while
lazily creating and caching a GPU texture for rendering. When possible,
`load()` also pre-decodes the source into an `ImageBitmap` so texture uploads
preserve pixel-art alpha and color values more reliably.
After calling `indexize()`, the sheet stores palette indices rather than RGBA
data. The GPU texture becomes an `r8uint` format uploaded via `writeTexture`.
The original RGBA bytes are retained so `reindexize()` can re-convert without
reloading the image.SpriteSheet.SpriteSheet.load(url: string): Promise<SpriteSheet>Loads a sprite sheet from an image URL.
Attempts to create an `ImageBitmap` with explicit alpha and color-space
settings for more predictable GPU uploads. If bitmap creation fails, the
instance still works and falls back to uploading the `HTMLImageElement`.load(const SPRITE_URL: "/sprites/mushroom-cloud.png"SPRITE_URL);
} catch (function (local var) error: unknownerror) {
console.error('[PaletteExposureFadeDemo] Failed to load sprite:', function (local var) error: unknownerror);
return false;
}
// A source rectangle covering the whole image, and the gap that centers it
// inside its 240-wide half of the screen.
this.Demo.spriteRect: Rect2i | nullspriteRect = this.Demo.sheet: SpriteSheet | nullsheet.SpriteSheet.fullRect(): Rect2iReturns a source rectangle that covers the entire sprite sheet.fullRect();
this.Demo.sceneX: numbersceneX = Math.floor((const HALF_WIDTH: 240HALF_WIDTH - this.Demo.spriteRect: Rect2ispriteRect.Rect2i.width: numberWidth in pixels (defaults to 0).width) / 2);
if (this.Demo.spriteRect: Rect2ispriteRect.Rect2i.height: numberHeight in pixels (defaults to 0).height !== const SCENE_HEIGHT: 240SCENE_HEIGHT) {
console.warn(
`[PaletteExposureFadeDemo] ${const SPRITE_URL: "/sprites/mushroom-cloud.png"SPRITE_URL} is ${this.Demo.spriteRect: Rect2ispriteRect.Rect2i.height: numberHeight in pixels (defaults to 0).height}px tall, ` +
`but the layout expects ${const SCENE_HEIGHT: 240SCENE_HEIGHT}px. Update SCENE_HEIGHT.`,
);
}
// Turn the picture's pixels into slot numbers. Every pixel is looked up in
// expLit, where the colors sit at slots 1..12, so that is what each pixel
// stores from here on.
this.Demo.sheet: SpriteSheet | nullsheet.SpriteSheet.indexize(palette: Palette): voidConverts the sprite sheet's RGBA pixels to palette indices.
Each non-transparent pixel is looked up in the provided palette via exact
color matching. Index 0 is always transparent. The resulting indices are
stored internally; an `r8uint` GPU texture is created lazily on the next
`getTexture()` call.
The original RGBA data is retained so `reindexize()` can re-convert after a
palette swap without reloading the image.indexize(this.Demo.expLit: PaletteexpLit);
// Start the picture black, so the first thing a viewer sees is a fade up.
this.Demo.resetPictureToBlack(): voidPaints both halves of the picture black, undoing whatever a fade left behind.resetPictureToBlack();
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteSet: (palette: Palette) => voidStores the active engine palette.
Use this to swap the **entire palette** (e.g. switch between a day and night
theme). After this call the renderer uploads the new palette uniform on the
next frame.
**Palette-value swap (change what a slot looks like):** mutate the live
{@link
BT.palette
}
in place with `palette.set(slot, newColor)`. The renderer
uploads dirty slots on the next frame; no `paletteSet()` or
{@link
BT.spritesRefresh
}
needed.
**Palette-layout swap (same colors, different slot positions):** build a new
palette with the same colors at new indices, call `paletteSet()`, then call
{@link
BT.spritesRefresh
}
so every sprite sheet re-maps its original RGBA
pixels against the new slot layout.paletteSet(this.Demo.palette: Palettepalette);
return true;
}
/**
* Runs the cycle: fires both fades at the start of a fade step, then waits.
*/
Demo.update(): voidRuns the cycle: fires both fades at the start of a fade step, then waits.update() {
// Lets the UI kit latch key presses, taps, and slider drags.
import uiui.tick();
const const tick: numbertick = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.ticks: numberCurrent fixed-update tick counter.
Increments once per engine update. Reset via
{@link
BT.ticksReset
}
.ticks;
this.Demo.triggerPhaseEffect(): voidFires this step's two fades, once per step.triggerPhaseEffect();
this.Demo.advancePhaseIfExpired(elapsed: number, tick: number): voidMoves to the next step of the cycle once the current one has run long enough.advancePhaseIfExpired(const tick: numbertick - this.Demo.phaseStartTick: numberphaseStartTick, const tick: numbertick);
}
/**
* Draws the picture twice and the UI panel on top.
*/
Demo.render(): voidDraws the picture twice and the UI panel on top.render() {
// A dark backdrop so the two halves read as separate pictures.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.clear: (paletteIndex: number) => voidSets the frame clear color using a palette index.
The renderer uses this color when clearing the full display at the start
of the next frame.clear(this.Demo.theme: nulltheme.shadow);
// Left half: the plain fade. Right half: the exposure fade. The same sprite
// both times - only the run of palette slots it reads differs.
this.Demo.renderScene(originX: number, firstSlot: number): voidDraws one copy of the picture, plus the swatch strip under it.renderScene(0, const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT);
this.Demo.renderScene(originX: number, firstSlot: number): voidDraws one copy of the picture, plus the swatch strip under it.renderScene(const HALF_WIDTH: 240HALF_WIDTH, const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT);
// Name each half, using UI colors the fades never touch.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.systemPrint: (pos: Vector2i, paletteIndex: number, text: string) => voidDraws text using the built-in 6x14 system font.
The system font covers printable ASCII (characters 32-126). For custom
bitmap fonts with proportional glyphs, use
{@link
BT.printFont
}
instead.systemPrint(new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(7, const TITLE_Y: 44TITLE_Y), this.Demo.theme: nulltheme.dim, 'paletteFade:');
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.systemPrint: (pos: Vector2i, paletteIndex: number, text: string) => voidDraws text using the built-in 6x14 system font.
The system font covers printable ASCII (characters 32-126). For custom
bitmap fonts with proportional glyphs, use
{@link
BT.printFont
}
instead.systemPrint(new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(const HALF_WIDTH: 240HALF_WIDTH + 7, const TITLE_Y: 44TITLE_Y), this.Demo.theme: nulltheme.header, 'paletteFadeExposure:');
// Pinned to PANEL_Y rather than anchored to the bottom of the screen: the
// overlay's palette grid owns the bottom, and it draws after this.
import uiui.begin(import UI_ANCHORSUI_ANCHORS.TOP_LEFT, { y: numbery: const PANEL_Y: numberPANEL_Y });
import uiui.panel('Exposure Fade');
// Dragging this changes the next fade, not the one already running - a fade
// captures its settings the moment it starts.
this.Demo.highlightLead: numberhighlightLead = import uiui.slider('Highlight lead', this.Demo.highlightLead: numberhighlightLead, { min: numbermin: 0, max: numbermax: 0.95, width: numberwidth: 456 });
// A tap target as well as a key, so the demo works on a phone.
if (import uiui.button('Restart [R]', { key: stringkey: 'KeyR' })) {
this.Demo.restart(): voidCancels anything running and starts the cycle over from black.restart();
}
import uiui.end();
}
/**
* Current cycle step, shown in the engine overlay above the FPS bar.
*
* @returns {readonly { leftText: string }[]}
*/
Demo.overlayRows(): readonly {
leftText: string;
}[]
Current cycle step, shown in the engine overlay above the FPS bar.overlayRows() {
this.Demo.overlayRowData: {}overlayRowData[0].leftText = this.Demo.getPhaseLabel(): stringA readable name for the step of the cycle we are in.getPhaseLabel();
return this.Demo.overlayRowData: {}overlayRowData;
}
/**
* A readable name for the step of the cycle we are in.
*
* @returns {string}
*/
Demo.getPhaseLabel(): stringA readable name for the step of the cycle we are in.getPhaseLabel() {
return const PHASE_LABELS: {
'fade-in': string;
lit: string;
'fade-out': string;
dark: string;
}
PHASE_LABELS[this.Demo.phase: stringphase];
}
/**
* Fires this step's two fades, once per step.
*/
Demo.triggerPhaseEffect(): voidFires this step's two fades, once per step.triggerPhaseEffect() {
// Already fired for this step, or this step has no fade at all.
if (this.Demo.effectTriggered: booleaneffectTriggered) {
return;
}
if (this.Demo.phase: stringphase === 'fade-in') {
this.Demo.startFades(plainTarget: Palette, exposureTarget: Palette): voidStarts both fades on the same frame with the same duration and easing.startFades(this.Demo.plainLit: Palette | nullplainLit, this.Demo.expLit: Palette | nullexpLit);
} else if (this.Demo.phase: stringphase === 'fade-out') {
this.Demo.startFades(plainTarget: Palette, exposureTarget: Palette): voidStarts both fades on the same frame with the same duration and easing.startFades(this.Demo.plainDark: Palette | nullplainDark, this.Demo.expDark: Palette | nullexpDark);
}
}
/**
* Starts both fades on the same frame with the same duration and easing.
*
* @param {Palette} plainTarget - Where the left half's colors should end up.
* @param {Palette} exposureTarget - Where the right half's colors should end up.
*/
Demo.startFades(plainTarget: Palette, exposureTarget: Palette): voidStarts both fades on the same frame with the same duration and easing.startFades(plainTarget: Palette- Where the left half's colors should end up.plainTarget, exposureTarget: Palette- Where the right half's colors should end up.exposureTarget) {
// Left half: the plain fade, limited to the slots the left picture uses.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteFadeRange: (start: number, end: number, target: Palette, durationMs: number, easing?: EasingFunction) => voidFades only a subset of palette indices toward a target over time.
Same as
{@link
BT.paletteFade
}
but restricted to the range `[start, end]`.
Indices outside the range are left untouched.paletteFadeRange(const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT, const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT + this.Demo.colorCount: numbercolorCount - 1, plainTarget: Palette- Where the left half's colors should end up.plainTarget, const FADE_MS: numberFADE_MS);
// Right half: the exposure fade. It only reaches slots 1..15 because the
// target palette we hand it is 16 slots long.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteFadeExposure: (target: Palette, durationMs: number, options?: ExposureFadeOptions) => voidFades all palette entries toward a target the way an iris pull does.
{@link
BT.paletteFade
}
interpolates encoded color values, which is a
post-production crossfade: every entry drops by the same proportion for the
whole fade and the image sags uniformly into gray. This one interpolates
each RGB channel in linear light instead, and offsets each entry's schedule
by its luminance, so bright entries come up first and hold on longest while
dark entries arrive late and crush early. The fade still lands exactly on
the target at completion.
Fading up from black or down to black, which is the usual case, that
interpolation is exactly scaling light the way an iris does.
`highlightLead` is the knob: `0` is a plain linear-light fade with every
entry on one schedule, higher values push the highlights further ahead.
Defaults to `0.5`.
This is a per-index effect, not a per-pixel one - a dark object in a bright
scene fades on the dark schedule regardless of what surrounds it, because
the engine only knows its palette slot.
Slots past the end of `target` are left alone, so passing a smaller target
palette scopes the fade to its own slots.
Common patterns:
- Cinematic fade up from black: `BT.paletteFadeExposure(gamePalette, 1500)`
- Subtle version: `BT.paletteFadeExposure(gamePalette, 1500, { highlightLead: 0.2 })`
- Fade out: `BT.paletteFadeExposure(blackPalette, 1000)`paletteFadeExposure(exposureTarget: Palette- Where the right half's colors should end up.exposureTarget, const FADE_MS: numberFADE_MS, { ExposureFadeOptions.highlightLead?: number | undefinedHow far ahead of the schedule a fully lit entry runs, in range 0-1.
`0` is a plain linear-light fade - every entry on the same schedule. Higher
values push bright entries further ahead on the way up and further behind on
the way down. Defaults to `0.5`. Values outside the range are clamped;
anything at or above the cap is held just below `1` so the schedule stays
divisible.highlightLead: this.Demo.highlightLead: numberhighlightLead });
this.Demo.effectTriggered: booleaneffectTriggered = true;
}
/**
* Moves to the next step of the cycle once the current one has run long enough.
*
* @param {number} elapsed - Ticks since this step started.
* @param {number} tick - The current tick.
*/
Demo.advancePhaseIfExpired(elapsed: number, tick: number): voidMoves to the next step of the cycle once the current one has run long enough.advancePhaseIfExpired(elapsed: number- Ticks since this step started.elapsed, tick: number- The current tick.tick) {
const const current: anycurrent = const PHASE_TRANSITIONS: {
'fade-in': {
duration: number;
next: string;
};
lit: {
duration: number;
next: string;
};
'fade-out': {
duration: number;
next: string;
};
dark: {
duration: number;
next: string;
};
}
PHASE_TRANSITIONS[this.Demo.phase: stringphase];
if (elapsed: number- Ticks since this step started.elapsed >= const current: anycurrent.duration) {
this.Demo.phase: stringphase = const current: anycurrent.next;
this.Demo.phaseStartTick: numberphaseStartTick = tick: number- The current tick.tick;
this.Demo.effectTriggered: booleaneffectTriggered = false;
}
}
/**
* Cancels anything running and starts the cycle over from black.
*/
Demo.restart(): voidCancels anything running and starts the cycle over from black.restart() {
// Effects keep the palette wherever they left it, so paint black by hand.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteClearEffects: () => voidCancels all running palette effects immediately.
The palette stays at whatever state it was in when canceled.paletteClearEffects();
this.Demo.resetPictureToBlack(): voidPaints both halves of the picture black, undoing whatever a fade left behind.resetPictureToBlack();
this.Demo.phase: stringphase = 'fade-in';
this.Demo.phaseStartTick: numberphaseStartTick = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.ticks: numberCurrent fixed-update tick counter.
Increments once per engine update. Reset via
{@link
BT.ticksReset
}
.ticks;
this.Demo.effectTriggered: booleaneffectTriggered = false;
}
/**
* Paints both halves of the picture black, undoing whatever a fade left behind.
*/
Demo.resetPictureToBlack(): voidPaints both halves of the picture black, undoing whatever a fade left behind.resetPictureToBlack() {
function fillBlack(palette: Palette, firstSlot: number, count: number): voidWrites plain black into a run of slots, for the "faded out" target.fillBlack(this.Demo.palette: Palette | nullpalette, const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT, this.Demo.colorCount: numbercolorCount);
function fillBlack(palette: Palette, firstSlot: number, count: number): voidWrites plain black into a run of slots, for the "faded out" target.fillBlack(this.Demo.palette: Palette | nullpalette, const PLAIN_FIRST_SLOT: 16PLAIN_FIRST_SLOT, this.Demo.colorCount: numbercolorCount);
}
/**
* Draws one copy of the picture, plus the swatch strip under it.
*
* @param {number} originX - Left edge of this half of the screen.
* @param {number} firstSlot - Slot holding the picture's first (darkest) color.
*/
Demo.renderScene(originX: number, firstSlot: number): voidDraws one copy of the picture, plus the swatch strip under it.renderScene(originX: number- Left edge of this half of the screen.originX, firstSlot: number- Slot holding the picture's first (darkest) color.firstSlot) {
// Every pixel of the image stores a slot number counted from EXP_FIRST_SLOT.
// drawSprite() adds this number to each of them as it draws, which is how the
// one image reads two different runs of slots: 0 leaves it on slots 1..12, and
// 15 shifts it up onto slots 16..27.
const const paletteOffset: numberpaletteOffset = firstSlot: number- Slot holding the picture's first (darkest) color.firstSlot - const EXP_FIRST_SLOT: 1EXP_FIRST_SLOT;
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawSprite: (spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, paletteOffset?: number) => voidDraws a sprite region from an indexed sprite sheet.
Sprite draws are batched internally. Grouping draws from the same
{@link
SpriteSheet
}
minimizes batch flushes and reduces GPU state changes.
The sprite sheet must have been converted to palette indices via
`spriteSheet.indexize(palette)` before the first draw call. Prefer
`SpriteSheet.loadIndexed(...)` for one-call setup.
**Palette offset semantics:** Sprite pixels are stored as palette indices starting at 1.
Index 0 is always transparent and is discarded by the fragment shader. The final palette
lookup is `storedIndex + paletteOffset`, so:
- `paletteOffset = 0` (default): a sprite pixel stored at index 1 renders as `palette[1]`.
`palette[0]` is never reachable because stored indices start at 1.
- `paletteOffset = N`: shifts the entire sprite's color range up by N slots. A pixel stored
at index 1 renders as `palette[1 + N]`, a pixel at index 2 renders as `palette[2 + N]`,
and so on. Use this for palette-swap effects such as team colors or damage flashes.
**Out-of-range behavior:** No CPU-side validation is performed. `paletteOffset` is passed to
the GPU as a `u32`. If `storedIndex + paletteOffset` exceeds the last palette index, WebGPU's
robust buffer access returns 0 for every component; because the fragment shader forces alpha
to 1.0, the affected pixels render as opaque black. Negative values are forbidden - a negative
JS number written into a `u32` vertex attribute wraps to a large unsigned integer, which also
produces out-of-bounds black pixels.drawSprite(this.Demo.sheet: SpriteSheet | nullsheet, this.Demo.spriteRect: Rect2i | nullspriteRect, new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(originX: number- Left edge of this half of the screen.originX + this.Demo.sceneX: numbersceneX, const SCENE_TOP: 62SCENE_TOP), const paletteOffset: numberpaletteOffset);
this.Demo.renderRamp(originX: number, firstSlot: number): voidDraws the strip of the picture's colors under one copy of it.
The colors arrive from the loader sorted darkest first, so the strip reads as a
plain climb in brightness from left to right. Seeing those steps side by side
makes the ordering of a fade very easy to spot: the plain fade dims all eleven
gaps by the same share at once, while the exposure fade lets the right-hand
swatches lead and the left-hand ones lag.renderRamp(originX: number- Left edge of this half of the screen.originX, firstSlot: number- Slot holding the picture's first (darkest) color.firstSlot);
}
/**
* Draws the strip of the picture's colors under one copy of it.
*
* The colors arrive from the loader sorted darkest first, so the strip reads as a
* plain climb in brightness from left to right. Seeing those steps side by side
* makes the ordering of a fade very easy to spot: the plain fade dims all eleven
* gaps by the same share at once, while the exposure fade lets the right-hand
* swatches lead and the left-hand ones lag.
*
* @param {number} originX - Left edge of this half of the screen.
* @param {number} firstSlot - Slot holding the picture's first (darkest) color.
*/
Demo.renderRamp(originX: number, firstSlot: number): voidDraws the strip of the picture's colors under one copy of it.
The colors arrive from the loader sorted darkest first, so the strip reads as a
plain climb in brightness from left to right. Seeing those steps side by side
makes the ordering of a fade very easy to spot: the plain fade dims all eleven
gaps by the same share at once, while the exposure fade lets the right-hand
swatches lead and the left-hand ones lag.renderRamp(originX: number- Left edge of this half of the screen.originX, firstSlot: number- Slot holding the picture's first (darkest) color.firstSlot) {
// Share the width evenly between the swatches, then center the whole strip.
const const stepWidth: anystepWidth = Math.floor((const HALF_WIDTH: 240HALF_WIDTH - 8) / this.Demo.colorCount: numbercolorCount);
const const stripWidth: numberstripWidth = const stepWidth: anystepWidth * this.Demo.colorCount: numbercolorCount;
const const left: anyleft = originX: number- Left edge of this half of the screen.originX + Math.floor((const HALF_WIDTH: 240HALF_WIDTH - const stripWidth: numberstripWidth) / 2);
for (let let i: numberi = 0; let i: numberi < this.Demo.colorCount: numbercolorCount; let i: numberi++) {
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawRectFill: (rect: Rect2i, paletteIndex: number) => voidDraws a filled rectangle.drawRectFill(new new Rect2i(x?: number, y?: number, width?: number, height?: number): Rect2iCreates an integer rectangle, truncating all inputs toward zero.Rect2i(const left: anyleft + let i: numberi * const stepWidth: anystepWidth, const RAMP_TOP: numberRAMP_TOP, const stepWidth: anystepWidth, const RAMP_HEIGHT: 22RAMP_HEIGHT), firstSlot: number- Slot holding the picture's first (darkest) color.firstSlot + let i: numberi);
}
}
}
function bootstrap(DemoClass: DemoConstructor, options?: BootstrapOptions): Promise<boolean>One-liner bootstrap function for BLIT386 demos.
Handles canvas retrieval and engine initialization. Backend selection
(WebGPU or software fallback) is managed internally by BTAPI.
This function provides a streamlined way to start a demo with sensible defaults
while allowing customization through options.bootstrap(class DemoShows the difference between BT.paletteFade and BT.paletteFadeExposure by
running both on the same picture at the same time, on separate palette slots.Demo);